@luigisbox/luma 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/README.md +75 -8
- package/build/baseline.mjs +2 -2
- package/build/vite.mjs +253 -20
- package/docs/ACCESSIBILITY.md +9 -2
- package/package.json +3 -2
- package/src/analytics/chain.ts +1 -1
- package/src/analytics/pageview.ts +1 -1
- package/src/analytics/start.ts +3 -4
- package/src/analytics/wire.ts +1 -1
- package/src/api/ai.ts +32 -20
- package/src/api/token.ts +7 -7
- package/src/core/a11y.ts +6 -35
- package/src/core/context.ts +2 -0
- package/src/core/contrast-toggle.ts +32 -0
- package/src/core/default-button.ts +66 -0
- package/src/core/early-rules.ts +3 -2
- package/src/core/features.ts +109 -0
- package/src/core/frameworks-check.ts +32 -0
- package/src/core/frameworks.ts +35 -0
- package/src/core/image-load.ts +0 -26
- package/src/core/image-preload.ts +29 -0
- package/src/core/image-tags.ts +7 -1
- package/src/core/images.ts +3 -2
- package/src/core/integration.ts +42 -26
- package/src/core/listings.ts +9 -5
- package/src/core/locales.ts +2 -1
- package/src/core/own.ts +7 -3
- package/src/core/recommenders.ts +2 -2
- package/src/core/reconciler.ts +32 -23
- package/src/core/routes.ts +7 -6
- package/src/core/scheduler.ts +39 -5
- package/src/core/speculation.ts +2 -0
- package/src/core/stash.ts +15 -6
- package/src/core/styles.ts +6 -5
- package/src/core/support.ts +1 -1
- package/src/core/takeover.ts +6 -5
- package/src/core/templates.ts +5 -2
- package/src/core/tiles.ts +33 -10
- package/src/core/url.ts +6 -3
- package/src/core/widget-inits.ts +4 -1
- package/src/lit.ts +2 -1
- package/src/locales/cs.json +0 -1
- package/src/locales/de.json +0 -1
- package/src/locales/en.json +0 -1
- package/src/main.ts +70 -53
- package/src/types.ts +29 -9
- package/src/widgets/autocomplete/index.ts +58 -19
- package/src/widgets/listing/element.ts +81 -58
- package/src/widgets/listing/query.ts +6 -3
- package/src/widgets/listing/stash.ts +33 -16
- package/src/widgets/listing/state.ts +12 -0
- package/src/widgets/listing/view.ts +2 -1
- package/src/widgets/plp/route.ts +3 -2
- package/src/widgets/recommender/index.ts +3 -2
- package/src/widgets/search/direct.ts +39 -0
- package/src/widgets/search/form-url.ts +38 -34
- package/src/widgets/search/in-place.ts +43 -108
- package/src/widgets/search/index.ts +7 -2
- package/src/widgets/search/speculate.ts +48 -27
- package/src/widgets/search/submit.ts +136 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// A submit of the site's search form, taken by Luma: in place on the search
|
|
2
|
+
// page (./in-place.ts) or straight to the query's page (./direct.ts). Enter in
|
|
3
|
+
// the input is modelled as the browser's implicit submission
|
|
4
|
+
// (implicitSubmitter, validates). Not in lbx-nav (every listing page loads
|
|
5
|
+
// that): a category page never takes a submit; loaded with in-place.ts
|
|
6
|
+
// (build/vite.mjs lbx-search-in-place), direct.ts imports it from there.
|
|
7
|
+
|
|
8
|
+
import { defaultSubmitter, type Submitter } from '../../core/default-button';
|
|
9
|
+
import { reportError } from '../../core/health';
|
|
10
|
+
|
|
11
|
+
/** Fields whose Enter is an implicit submission (the HTML standard's list): with no submit button, the form submits only when it has exactly one. */
|
|
12
|
+
const BLOCKS_IMPLICIT = /^(text|search|email|url|tel|password|date|month|week|time|datetime-local|number)$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What an Enter in a field of `form` submits with, as the browser's implicit
|
|
16
|
+
* submission: the default button (`submitter`, null without one), or null when
|
|
17
|
+
* it submits nothing: a disabled default button (a fieldset's too), or no
|
|
18
|
+
* button and not exactly one field that blocks implicit submission. The
|
|
19
|
+
* form's validation is `validates`, apart: the browser runs it last.
|
|
20
|
+
*/
|
|
21
|
+
export const implicitSubmitter = (form: HTMLFormElement): { submitter: Submitter | null } | null => {
|
|
22
|
+
const submitter = defaultSubmitter(form);
|
|
23
|
+
if (submitter) {
|
|
24
|
+
if (submitter.matches(':disabled')) return null;
|
|
25
|
+
} else if ([...form.elements].filter((el) => el instanceof HTMLInputElement && BLOCKS_IMPLICIT.test(el.type)).length !== 1) return null;
|
|
26
|
+
return { submitter };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The form's constraint validation, as the browser runs it before a submit:
|
|
31
|
+
* false when a field is invalid (the browser's own Enter then reports it and
|
|
32
|
+
* submits nothing), unless the form has `novalidate` or the submitter
|
|
33
|
+
* `formnovalidate`. `checkValidity` fires each invalid field's `invalid`
|
|
34
|
+
* event, as the browser is about to: asked last, only for an Enter that would
|
|
35
|
+
* otherwise be taken, so a host's listeners hear it once more at most.
|
|
36
|
+
*/
|
|
37
|
+
export const validates = (form: HTMLFormElement, submitter: Submitter | null) => form.noValidate || !!submitter?.formNoValidate || form.checkValidity();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A submit of the site's search form, the one holding an input matching
|
|
41
|
+
* `inputSelector` (any form without a selector). `target(form, submitter,
|
|
42
|
+
* input)` says where it leads when it is ours to take (null: the browser's);
|
|
43
|
+
* then the event is cancelled and `go(to, form, input, e)` does it, and the
|
|
44
|
+
* event goes no further (a host handler that ignores defaultPrevented would
|
|
45
|
+
* navigate as well, htmx's boosted form). Heard as the window's `submit`
|
|
46
|
+
* (capture), in a shadow root's too (a web-component header: its submit is
|
|
47
|
+
* not composed) once one of its inputs is used, and as Enter in the input
|
|
48
|
+
* ahead of the site's own handlers (some navigate with form.submit() on Enter,
|
|
49
|
+
* which fires no submit event), as the browser's implicit submission
|
|
50
|
+
* (`implicitSubmitter`, then `validates` once the Enter would be taken: nothing
|
|
51
|
+
* where the browser would submit nothing). Not with a modifier key held (a
|
|
52
|
+
* new tab or window, where the browser supports it), and not an Enter on an
|
|
53
|
+
* option selected in our open autocomplete (the autocomplete's; its own
|
|
54
|
+
* hand-off of a query is a synthetic Enter, taken). Errors in `target` and
|
|
55
|
+
* `go` are reported as `where`'s. Returns `isInput` and the inputs seen in
|
|
56
|
+
* shadow roots (in-place.ts shows the query in them on back/forward).
|
|
57
|
+
*/
|
|
58
|
+
export const onSearchSubmit = (
|
|
59
|
+
where: string,
|
|
60
|
+
inputSelector: string | undefined,
|
|
61
|
+
target: (form: HTMLFormElement, submitter: HTMLElement | null, input: HTMLInputElement | null) => URL | null,
|
|
62
|
+
go: (to: URL, form: HTMLFormElement, input: HTMLInputElement | null, e: Event) => void,
|
|
63
|
+
) => {
|
|
64
|
+
const isInput = (el: unknown): el is HTMLInputElement => !!inputSelector && el instanceof HTMLInputElement && el.matches(inputSelector);
|
|
65
|
+
const realTarget = (e: Event) => (e.composedPath?.()[0] ?? e.target) as Element | null;
|
|
66
|
+
let mods = { on: false, t: -1 };
|
|
67
|
+
const track = (e: KeyboardEvent | MouseEvent) => void (mods = { on: e.ctrlKey || e.metaKey || e.shiftKey || e.altKey, t: e.timeStamp });
|
|
68
|
+
addEventListener('keydown', track, true);
|
|
69
|
+
addEventListener('click', track, true);
|
|
70
|
+
const guarded = <T>(f: () => T, otherwise: T): T => {
|
|
71
|
+
try {
|
|
72
|
+
return f();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
reportError(where, err);
|
|
75
|
+
return otherwise;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const taken = (to: URL, form: HTMLFormElement, input: HTMLInputElement | null, e: Event) => {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
guarded(() => go(to, form, input, e), undefined);
|
|
81
|
+
};
|
|
82
|
+
const onSubmit = (e: SubmitEvent) => {
|
|
83
|
+
const form = e.target;
|
|
84
|
+
if (e.defaultPrevented || !(form instanceof HTMLFormElement) || (mods.on && e.timeStamp - mods.t < 100)) return;
|
|
85
|
+
// The form's search input; with a selector, a form without one is not the site's search form.
|
|
86
|
+
const input = inputSelector ? ([...form.elements].find(isInput) ?? null) : null;
|
|
87
|
+
if (inputSelector && !input) return;
|
|
88
|
+
const to = guarded(() => target(form, e.submitter, input), null);
|
|
89
|
+
if (!to) return;
|
|
90
|
+
taken(to, form, input, e);
|
|
91
|
+
e.stopPropagation();
|
|
92
|
+
};
|
|
93
|
+
addEventListener('submit', onSubmit, true);
|
|
94
|
+
// A search input in a shadow root: its key events reach window retargeted to
|
|
95
|
+
// the shadow host (read from the composed path); its root gets the submit
|
|
96
|
+
// listener once one of its inputs is used.
|
|
97
|
+
const shadowInputs = new Set<HTMLInputElement>();
|
|
98
|
+
const roots = new WeakSet<ShadowRoot>();
|
|
99
|
+
const seen = (e: Event) => {
|
|
100
|
+
const i = realTarget(e);
|
|
101
|
+
const root = isInput(i) ? i.getRootNode() : null;
|
|
102
|
+
if (!(root instanceof ShadowRoot)) return;
|
|
103
|
+
shadowInputs.add(i as HTMLInputElement);
|
|
104
|
+
if (roots.has(root)) return;
|
|
105
|
+
roots.add(root);
|
|
106
|
+
root.addEventListener('submit', onSubmit as EventListener, true);
|
|
107
|
+
};
|
|
108
|
+
addEventListener('focusin', seen, true);
|
|
109
|
+
// Our open autocomplete with an option selected: in its shadow root by default (light DOM with dom: 'light').
|
|
110
|
+
const acSelection = () => {
|
|
111
|
+
const o = document.querySelector('[data-lbx="ac-overlay"]');
|
|
112
|
+
return !!o?.matches(':popover-open') && !!(o.shadowRoot ?? o).querySelector('[aria-selected="true"]');
|
|
113
|
+
};
|
|
114
|
+
addEventListener(
|
|
115
|
+
'keydown',
|
|
116
|
+
(e) => {
|
|
117
|
+
if (e.key !== 'Enter' || e.isComposing || e.defaultPrevented || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
|
118
|
+
const input = realTarget(e);
|
|
119
|
+
if (!isInput(input) || !input.form) return;
|
|
120
|
+
seen(e);
|
|
121
|
+
if (e.isTrusted && acSelection()) return;
|
|
122
|
+
const form = input.form;
|
|
123
|
+
const sub = implicitSubmitter(form);
|
|
124
|
+
if (!sub) return; // the browser submits nothing
|
|
125
|
+
// `target` first (its formUrl builds the form's data: the form's `formdata` event, as a submit's), validation
|
|
126
|
+
// last: an Enter declined here fires no `invalid` events of ours before the browser's own; an invalid
|
|
127
|
+
// field then costs one `formdata` event the browser would not have sent, the smaller of the two.
|
|
128
|
+
const to = guarded(() => target(form, sub.submitter, input), null);
|
|
129
|
+
if (!to || !validates(form, sub.submitter)) return; // not ours; or the browser reports the invalid field and submits nothing
|
|
130
|
+
taken(to, form, input, e);
|
|
131
|
+
e.stopImmediatePropagation();
|
|
132
|
+
},
|
|
133
|
+
true,
|
|
134
|
+
);
|
|
135
|
+
return { isInput, shadowInputs };
|
|
136
|
+
};
|