Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading