@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +75 -8
  3. package/build/baseline.mjs +2 -2
  4. package/build/vite.mjs +253 -20
  5. package/docs/ACCESSIBILITY.md +9 -2
  6. package/package.json +3 -2
  7. package/src/analytics/chain.ts +1 -1
  8. package/src/analytics/pageview.ts +1 -1
  9. package/src/analytics/start.ts +3 -4
  10. package/src/analytics/wire.ts +1 -1
  11. package/src/api/ai.ts +32 -20
  12. package/src/api/token.ts +7 -7
  13. package/src/core/a11y.ts +6 -35
  14. package/src/core/context.ts +2 -0
  15. package/src/core/contrast-toggle.ts +32 -0
  16. package/src/core/default-button.ts +66 -0
  17. package/src/core/early-rules.ts +3 -2
  18. package/src/core/features.ts +109 -0
  19. package/src/core/frameworks-check.ts +32 -0
  20. package/src/core/frameworks.ts +35 -0
  21. package/src/core/image-load.ts +0 -26
  22. package/src/core/image-preload.ts +29 -0
  23. package/src/core/image-tags.ts +7 -1
  24. package/src/core/images.ts +3 -2
  25. package/src/core/integration.ts +42 -26
  26. package/src/core/listings.ts +9 -5
  27. package/src/core/locales.ts +2 -1
  28. package/src/core/own.ts +7 -3
  29. package/src/core/recommenders.ts +2 -2
  30. package/src/core/reconciler.ts +32 -23
  31. package/src/core/routes.ts +7 -6
  32. package/src/core/scheduler.ts +39 -5
  33. package/src/core/speculation.ts +2 -0
  34. package/src/core/stash.ts +15 -6
  35. package/src/core/styles.ts +6 -5
  36. package/src/core/support.ts +1 -1
  37. package/src/core/takeover.ts +6 -5
  38. package/src/core/templates.ts +5 -2
  39. package/src/core/tiles.ts +33 -10
  40. package/src/core/url.ts +6 -3
  41. package/src/core/widget-inits.ts +4 -1
  42. package/src/lit.ts +2 -1
  43. package/src/locales/cs.json +0 -1
  44. package/src/locales/de.json +0 -1
  45. package/src/locales/en.json +0 -1
  46. package/src/main.ts +70 -53
  47. package/src/types.ts +29 -9
  48. package/src/widgets/autocomplete/index.ts +58 -19
  49. package/src/widgets/listing/element.ts +81 -58
  50. package/src/widgets/listing/query.ts +6 -3
  51. package/src/widgets/listing/stash.ts +33 -16
  52. package/src/widgets/listing/state.ts +12 -0
  53. package/src/widgets/listing/view.ts +2 -1
  54. package/src/widgets/plp/route.ts +3 -2
  55. package/src/widgets/recommender/index.ts +3 -2
  56. package/src/widgets/search/direct.ts +39 -0
  57. package/src/widgets/search/form-url.ts +38 -34
  58. package/src/widgets/search/in-place.ts +43 -108
  59. package/src/widgets/search/index.ts +7 -2
  60. package/src/widgets/search/speculate.ts +48 -27
  61. 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
+ };