Skip to content

Commit 295d8ac

Browse files
committed
Initial draft of a dismissible widget
1 parent f24755c commit 295d8ac

File tree

6 files changed

+207
-24
lines changed

6 files changed

+207
-24
lines changed

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,14 @@ d.Node pageLayoutNode({
195195
if (announcementBanner != null)
196196
d.div(
197197
classes: ['announcement-banner'],
198-
child: announcementBanner,
198+
attributes: {
199+
'data-widget': 'dismissible',
200+
'data-dismissible-by': '.dismisser'
201+
},
202+
children: [
203+
announcementBanner,
204+
d.div(classes: ['dismisser'], text: 'x'),
205+
],
199206
),
200207
],
201208
),

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: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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 dismissible widget on [element].
19+
///
20+
/// A `data-dismissible-by` is required, this must be a CSS selected for a
21+
/// child element that when clicked will dismiss the message by removing
22+
/// [element].
23+
///
24+
/// A hash is computed from `innerText` of [element], once dismissed this hash
25+
/// will be stored in `localStorage`. And next time this widget is instantiated
26+
/// with the same `innerText` it'll be removed immediately.
27+
///
28+
/// Hashes of dismissed messages will be stored for up to 2 years.
29+
/// No more than 50 dismissed messages are retained in `localStorage`.
30+
void create(HTMLElement element, Map<String, String> options) {
31+
final by = options['by'];
32+
if (by == null) throw UnsupportedError('data-dismissible-by required');
33+
34+
late final hash = _cheapNaiveHash(element.innerText);
35+
if (_dismissed.any((e) => e.hash == hash)) {
36+
element.remove();
37+
return;
38+
} else {
39+
element.style.display = 'revert';
40+
}
41+
42+
final dismiss = (Event e) {
43+
e.preventDefault();
44+
45+
element.remove();
46+
_dismissed.add((
47+
hash: hash,
48+
date: DateTime.now(),
49+
));
50+
_saveDismissed();
51+
};
52+
53+
var dismissible = false;
54+
for (final dismisser in element.querySelectorAll(by).toList()) {
55+
if (!dismisser.isA<HTMLElement>()) continue;
56+
dismisser.addEventListener('click', dismiss.toJS);
57+
dismissible = true;
58+
}
59+
if (!dismissible) {
60+
throw UnsupportedError(
61+
'data-dismissible-by must point to a child element',
62+
);
63+
}
64+
}
65+
66+
/// Create a cheap naive hash from [text].
67+
String _cheapNaiveHash(String text) {
68+
final half = (text.length / 2).floor();
69+
return text.length.toRadixString(16).padLeft(2, '0') +
70+
text.hashCode.toRadixString(16).padLeft(2, '0') +
71+
text.substring(0, half).hashCode.toRadixString(16).padLeft(2, '0') +
72+
text.substring(half).hashCode.toRadixString(16).padLeft(2, '0');
73+
}
74+
75+
/// LocalStorage key where we store the hashes of messages that have been
76+
/// dismissed.
77+
///
78+
/// Data is stored on the format: `<hash>@<date>;<hash>@<date>;...`
79+
const _dismissedMessageslocalStorageKey = 'dismissed-messages';
80+
81+
late final _dismissed = [
82+
...?window.localStorage
83+
.getItem(_dismissedMessageslocalStorageKey)
84+
?.split(';')
85+
.where((e) => e.contains('@'))
86+
.map((entry) {
87+
final [hash, date, ...] = entry.split('@');
88+
return (
89+
hash: hash,
90+
date: DateTime.tryParse(date) ?? DateTime.fromMicrosecondsSinceEpoch(0),
91+
);
92+
}).where((entry) => entry.date.isAfter(_deadline)),
93+
];
94+
95+
void _saveDismissed() {
96+
window.localStorage.setItem(
97+
_dismissedMessageslocalStorageKey,
98+
_dismissed
99+
.sortedBy((e) => e.date) // Sort by date
100+
.reversed // Reverse ordering to prefer newest dates
101+
.take(_maxMissedMessages) // Limit how many entries we save
102+
.map((e) => e.hash + '@' + e.date.toIso8601String().split('T').first)
103+
.join(';'),
104+
);
105+
}

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

Lines changed: 4 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 'dismissible/widget.dart' deferred as dismissible;
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,8 @@ 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+
'dismissible': () =>
36+
dismissible.loadLibrary().then((_) => dismissible.create),
3437
};
3538

3639
Future<_WidgetFn> _noSuchWidget() async =>

pkg/web_css/lib/src/_base.scss

Lines changed: 85 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ body {
1919
overflow-wrap: anywhere;
2020
}
2121

