Skip to content

Commit

Permalink
Draft of how widgets could be organized (#8035)
Browse files Browse the repository at this point in the history
* Draft of how widgets could be organized

* Fix nits

* Fix deferred imports
  • Loading branch information
jonasfj authored Sep 12, 2024
1 parent ae925a8 commit f24755c
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 120 deletions.
4 changes: 2 additions & 2 deletions app/test/frontend/static_files_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ void main() {
test('script.dart.js and parts size check', () {
final file = cache.getFile('/static/js/script.dart.js');
expect(file, isNotNull);
expect((file!.bytes.length / 1024).round(), closeTo(343, 2));
expect((file!.bytes.length / 1024).round(), closeTo(333, 2));

final parts = cache.paths
.where((path) =>
Expand All @@ -223,7 +223,7 @@ void main() {
final partsSize = parts
.map((p) => cache.getFile(p)!.bytes.length)
.reduce((a, b) => a + b);
expect((partsSize / 1024).round(), closeTo(212, 10));
expect((partsSize / 1024).round(), closeTo(227, 10));
});
});

Expand Down
2 changes: 1 addition & 1 deletion pkg/web_app/lib/script.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import 'src/page_updater.dart';
import 'src/screenshot_carousel.dart';
import 'src/scroll.dart';
import 'src/search.dart';
import 'src/widgets.dart' show setupWidgets;
import 'src/widget/widget.dart' show setupWidgets;

void main() {
window.onLoad.listen((_) => mdc.autoInit());
Expand Down
6 changes: 6 additions & 0 deletions pkg/web_app/lib/src/web_util.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:js_interop';

import 'package:web/web.dart';

extension NodeListTolist on NodeList {
Expand All @@ -19,3 +21,7 @@ extension HTMLCollectionToList on HTMLCollection {
/// [HTMLCollection].
List<Element> toList() => List.generate(length, (i) => item(i)!);
}

extension JSStringArrayIterable on JSArray<JSString> {
Iterable<String> get iterable => toDart.map((s) => s.toDart);
}
8 changes: 8 additions & 0 deletions pkg/web_app/lib/src/widget/completion/completion.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// 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.

// TODO: Create a function that can create an instance of Node on the server
// We might need to move a lot of code around since Node is currently
// defined in app/lib/frontend/dom/dom.dart
// It's also possible we can define the helper function somewhere else.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,85 @@ import 'package:collection/collection.dart';
import 'package:http/http.dart' deferred as http show read;
import 'package:web/web.dart';

import 'web_util.dart';
import '../../web_util.dart';

/// Create a [_CompletionWidget] on [element].
///
/// Here [element] must:
/// * be an `<input>` element, with
/// * `type="text"`, or,
/// * `type="search".
/// * have properties:
/// * `data-completion-src`, URL from which completion data should be
/// loaded.
/// * `data-completion-class` (optional), class that should be applied to
/// the dropdown that provides completion options.
/// Useful if styling multiple completer widgets.
///
/// The dropdown that provides completions will be appended to
/// `document.body` and given the following classes:
/// * `completion-dropdown` for the completion dropdown.
/// * `completion-option` for each option in the dropdown, and,
/// * `completion-option-select` is applied to selected options.
void create(Element element, Map<String, String> options) {
if (!element.isA<HTMLInputElement>()) {
throw UnsupportedError('Must be <input> element');
}
final input = element as HTMLInputElement;

if (input.type != 'text' && input.type != 'search') {
throw UnsupportedError('Must have type="text" or type="search"');
}

final src = options['src'] ?? '';
if (src.isEmpty) {
throw UnsupportedError('Must have completion-src="<url>"');
}
final srcUri = Uri.tryParse(src);
if (srcUri == null) {
throw UnsupportedError('completion-src="$src" must be a valid URI');
}
final completionClass = options['class'] ?? '';

// Setup attributes
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
input.setAttribute('autocorrect', 'off'); // safari only

scheduleMicrotask(() async {
// Don't do anymore setup before input has focus
if (document.activeElement != input) {
await input.onFocus.first;
}

final _CompletionData data;
try {
data = await _CompletionWidget._completionDataFromUri(srcUri);
} on Exception catch (e) {
throw Exception(
'Unable to load autocompletion-src="$src", error: $e',
);
}

// Create and style the dropdown element
final dropdown = HTMLDivElement()
..style.display = 'none'
..style.position = 'absolute'
..classList.add('completion-dropdown');
if (completionClass.isNotEmpty) {
dropdown.classList.add(completionClass);
}

_CompletionWidget._(
input: input,
dropdown: dropdown,
data: data,
);
// Add dropdown after the <input>
document.body!.after(dropdown);
});
}

typedef _CompletionData = List<
({
Expand Down Expand Up @@ -93,7 +171,7 @@ final class _State {
'_State(forced: $forced, triggered: $triggered, caret: $caret, text: $text, selected: $selectedIndex)';
}

final class CompletionWidget {
final class _CompletionWidget {
static final _whitespace = RegExp(r'\s');
static final optionClass = 'completion-option';
static final selectedOptionClass = 'completion-option-selected';
Expand All @@ -103,7 +181,7 @@ final class CompletionWidget {
final _CompletionData data;
var state = _State();

CompletionWidget._({
_CompletionWidget._({
required this.input,
required this.dropdown,
required this.data,
Expand Down Expand Up @@ -343,83 +421,6 @@ final class CompletionWidget {
trackState();
}

/// Create a [CompletionWidget] on [element].
///
/// Here [element] must:
/// * be an `<input>` element, with
/// * `type="text"`, or,
/// * `type="search".
/// * have properties:
/// * `data-completion-src`, URL from which completion data should be
/// loaded.
/// * `data-completion-class` (optional), class that should be applied to
/// the dropdown that provides completion options.
/// Useful if styling multiple completer widgets.
///
/// The dropdown that provides completions will be appended to
/// `document.body` and given the following classes:
/// * `completion-dropdown` for the completion dropdown.
/// * `completion-option` for each option in the dropdown, and,
/// * `completion-option-select` is applied to selected options.
static void create(Element element) {
if (!element.isA<HTMLInputElement>()) {
throw UnsupportedError('Must be <input> element');
}
final input = element as HTMLInputElement;

if (input.type != 'text' && input.type != 'search') {
throw UnsupportedError('Must have type="text" or type="search"');
}
final src = input.getAttribute('data-completion-src') ?? '';
if (src.isEmpty) {
throw UnsupportedError('Must have completion-src="<url>"');
}
final srcUri = Uri.tryParse(src);
if (srcUri == null) {
throw UnsupportedError('completion-src="$src" must be a valid URI');
}
final completionClass = input.getAttribute('data-completion-class') ?? '';

// Setup attributes
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.spellcheck = false;
input.setAttribute('autocorrect', 'off'); // safari only

scheduleMicrotask(() async {
// Don't do anymore setup before input has focus
if (document.activeElement != input) {
await input.onFocus.first;
}

final _CompletionData data;
try {
data = await _completionDataFromUri(srcUri);
} on Exception catch (e) {
throw Exception(
'Unable to load autocompletion-src="$src", error: $e',
);
}

// Create and style the dropdown element
final dropdown = HTMLDivElement()
..style.display = 'none'
..style.position = 'absolute'
..classList.add('completion-dropdown');
if (completionClass.isNotEmpty) {
dropdown.classList.add(completionClass);
}

CompletionWidget._(
input: input,
dropdown: dropdown,
data: data,
);
// Add dropdown after the <input>
document.body!.after(dropdown);
});
}

/// Load completion data from [src].
///
/// Completion data must be a JSON response on the form:
Expand Down Expand Up @@ -607,7 +608,7 @@ final class CompletionWidget {
trigger: trigger,
suggestions: completion.options
.map((option) {
final overlap = lcs(prefix, option);
final overlap = _lcs(prefix, option);
var html = option;
if (overlap.isNotEmpty) {
html = html.replaceAll(overlap, '<strong>$overlap</strong>');
Expand All @@ -632,7 +633,7 @@ final class CompletionWidget {
}

/// The longest common substring
String lcs(String S, String T) {
String _lcs(String S, String T) {
final r = S.length;
final n = T.length;
var Lp = List.filled(n, 0); // ignore: non_constant_identifier_names
Expand Down
83 changes: 83 additions & 0 deletions pkg/web_app/lib/src/widget/widget.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// 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:async';
import 'dart:js_interop';

import 'package:collection/collection.dart';
import 'package:web/web.dart';

import '../web_util.dart';
import 'completion/widget.dart' deferred as completion;

/// Function to create an instance of the widget given an element and options.
///
/// [element] which carries `data-widget="$name"`.
/// [options] a map from options to values, where options are specified as
/// `data-$name-$option="$value"`.
///
/// Hence, a widget called `completion` is created on an element by adding
/// `data-widget="completion"`. And option `src` is specified with:
/// `data-completion-src="$value"`.
typedef _WidgetFn = FutureOr<void> Function(
Element element,
Map<String, String> options,
);

/// Function for loading a widget.
typedef _WidgetLoaderFn = FutureOr<_WidgetFn> Function();

/// Map from widget name to widget loader
final _widgets = <String, _WidgetLoaderFn>{
'completion': () => completion.loadLibrary().then((_) => completion.create),
};

Future<_WidgetFn> _noSuchWidget() async =>
(_, __) => throw AssertionError('no such widget');

void setupWidgets() async {
final widgetAndElements = document
// query for all elements with the property `data-widget="..."`
.querySelectorAll('[data-widget]')
.toList() // Convert NodeList to List
// We only care about elements
.where((node) => node.isA<HTMLElement>())
.map((node) => node as HTMLElement)
// group by widget
.groupListsBy((element) => element.getAttribute('data-widget') ?? '');

// For each (widget, elements) load widget and create widgets
await Future.wait(widgetAndElements.entries.map((entry) async {
// Get widget name and elements which it should be created for
final MapEntry(key: name, value: elements) = entry;

// Find the widget and load it
final widget = await (_widgets[name] ?? _noSuchWidget)();

// Create widget for each element
await Future.wait(elements.map((element) async {
try {
final prefix = 'data-$name-';
final options = Map.fromEntries(element
.getAttributeNames()
.iterable
.where((attr) => attr.startsWith(prefix))
.map((attr) {
return MapEntry(
attr.substring(prefix.length),
element.getAttribute(attr) ?? '',
);
}));

await widget(element, options);
} catch (e, st) {
console.error('Failed to initialize data-widget="$name"'.toJS);
console.error('Triggered by element:'.toJS);
console.error(element);
console.error(e.toString().toJS);
console.error(st.toString().toJS);
}
}));
}));
}
35 changes: 0 additions & 35 deletions pkg/web_app/lib/src/widgets.dart

This file was deleted.

1 change: 1 addition & 0 deletions pkg/web_app/test/deferred_import_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ void main() {
'package:markdown/': [
'lib/src/deferred/markdown.dart',
],
'completion/': [],
};

for (final file in files) {
Expand Down

0 comments on commit f24755c

Please sign in to comment.