diff --git a/app/lib/frontend/templates/views/shared/layout.dart b/app/lib/frontend/templates/views/shared/layout.dart index 258e255c4..5f4f575d0 100644 --- a/app/lib/frontend/templates/views/shared/layout.dart +++ b/app/lib/frontend/templates/views/shared/layout.dart @@ -201,9 +201,11 @@ d.Node pageLayoutNode({ d.div( classes: ['dismisser'], attributes: { - 'data-widget': 'dismiss', - 'data-dismiss-target': '.announcement-banner', - 'data-dismiss-message-id': announcementBannerHash, + 'data-widget': 'switch', + 'data-switch-target': '.announcement-banner', + 'data-switch-enabled': 'dismissed', + 'data-switch-initial-state': 'false', + 'data-switch-state-id': announcementBannerHash, }, text: 'x', ), diff --git a/pkg/web_app/lib/src/widget/dismiss/widget.dart b/pkg/web_app/lib/src/widget/dismiss/widget.dart deleted file mode 100644 index 1c9c57cd1..000000000 --- a/pkg/web_app/lib/src/widget/dismiss/widget.dart +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file -// for details. All rights reserved. Use of this source code is governed by a -// BSD-style license that can be found in the LICENSE file. - -import 'dart:js_interop'; - -import 'package:collection/collection.dart'; -import 'package:web/web.dart'; - -import '../../web_util.dart'; - -/// Forget dismissed messages that are more than 2 years old -late final _deadline = DateTime.now().subtract(Duration(days: 365 * 2)); - -/// Don't save more than 50 entries -const _maxMissedMessages = 50; - -/// Create a dismiss widget on [element]. -/// -/// A `data-dismiss-target` is required, this must be a CSS selector for the -/// element(s) that are to be dismissed when this widget is clicked. -/// -/// A `data-dismiss-message-id` is required, this must be a string identifying -/// the message being dismissed. Once dismissed this identifier will be stored -/// in `localStorage`. And next time this widget is instantiated with the same -/// `data-dismiss-message-id` it'll be removed immediately. -/// -/// When in a dismissed state the `data-dismiss-target` elements will have a -/// `dismissed` class added to them. If they have this class initially, it will -/// be removed unless, the message has already been dismissed previously. -/// -/// Identifiers of dismissed messages will be stored for up to 2 years. -/// No more than 50 dismissed messages are retained in `localStorage`. -void create(HTMLElement element, Map options) { - final target = options['target']; - if (target == null) { - throw UnsupportedError('data-dismissible-target required'); - } - final messageId = options['message-id']; - if (messageId == null) { - throw UnsupportedError('data-dismissible-message-id required'); - } - - void applyDismissed(bool enabled) { - for (final e in document.querySelectorAll(target).toList()) { - if (e.isA()) { - final element = e as HTMLHtmlElement; - if (enabled) { - element.classList.add('dismissed'); - } else { - element.classList.remove('dismissed'); - } - } - } - } - - if (_dismissed.any((e) => e.id == messageId)) { - applyDismissed(true); - return; - } else { - applyDismissed(false); - } - - void dismiss(Event e) { - e.preventDefault(); - - applyDismissed(true); - _dismissed.add(( - id: messageId, - date: DateTime.now(), - )); - _saveDismissed(); - } - - element.addEventListener('click', dismiss.toJS); -} - -/// LocalStorage key where we store the identifiers of messages that have been -/// dismissed. -/// -/// Data is stored on the format: `@;@;...`, -/// where: -/// * `` is on the form `YYYY-MM-DD`. -/// * `` is the base64 encoded `data-dismiss-message-id` passed to -/// a dismiss widget. -const _dismissedMessageslocalStorageKey = 'dismissed-messages'; - -late final _dismissed = [ - ...?window.localStorage - .getItem(_dismissedMessageslocalStorageKey) - ?.split(';') - .where((e) => e.contains('@')) - .map((entry) { - final [id, date, ...] = entry.split('@'); - return ( - id: window.atob(id), - date: DateTime.tryParse(date) ?? DateTime.fromMicrosecondsSinceEpoch(0), - ); - }).where((entry) => entry.date.isAfter(_deadline)), -]; - -void _saveDismissed() { - window.localStorage.setItem( - _dismissedMessageslocalStorageKey, - _dismissed - .sortedBy((e) => e.date) // Sort by date - .reversed // Reverse ordering to prefer newest dates - .take(_maxMissedMessages) // Limit how many entries we save - .map( - (e) => - window.btoa(e.id) + - '@' + - e.date.toIso8601String().split('T').first, - ) - .join(';'), - ); -} diff --git a/pkg/web_app/lib/src/widget/switch/widget.dart b/pkg/web_app/lib/src/widget/switch/widget.dart new file mode 100644 index 000000000..09b60880e --- /dev/null +++ b/pkg/web_app/lib/src/widget/switch/widget.dart @@ -0,0 +1,127 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:js_interop'; + +import 'package:collection/collection.dart'; +import 'package:web/web.dart'; + +import '../../web_util.dart'; + +/// Create a switch widget on [element]. +/// +/// A `data-switch-target` is required, this must be a CSS selector for the +/// element(s) that are affected when this widget is clicked. +/// +/// The optional `data-switch-initial-state` property may be used to provide an +/// initial state. The initial state is used if state can be loaded from +/// `localStorage` (because there is none). If not provided, initial state is +/// derived from document state. +/// +/// The optional `data-switch-state-id` property may be used to provide an +/// identifier for the sttae of this switch in `localStorage`. If supplied state +/// will be sync'ed across windows. +/// +/// The optional `data-switch-enabled` property may be used to specify a space +/// separated list of classes to be applied to `data-switch-target` when the +/// switch is enabled. +/// +/// The optional `data-switch-disabled` property may be used to specify a space +/// separated list of classes to be applied to `data-switch-target` when the +/// switch is disabled. +void create(HTMLElement element, Map options) { + final target = options['target']; + if (target == null) { + throw UnsupportedError('data-switch-target required'); + } + final initialState_ = options['initial-state']; + final stateId = options['state-id']; + final enabledClassList = (options['enabled'] ?? '') + .split(' ') + .where((s) => s.isNotEmpty) + .toSet() + .toList(); + final disabledClassList = (options['disabled'] ?? '') + .split(' ') + .where((s) => s.isNotEmpty) + .toSet() + .toList(); + + void applyState(bool enabled) { + for (final e in document.querySelectorAll(target).toList()) { + if (e.isA()) { + final element = e as HTMLHtmlElement; + if (enabled) { + for (final c in enabledClassList) { + element.classList.add(c); + } + for (final c in disabledClassList) { + element.classList.remove(c); + } + } else { + for (final c in enabledClassList) { + element.classList.remove(c); + } + for (final c in disabledClassList) { + element.classList.add(c); + } + } + } + } + } + + bool? initialState; + if (stateId != null) { + void onStorage(StorageEvent e) { + if (e.key == 'switch-$stateId' && e.storageArea == window.localStorage) { + applyState(e.newValue == 'true'); + } + } + + window.addEventListener('storage', onStorage.toJS); + final state = window.localStorage.getItem('switch-$stateId'); + if (state != null) { + initialState = state == 'true'; + } + } + + // Set initialState, if present + if (initialState_ != null) { + initialState ??= initialState_ == 'true'; + } + // If there are no classes to be applied, this is weird, but then we can't + // infer an initial state. + if (enabledClassList.isEmpty && disabledClassList.isEmpty) { + initialState ??= false; + } + // Infer initial state from document state, unless loaded from localStorage + initialState ??= document + .querySelectorAll(target) + .toList() + .where((e) => e.isA()) + .map((e) => e as HTMLElement) + .every( + (e) => + enabledClassList.every((c) => e.classList.contains(c)) && + disabledClassList.none((c) => e.classList.contains(c)), + ); + + applyState(initialState); + var state = initialState; + + void onClick(MouseEvent e) { + if (e.defaultPrevented) { + return; + } + e.preventDefault(); + + state = !state; + applyState(state); + if (stateId != null) { + window.localStorage.setItem('switch-$stateId', state.toString()); + } + } + + element.addEventListener('click', onClick.toJS); +} diff --git a/pkg/web_app/lib/src/widget/widget.dart b/pkg/web_app/lib/src/widget/widget.dart index 3b6d7189b..5afbfe54b 100644 --- a/pkg/web_app/lib/src/widget/widget.dart +++ b/pkg/web_app/lib/src/widget/widget.dart @@ -10,7 +10,7 @@ import 'package:web/web.dart'; import '../web_util.dart'; import 'completion/widget.dart' deferred as completion; -import 'dismiss/widget.dart' deferred as dismiss; +import 'switch/widget.dart' deferred as switch_; /// Function to create an instance of the widget given an element and options. /// @@ -32,7 +32,7 @@ typedef _WidgetLoaderFn = FutureOr<_WidgetFn> Function(); /// Map from widget name to widget loader final _widgets = { 'completion': () => completion.loadLibrary().then((_) => completion.create), - 'dismiss': () => dismiss.loadLibrary().then((_) => dismiss.create), + 'switch': () => switch_.loadLibrary().then((_) => switch_.create), }; Future<_WidgetFn> _noSuchWidget() async => diff --git a/pkg/web_app/test/deferred_import_test.dart b/pkg/web_app/test/deferred_import_test.dart index 470238772..d3059be9c 100644 --- a/pkg/web_app/test/deferred_import_test.dart +++ b/pkg/web_app/test/deferred_import_test.dart @@ -37,6 +37,7 @@ void main() { ], 'completion/': [], 'dismiss/': [], + 'switch/': [], }; for (final file in files) { diff --git a/pkg/web_css/lib/src/_base.scss b/pkg/web_css/lib/src/_base.scss index 7c62839c8..672baf995 100644 --- a/pkg/web_css/lib/src/_base.scss +++ b/pkg/web_css/lib/src/_base.scss @@ -400,7 +400,7 @@ pre { } &.dismissed { - display: none; + visibility: hidden; } z-index: 0;