22-
body, input, button, select {
22+
body,
23+
input,
24+
button,
25+
select {
2326
font-family: var(--pub-default-text-font_family);
2427
-webkit-font-smoothing: antialiased;
2528
// we don't use font ligatures, and Google Sans fonts would otherwise change text in surprising ways
@@ -41,17 +44,39 @@ body,
4144
font-weight: 400;
4245
font-size: 16px;
4346

44-
h1, h2, h3, h4, h5, h6 {
47+
h1,
48+
h2,
49+
h3,
50+
h4,
51+
h5,
52+
h6 {
4553
font-family: var(--pub-default-text-font_family);
4654
font-weight: 400;
4755
}
4856

49-
h1 { font-size: 36px; }
50-
h2 { font-size: 24px; }
51-
h3 { font-size: 18px; }
52-
h4 { font-size: 18px; }
53-
h5 { font-size: 16px; }
54-
h6 { font-size: 16px; }
57+
h1 {
58+
font-size: 36px;
59+
}
60+
61+
h2 {
62+
font-size: 24px;
63+
}
64+
65+
h3 {
66+
font-size: 18px;
67+
}
68+
69+
h4 {
70+
font-size: 18px;
71+
}
72+
73+
h5 {
74+
font-size: 16px;
75+
}
76+
77+
h6 {
78+
font-size: 16px;
79+
}
5580
}
5681

5782
img {
@@ -134,6 +159,7 @@ main {
134159
.standalone-side-image-block {
135160
display: block;
136161
}
162+
137163
.standalone-wrapper.-has-side-image {
138164
.standalone-content {
139165
margin: 0px;
@@ -167,7 +193,7 @@ pre {
167193
padding: 30px;
168194
overflow-x: auto;
169195

170-
> code {
196+
>code {
171197
padding: 0px !important;
172198
border-radius: 0;
173199
display: inline-block;
@@ -232,29 +258,37 @@ pre {
232258

233259
.markdown-body {
234260
p {
235-
margin-top: 8px; /* overrides github-markdown.css */
236-
margin-bottom: 12px; /* overrides github-markdown.css */
261+
margin-top: 8px;
262+
/* overrides github-markdown.css */
263+
margin-bottom: 12px;
264+
/* overrides github-markdown.css */
237265
}
238266

239267
table {
240268

241-
td, th {
242-
padding: 12px 12px 12px 0; /* overrides github-markdown.css */
243-
border: none; /* overrides github-markdown.css */
269+
td,
270+
th {
271+
padding: 12px 12px 12px 0;
272+
/* overrides github-markdown.css */
273+
border: none;
274+
/* overrides github-markdown.css */
244275
}
245276

246277
tr {
247-
border-top: none; /* overrides github-markdown.css */
278+
border-top: none;
279+
/* overrides github-markdown.css */
248280

249281
&:nth-child(2n) {
250-
background-color: inherit; /* overrides github-markdown.css */
282+
background-color: inherit;
283+
/* overrides github-markdown.css */
251284
}
252285
}
253286

254287
th {
255288
font-family: var(--pub-default-text-font_family);
256289
font-size: 16px;
257-
font-weight: 400; /* overrides github-markdown.css */
290+
font-weight: 400;
291+
/* overrides github-markdown.css */
258292
border-bottom: 1px solid #c8c8ca;
259293
text-align: left;
260294
}
@@ -264,7 +298,8 @@ pre {
264298
}
265299

266300
img {
267-
background-color: inherit; /* overrides github-markdown.css */
301+
background-color: inherit;
302+
/* overrides github-markdown.css */
268303
}
269304
}
270305
}
@@ -355,8 +390,13 @@ pre {
355390
}
356391

357392
@keyframes spin {
358-
0% { transform: rotate(0deg); }
359-
100% { transform: rotate(360deg); }
393+
0% {
394+
transform: rotate(0deg);
395+
}
396+
397+
100% {
398+
transform: rotate(360deg);
399+
}
360400
}
361401

362402
.hash-link {
@@ -384,12 +424,37 @@ pre {
384424
}
385425

386426
.announcement-banner {
427+
display: none;
428+
387429
padding: 10px 0;
388430

389431
background: var(--pub-home_announcement-background-color);
390432
font-size: 16px;
391433

392434
text-align: center;
435+
436+
.dismisser {
437+
float: right;
438+
padding: 5px 15px;
439+
margin-top: -5px;
440+
cursor: pointer;
441+
user-select: none;
442+
}
443+
444+
z-index: 0;
445+
animation-duration: 200ms;
446+
animation-name: slide-down;
447+
animation-timing-function: ease;
448+
}
449+
450+
@keyframes slide-down {
451+
from {
452+
translate: 0 -100%;
453+
}
454+
455+
to {
456+
translate: 0 0;
457+
}
393458
}
394459

395460
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)