Skip to content

Commit f24755c

Browse files
authored
Draft of how widgets could be organized (#8035)
* Draft of how widgets could be organized * Fix nits * Fix deferred imports
1 parent ae925a8 commit f24755c

File tree

8 files changed

+184
-120
lines changed

8 files changed

+184
-120
lines changed

app/test/frontend/static_files_test.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ void main() {
212212
test('script.dart.js and parts size check', () {
213213
final file = cache.getFile('/static/js/script.dart.js');
214214
expect(file, isNotNull);
215-
expect((file!.bytes.length / 1024).round(), closeTo(343, 2));
215+
expect((file!.bytes.length / 1024).round(), closeTo(333, 2));
216216

217217
final parts = cache.paths
218218
.where((path) =>
@@ -223,7 +223,7 @@ void main() {
223223
final partsSize = parts
224224
.map((p) => cache.getFile(p)!.bytes.length)
225225
.reduce((a, b) => a + b);
226-
expect((partsSize / 1024).round(), closeTo(212, 10));
226+
expect((partsSize / 1024).round(), closeTo(227, 10));
227227
});
228228
});
229229

pkg/web_app/lib/script.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import 'src/page_updater.dart';
1616
import 'src/screenshot_carousel.dart';
1717
import 'src/scroll.dart';
1818
import 'src/search.dart';
19-
import 'src/widgets.dart' show setupWidgets;
19+
import 'src/widget/widget.dart' show setupWidgets;
2020

2121
void main() {
2222
window.onLoad.listen((_) => mdc.autoInit());

pkg/web_app/lib/src/web_util.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:js_interop';
2+
13
import 'package:web/web.dart';
24

35
extension NodeListTolist on NodeList {
@@ -19,3 +21,7 @@ extension HTMLCollectionToList on HTMLCollection {
1921
/// [HTMLCollection].
2022
List<Element> toList() => List.generate(length, (i) => item(i)!);
2123
}
24+
25+
extension JSStringArrayIterable on JSArray<JSString> {
26+
Iterable<String> get iterable => toDart.map((s) => s.toDart);
27+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
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+
// TODO: Create a function that can create an instance of Node on the server
6+
// We might need to move a lot of code around since Node is currently
7+
// defined in app/lib/frontend/dom/dom.dart
8+
// It's also possible we can define the helper function somewhere else.

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

Lines changed: 83 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,85 @@ import 'package:collection/collection.dart';
1111
import 'package:http/http.dart' deferred as http show read;
1212
import 'package:web/web.dart';
1313

14-
import 'web_util.dart';
14+
import '../../web_util.dart';
15+
16+
/// Create a [_CompletionWidget] on [element].
17+
///
18+
/// Here [element] must:
19+
/// * be an `<input>` element, with
20+
/// * `type="text"`, or,
21+
/// * `type="search".
22+
/// * have properties:
23+
/// * `data-completion-src`, URL from which completion data should be
24+
/// loaded.
25+
/// * `data-completion-class` (optional), class that should be applied to
26+
/// the dropdown that provides completion options.
27+
/// Useful if styling multiple completer widgets.
28+
///
29+
/// The dropdown that provides completions will be appended to
30+
/// `document.body` and given the following classes:
31+
/// * `completion-dropdown` for the completion dropdown.
32+
/// * `completion-option` for each option in the dropdown, and,
33+
/// * `completion-option-select` is applied to selected options.
34+
void create(Element element, Map<String, String> options) {
35+
if (!element.isA<HTMLInputElement>()) {
36+
throw UnsupportedError('Must be <input> element');
37+
}
38+
final input = element as HTMLInputElement;
39+
40+
if (input.type != 'text' && input.type != 'search') {
41+
throw UnsupportedError('Must have type="text" or type="search"');
42+
}
43+
44+
final src = options['src'] ?? '';
45+
if (src.isEmpty) {
46+
throw UnsupportedError('Must have completion-src="<url>"');
47+
}
48+
final srcUri = Uri.tryParse(src);
49+
if (srcUri == null) {
50+
throw UnsupportedError('completion-src="$src" must be a valid URI');
51+
}
52+
final completionClass = options['class'] ?? '';
53+
54+
// Setup attributes
55+
input.autocomplete = 'off';
56+
input.autocapitalize = 'off';
57+
input.spellcheck = false;
58+
input.setAttribute('autocorrect', 'off'); // safari only
59+
60+
scheduleMicrotask(() async {
61+
// Don't do anymore setup before input has focus
62+
if (document.activeElement != input) {
63+
await input.onFocus.first;
64+
}
65+
66+
final _CompletionData data;
67+
try {
68+
data = await _CompletionWidget._completionDataFromUri(srcUri);
69+
} on Exception catch (e) {
70+
throw Exception(
71+
'Unable to load autocompletion-src="$src", error: $e',
72+
);
73+
}
74+
75+
// Create and style the dropdown element
76+
final dropdown = HTMLDivElement()
77+
..style.display = 'none'
78+
..style.position = 'absolute'
79+
..classList.add('completion-dropdown');
80+
if (completionClass.isNotEmpty) {
81+
dropdown.classList.add(completionClass);
82+
}
83+
84+
_CompletionWidget._(
85+
input: input,
86+
dropdown: dropdown,
87+
data: data,
88+
);
89+
// Add dropdown after the <input>
90+
document.body!.after(dropdown);
91+
});
92+
}
1593

1694
typedef _CompletionData = List<
1795
({
@@ -93,7 +171,7 @@ final class _State {
93171
'_State(forced: $forced, triggered: $triggered, caret: $caret, text: $text, selected: $selectedIndex)';
94172
}
95173

96-
final class CompletionWidget {
174+
final class _CompletionWidget {
97175
static final _whitespace = RegExp(r'\s');
98176
static final optionClass = 'completion-option';
99177
static final selectedOptionClass = 'completion-option-selected';
@@ -103,7 +181,7 @@ final class CompletionWidget {
103181
final _CompletionData data;
104182
var state = _State();
105183

106-
CompletionWidget._({
184+
_CompletionWidget._({
107185
required this.input,
108186
required this.dropdown,
109187
required this.data,
@@ -343,83 +421,6 @@ final class CompletionWidget {
343421
trackState();
344422
}
345423

346-
/// Create a [CompletionWidget] on [element].
347-
///
348-
/// Here [element] must:
349-
/// * be an `<input>` element, with
350-
/// * `type="text"`, or,
351-
/// * `type="search".
352-
/// * have properties:
353-
/// * `data-completion-src`, URL from which completion data should be
354-
/// loaded.
355-
/// * `data-completion-class` (optional), class that should be applied to
356-
/// the dropdown that provides completion options.
357-
/// Useful if styling multiple completer widgets.
358-
///
359-
/// The dropdown that provides completions will be appended to
360-
/// `document.body` and given the following classes:
361-
/// * `completion-dropdown` for the completion dropdown.
362-
/// * `completion-option` for each option in the dropdown, and,
363-
/// * `completion-option-select` is applied to selected options.
364-
static void create(Element element) {
365-
if (!element.isA<HTMLInputElement>()) {
366-
throw UnsupportedError('Must be <input> element');
367-
}
368-
final input = element as HTMLInputElement;
369-
370-
if (input.type != 'text' && input.type != 'search') {
371-
throw UnsupportedError('Must have type="text" or type="search"');
372-
}
373-
final src = input.getAttribute('data-completion-src') ?? '';
374-
if (src.isEmpty) {
375-
throw UnsupportedError('Must have completion-src="<url>"');
376-
}
377-
final srcUri = Uri.tryParse(src);
378-
if (srcUri == null) {
379-
throw UnsupportedError('completion-src="$src" must be a valid URI');
380-
}
381-
final completionClass = input.getAttribute('data-completion-class') ?? '';
382-
383-
// Setup attributes
384-
input.autocomplete = 'off';
385-
input.autocapitalize = 'off';
386-
input.spellcheck = false;
387-
input.setAttribute('autocorrect', 'off'); // safari only
388-
389-
scheduleMicrotask(() async {
390-
// Don't do anymore setup before input has focus
391-
if (document.activeElement != input) {
392-
await input.onFocus.first;
393-
}
394-
395-
final _CompletionData data;
396-
try {
397-
data = await _completionDataFromUri(srcUri);
398-
} on Exception catch (e) {
399-
throw Exception(
400-
'Unable to load autocompletion-src="$src", error: $e',
401-
);
402-
}
403-
404-
// Create and style the dropdown element
405-
final dropdown = HTMLDivElement()
406-
..style.display = 'none'
407-
..style.position = 'absolute'
408-
..classList.add('completion-dropdown');
409-
if (completionClass.isNotEmpty) {
410-
dropdown.classList.add(completionClass);
411-
}
412-
413-
CompletionWidget._(
414-
input: input,
415-
dropdown: dropdown,
416-
data: data,
417-
);
418-
// Add dropdown after the <input>
419-
document.body!.after(dropdown);
420-
});
421-
}
422-
423424
/// Load completion data from [src].
424425
///
425426
/// Completion data must be a JSON response on the form:
@@ -607,7 +608,7 @@ final class CompletionWidget {
607608
trigger: trigger,
608609
suggestions: completion.options
609610
.map((option) {
610-
final overlap = lcs(prefix, option);
611+
final overlap = _lcs(prefix, option);
611612
var html = option;
612613
if (overlap.isNotEmpty) {
613614
html = html.replaceAll(overlap, '<strong>$overlap</strong>');
@@ -632,7 +633,7 @@ final class CompletionWidget {
632633
}
633634

634635
/// The longest common substring
635-
String lcs(String S, String T) {
636+
String _lcs(String S, String T) {
636637
final r = S.length;
637638
final n = T.length;
638639
var Lp = List.filled(n, 0); // ignore: non_constant_identifier_names
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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:async';
6+
import 'dart:js_interop';
7+
8+
import 'package:collection/collection.dart';
9+
import 'package:web/web.dart';
10+
11+
import '../web_util.dart';
12+
import 'completion/widget.dart' deferred as completion;
13+
14+
/// Function to create an instance of the widget given an element and options.
15+
///
16+
/// [element] which carries `data-widget="$name"`.
17+
/// [options] a map from options to values, where options are specified as
18+
/// `data-$name-$option="$value"`.
19+
///
20+
/// Hence, a widget called `completion` is created on an element by adding
21+
/// `data-widget="completion"`. And option `src` is specified with:
22+
/// `data-completion-src="$value"`.
23+
typedef _WidgetFn = FutureOr<void> Function(
24+
Element element,
25+
Map<String, String> options,
26+
);
27+
28+
/// Function for loading a widget.
29+
typedef _WidgetLoaderFn = FutureOr<_WidgetFn> Function();
30+
31+
/// Map from widget name to widget loader
32+
final _widgets = <String, _WidgetLoaderFn>{
33+
'completion': () => completion.loadLibrary().then((_) => completion.create),
34+
};
35+
36+
Future<_WidgetFn> _noSuchWidget() async =>
37+
(_, __) => throw AssertionError('no such widget');
38+
39+
void setupWidgets() async {
40+
final widgetAndElements = document
41+
// query for all elements with the property `data-widget="..."`
42+
.querySelectorAll('[data-widget]')
43+
.toList() // Convert NodeList to List
44+
// We only care about elements
45+
.where((node) => node.isA<HTMLElement>())
46+
.map((node) => node as HTMLElement)
47+
// group by widget
48+
.groupListsBy((element) => element.getAttribute('data-widget') ?? '');
49+
50+
// For each (widget, elements) load widget and create widgets
51+
await Future.wait(widgetAndElements.entries.map((entry) async {
52+
// Get widget name and elements which it should be created for
53+
final MapEntry(key: name, value: elements) = entry;
54+
55+
// Find the widget and load it
56+
final widget = await (_widgets[name] ?? _noSuchWidget)();
57+
58+
// Create widget for each element
59+
await Future.wait(elements.map((element) async {
60+
try {
61+
final prefix = 'data-$name-';
62+
final options = Map.fromEntries(element
63+
.getAttributeNames()
64+
.iterable
65+
.where((attr) => attr.startsWith(prefix))
66+
.map((attr) {
67+
return MapEntry(
68+
attr.substring(prefix.length),
69+
element.getAttribute(attr) ?? '',
70+
);
71+
}));
72+
73+
await widget(element, options);
74+
} catch (e, st) {
75+
console.error('Failed to initialize data-widget="$name"'.toJS);
76+
console.error('Triggered by element:'.toJS);
77+
console.error(element);
78+
console.error(e.toString().toJS);
79+
console.error(st.toString().toJS);
80+
}
81+
}));
82+
}));
83+
}

pkg/web_app/lib/src/widgets.dart

Lines changed: 0 additions & 35 deletions
This file was deleted.

pkg/web_app/test/deferred_import_test.dart

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

4041
for (final file in files) {

0 commit comments

Comments
 (0)