Skip to content

Commit ab43e2d

Browse files
authored
Initial draft of a dismissible widget (#8054)
* Initial draft of a dismissible widget * Fixing part count * Fix tests * revert sass formatting * Change widget to dismiss * Document date-format * base64 encode data-dismiss-message-id in localStorage
1 parent 3c8b685 commit ab43e2d

File tree

9 files changed

+177
-6
lines changed

9 files changed

+177
-6
lines changed

app/lib/frontend/templates/layout.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'dart:convert';
6+
57
import 'package:_pub_shared/data/page_data.dart';
68
import 'package:_pub_shared/search/search_form.dart';
9+
import 'package:convert/convert.dart';
10+
import 'package:crypto/crypto.dart';
711
import 'package:pub_dev/admin/models.dart';
812

913
import '../../frontend/request_context.dart';
@@ -97,6 +101,11 @@ String renderLayoutPage(
97101
announcementBanner: announcementBannerHtml == null
98102
? null
99103
: d.unsafeRawHtml(announcementBannerHtml),
104+
announcementBannerHash: announcementBannerHtml == null
105+
? ''
106+
: hex
107+
.encode(sha1.convert(utf8.encode(announcementBannerHtml)).bytes)
108+
.substring(0, 16),
100109
searchBanner: showSearchBanner(type)
101110
? _renderSearchBanner(
102111
type: type,

app/lib/frontend/templates/views/shared/layout.dart

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ d.Node pageLayoutNode({
2020
required List<String>? bodyClasses,
2121
required d.Node siteHeader,
2222
required d.Node? announcementBanner,
23+
required String announcementBannerHash,
2324
required d.Node? searchBanner,
2425
required bool isLanding,
2526
required d.Node? landingBlurb,
@@ -194,8 +195,19 @@ d.Node pageLayoutNode({
194195
children: [
195196
if (announcementBanner != null)
196197
d.div(
197-
classes: ['announcement-banner'],
198-
child: announcementBanner,
198+
classes: ['announcement-banner', 'dismissed'],
199+
children: [
200+
announcementBanner,
201+
d.div(
202+
classes: ['dismisser'],
203+
attributes: {
204+
'data-widget': 'dismiss',
205+
'data-dismiss-target': '.announcement-banner',
206+
'data-dismiss-message-id': announcementBannerHash,
207+
},
208+
text: 'x',
209+
),
210+
],
199211
),
200212
],
201213
),

app/test/frontend/static_files_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ void main() {
219219
path.startsWith('/static/js/script.dart.js') &&
220220
path.endsWith('part.js'))
221221
.toList();
222-
expect(parts.length, closeTo(11, 3));
222+
expect(parts.length, closeTo(17, 3));
223223
final partsSize = parts
224224
.map((p) => cache.getFile(p)!.bytes.length)
225225
.reduce((a, b) => a + b);

pkg/web_app/lib/src/widget/completion/widget.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import '../../web_util.dart';
3131
/// * `completion-dropdown` for the completion dropdown.
3232
/// * `completion-option` for each option in the dropdown, and,
3333
/// * `completion-option-select` is applied to selected options.
34-
void create(Element element, Map<String, String> options) {
34+
void create(HTMLElement element, Map<String, String> options) {
3535
if (!element.isA<HTMLInputElement>()) {
3636
throw UnsupportedError('Must be <input> element');
3737
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:js_interop';
6+
7+
import 'package:collection/collection.dart';
8+
import 'package:web/web.dart';
9+
10+
import '../../web_util.dart';
11+
12+
/// Forget dismissed messages that are more than 2 years old
13+
late final _deadline = DateTime.now().subtract(Duration(days: 365 * 2));
14+
15+
/// Don't save more than 50 entries
16+
const _maxMissedMessages = 50;
17+
18+
/// Create a dismiss widget on [element].
19+
///
20+
/// A `data-dismiss-target` is required, this must be a CSS selector for the
21+
/// element(s) that are to be dismissed when this widget is clicked.
22+
///
23+
/// A `data-dismiss-message-id` is required, this must be a string identifying
24+
/// the message being dismissed. Once dismissed this identifier will be stored
25+
/// in `localStorage`. And next time this widget is instantiated with the same
26+
/// `data-dismiss-message-id` it'll be removed immediately.
27+
///
28+
/// When in a dismissed state the `data-dismiss-target` elements will have a
29+
/// `dismissed` class added to them. If they have this class initially, it will
30+
/// be removed unless, the message has already been dismissed previously.
31+
///
32+
/// Identifiers of dismissed messages will be stored for up to 2 years.
33+
/// No more than 50 dismissed messages are retained in `localStorage`.
34+
void create(HTMLElement element, Map<String, String> options) {
35+
final target = options['target'];
36+
if (target == null) {
37+
throw UnsupportedError('data-dismissible-target required');
38+
}
39+
final messageId = options['message-id'];
40+
if (messageId == null) {
41+
throw UnsupportedError('data-dismissible-message-id required');
42+
}
43+
44+
void applyDismissed(bool enabled) {
45+
for (final e in document.querySelectorAll(target).toList()) {
46+
if (e.isA<HTMLElement>()) {
47+
final element = e as HTMLHtmlElement;
48+
if (enabled) {
49+
element.classList.add('dismissed');
50+
} else {
51+
element.classList.remove('dismissed');
52+
}
53+
}
54+
}
55+
}
56+
57+
if (_dismissed.any((e) => e.id == messageId)) {
58+
applyDismissed(true);
59+
return;
60+
} else {
61+
applyDismissed(false);
62+
}
63+
64+
void dismiss(Event e) {
65+
e.preventDefault();
66+
67+
applyDismissed(true);
68+
_dismissed.add((
69+
id: messageId,
70+
date: DateTime.now(),
71+
));
72+
_saveDismissed();
73+
}
74+
75+
element.addEventListener('click', dismiss.toJS);
76+
}
77+
78+
/// LocalStorage key where we store the identifiers of messages that have been
79+
/// dismissed.
80+
///
81+
/// Data is stored on the format: `<message-id>@<date>;<message-id>@<date>;...`,
82+
/// where:
83+
/// * `<date>` is on the form `YYYY-MM-DD`.
84+
/// * `<message-id>` is the base64 encoded `data-dismiss-message-id` passed to
85+
/// a dismiss widget.
86+
const _dismissedMessageslocalStorageKey = 'dismissed-messages';
87+
88+
late final _dismissed = [
89+
...?window.localStorage
90+
.getItem(_dismissedMessageslocalStorageKey)
91+
?.split(';')
92+
.where((e) => e.contains('@'))
93+
.map((entry) {
94+
final [id, date, ...] = entry.split('@');
95+
return (
96+
id: window.atob(id),
97+
date: DateTime.tryParse(date) ?? DateTime.fromMicrosecondsSinceEpoch(0),
98+
);
99+
}).where((entry) => entry.date.isAfter(_deadline)),
100+
];
101+
102+
void _saveDismissed() {
103+
window.localStorage.setItem(
104+
_dismissedMessageslocalStorageKey,
105+
_dismissed
106+
.sortedBy((e) => e.date) // Sort by date
107+
.reversed // Reverse ordering to prefer newest dates
108+
.take(_maxMissedMessages) // Limit how many entries we save
109+
.map(
110+
(e) =>
111+
window.btoa(e.id) +
112+
'@' +
113+
e.date.toIso8601String().split('T').first,
114+
)
115+
.join(';'),
116+
);
117+
}

pkg/web_app/lib/src/widget/widget.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import 'package:web/web.dart';
1010

1111
import '../web_util.dart';
1212
import 'completion/widget.dart' deferred as completion;
13+
import 'dismiss/widget.dart' deferred as dismiss;
1314

1415
/// Function to create an instance of the widget given an element and options.
1516
///
@@ -21,7 +22,7 @@ import 'completion/widget.dart' deferred as completion;
2122
/// `data-widget="completion"`. And option `src` is specified with:
2223
/// `data-completion-src="$value"`.
2324
typedef _WidgetFn = FutureOr<void> Function(
24-
Element element,
25+
HTMLElement element,
2526
Map<String, String> options,
2627
);
2728

@@ -31,6 +32,7 @@ typedef _WidgetLoaderFn = FutureOr<_WidgetFn> Function();
3132
/// Map from widget name to widget loader
3233
final _widgets = <String, _WidgetLoaderFn>{
3334
'completion': () => completion.loadLibrary().then((_) => completion.create),
35+
'dismiss': () => dismiss.loadLibrary().then((_) => dismiss.create),
3436
};
3537

3638
Future<_WidgetFn> _noSuchWidget() async =>

pkg/web_app/test/deferred_import_test.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ void main() {
3636
'lib/src/deferred/markdown.dart',
3737
],
3838
'completion/': [],
39+
'dismiss/': [],
3940
};
4041

4142
for (final file in files) {

pkg/web_css/lib/src/_base.scss

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,33 @@ pre {
390390
font-size: 16px;
391391

392392
text-align: center;
393+
394+
.dismisser {
395+
float: right;
396+
padding: 5px 15px;
397+
margin-top: -5px;
398+
cursor: pointer;
399+
user-select: none;
400+
}
401+
402+
&.dismissed {
403+
display: none;
404+
}
405+
406+
z-index: 0;
407+
animation-duration: 200ms;
408+
animation-name: slide-down;
409+
animation-timing-function: ease;
410+
}
411+
412+
@keyframes slide-down {
413+
from {
414+
translate: 0 -100%;
415+
}
416+
417+
to {
418+
translate: 0 0;
419+
}
393420
}
394421

395422
a.-x-ago {

pkg/web_css/lib/src/_site_header.scss

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
}
2222

2323
.site-header {
24+
z-index: 100; // for animation of announcement
2425
background: var(--pub-site_header_banner-background-color);
2526
color: var(--pub-site_header_banner-text-color);
2627
display: flex;
@@ -32,6 +33,7 @@
3233

3334
@media (max-width: $device-mobile-max-width) {
3435
&:focus-within {
36+
3537
.hamburger,
3638
.site-logo {
3739
opacity: 0.3;
@@ -223,6 +225,7 @@
223225
}
224226

225227
.site-header-nav {
228+
226229
/* Navigation styles for mobile. */
227230
@media (max-width: $device-mobile-max-width) {
228231
position: fixed;
@@ -354,7 +357,7 @@
354357
padding: 12px;
355358
min-width: 100px;
356359

357-
> h3 {
360+
>h3 {
358361
border-bottom: 1px solid var(--pub-site_header_popup-border-color);
359362
}
360363
}

0 commit comments

Comments
 (0)