@luigisbox/luma 0.3.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 (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +95 -14
  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 +10 -2
  8. package/src/analytics/collect.ts +14 -7
  9. package/src/analytics/from-ga4.ts +5 -2
  10. package/src/analytics/from-luma.ts +6 -4
  11. package/src/analytics/index.ts +1 -1
  12. package/src/analytics/pageview.ts +1 -1
  13. package/src/analytics/start.ts +3 -4
  14. package/src/analytics/wire.ts +1 -1
  15. package/src/api/ai.ts +32 -20
  16. package/src/api/token.ts +7 -7
  17. package/src/core/a11y.ts +6 -35
  18. package/src/core/context.ts +2 -0
  19. package/src/core/contrast-toggle.ts +32 -0
  20. package/src/core/default-button.ts +66 -0
  21. package/src/core/early-rules.ts +18 -4
  22. package/src/core/features.ts +109 -0
  23. package/src/core/frameworks-check.ts +32 -0
  24. package/src/core/frameworks.ts +35 -0
  25. package/src/core/image-load.ts +0 -26
  26. package/src/core/image-preload.ts +29 -0
  27. package/src/core/image-tags.ts +7 -1
  28. package/src/core/images.ts +3 -2
  29. package/src/core/integration.ts +42 -26
  30. package/src/core/listings.ts +9 -5
  31. package/src/core/locales.ts +2 -1
  32. package/src/core/own.ts +7 -3
  33. package/src/core/recommenders.ts +2 -2
  34. package/src/core/reconciler.ts +32 -23
  35. package/src/core/routes.ts +7 -6
  36. package/src/core/scheduler.ts +39 -5
  37. package/src/core/speculation.ts +2 -0
  38. package/src/core/stash.ts +15 -6
  39. package/src/core/styles.ts +6 -5
  40. package/src/core/support.ts +1 -1
  41. package/src/core/takeover.ts +6 -5
  42. package/src/core/templates.ts +5 -2
  43. package/src/core/tiles.ts +33 -10
  44. package/src/core/url.ts +6 -3
  45. package/src/core/widget-inits.ts +4 -1
  46. package/src/lit.ts +2 -1
  47. package/src/locales/cs.json +0 -1
  48. package/src/locales/de.json +0 -1
  49. package/src/locales/en.json +0 -1
  50. package/src/main.ts +70 -53
  51. package/src/types.ts +29 -9
  52. package/src/widgets/autocomplete/index.ts +58 -19
  53. package/src/widgets/listing/element.ts +81 -58
  54. package/src/widgets/listing/query.ts +6 -3
  55. package/src/widgets/listing/stash.ts +33 -16
  56. package/src/widgets/listing/state.ts +12 -0
  57. package/src/widgets/listing/view.ts +2 -1
  58. package/src/widgets/plp/route.ts +3 -2
  59. package/src/widgets/recommender/index.ts +3 -2
  60. package/src/widgets/search/direct.ts +39 -0
  61. package/src/widgets/search/form-url.ts +38 -34
  62. package/src/widgets/search/in-place.ts +43 -108
  63. package/src/widgets/search/index.ts +7 -2
  64. package/src/widgets/search/speculate.ts +48 -27
  65. package/src/widgets/search/submit.ts +136 -0
@@ -10,10 +10,16 @@
10
10
  // - in sessionStorage (../listing/stash.ts, `spec`): Enter on another page
11
11
  // loads the search page, whose listing renders it at boot, like a
12
12
  // server-rendered state;
13
- // - optionally (`prerenderWhileTyping`) the search page itself is prerendered
14
- // once that result is stashed and the phrase is still the one typed: Enter
15
- // then activates a page that is already rendered (it renders from the
16
- // stash: no request of its own).
13
+ // - the search page itself (`pageWhileTyping`, Speculation Rules): once that
14
+ // result is stashed and the phrase is still the one typed, its document is
15
+ // fetched ahead (default), so Enter skips the site's server render, or the
16
+ // page is prerendered as well (`'prerender'`): Enter then activates a page
17
+ // that is already rendered (it renders from the stash: no request of its own).
18
+ //
19
+ // The page fetched for is the one the form's submit leads to (./form-url.ts
20
+ // searchTarget: the form's URL, or the integration's `queryUrl` of the phrase
21
+ // where the site's server redirects the form); the stash is under the query,
22
+ // so the page it loads at finds it whatever its URL.
17
23
  //
18
24
  // Only what is likely: the settled phrase (not every keystroke), 2+
19
25
  // characters, not the page we are on, none offline, with Save-Data or on 2G.
@@ -32,6 +38,7 @@
32
38
  import { getApi } from '../../api';
33
39
  import { firstRowImages, resultFactory, warmImages } from '../../core/support';
34
40
  import { mark, reportError } from '../../core/health';
41
+ import { cspNonce } from '../../core/styles';
35
42
  import { pageKey } from '../../core/page';
36
43
  import { dropPrefetch, hasPrefetch, putPrefetch } from '../../core/prefetch';
37
44
  import { onUrlChange } from '../../core/sense';
@@ -39,8 +46,10 @@ import type { GlobalConfigType, SearchConfigType } from '../../types';
39
46
  import { listingQuery, type ListingResult } from '../listing/query';
40
47
  import type { Query, Route } from '../listing/state';
41
48
  import { writeStash } from '../listing/stash';
42
- import { formUrl } from './form-url';
49
+ import { defaultSubmitter } from '../../core/default-button';
50
+ import { searchTarget } from './form-url';
43
51
  import { searchRoute } from './route';
52
+ import { NAMED_IN_PLACE_QUERY, PRERENDER_TYPING } from '../../core/features';
44
53
 
45
54
  const MIN_CHARS = 2;
46
55
  /** Speculative results kept at once (the phrase before the latest one: a shopper backspacing). */
@@ -80,12 +89,14 @@ export const startSpeculation = (g: GlobalConfigType, cfg: SearchConfigType, fir
80
89
  return form instanceof HTMLFormElement ? ([...form.elements].find((el) => el instanceof HTMLInputElement && el.matches(sel)) as HTMLInputElement | undefined) ?? null : null;
81
90
  };
82
91
 
83
- const prerender = startPrerender(cfg, route);
92
+ const prerender = startPage(cfg, route);
93
+ // The page Enter would load: the browser's implicit submission, with the form's default button (its name=value, formaction).
94
+ const target = (i: HTMLInputElement) => (i.isConnected && i.form ? searchTarget(cfg.queryUrl, i.form, defaultSubmitter(i.form), i) : null);
84
95
 
85
96
  const run = (i: HTMLInputElement) => {
86
97
  clearTimeout(timer);
87
- if (thrifty() || !i.form || !i.isConnected) return;
88
- const url = formUrl(i.form, null);
98
+ if (thrifty()) return;
99
+ const url = target(i);
89
100
  if (!url || url.origin !== location.origin || !route.accepts(url)) return;
90
101
  const key = pageKey(url.href);
91
102
  if (key === pageKey(location.href)) return; // the page we are on
@@ -106,7 +117,7 @@ export const startSpeculation = (g: GlobalConfigType, cfg: SearchConfigType, fir
106
117
  // For a page load of it: the whole result, unless something optional failed.
107
118
  if (!res.partial && writeStash(url.href, { kind: 'spec', route: route.id, query: q, result: res, ttl })) {
108
119
  // Still the phrase in the form: that page, rendered ahead (it renders from the stash).
109
- const now = i.isConnected && i.form ? formUrl(i.form, null) : null;
120
+ const now = target(i);
110
121
  if (now && pageKey(now.href) === key) prerender?.start(url.href, key);
111
122
  }
112
123
  if (warm > 0) warmFor(res);
@@ -160,21 +171,27 @@ export const startSpeculation = (g: GlobalConfigType, cfg: SearchConfigType, fir
160
171
  };
161
172
 
162
173
  /**
163
- * The search page prerendered while the shopper types (`prerenderWhileTyping`,
164
- * Speculation Rules: Chrome, Edge): one list rule for one URL, the phrase whose
165
- * result was just stashed. A newer phrase replaces it (removing a rule cancels
166
- * its prerender): one prerendered search page at most. Not on the search page
167
- * itself when queries there are in place (no page load to save).
168
- *
169
- * The cost: a host document rendered on their server per settled phrase the
170
- * shopper may never submit, and their scripts run in it (Chromium runs a
171
- * prerendered page's scripts; only APIs with visible effects wait for the
174
+ * The search page itself, ahead of Enter (`pageWhileTyping`, Speculation
175
+ * Rules): one list rule for one URL, the phrase whose result was just stashed.
176
+ * `'prefetch'` (default) fetches its document: a navigation to that URL is
177
+ * served from it (Chromium: `deliveryType` `navigational-prefetch`, cache
178
+ * headers and cookies as any navigation's; Firefox and Safari as they ship
179
+ * prefetch), the site's server render off the shopper's wait, nothing of the
180
+ * page run. `'prerender'` renders it as well (Chromium; the prefetch rule is
181
+ * kept for engines without prerendering, and Chromium's prerender takes the
182
+ * prefetched response): Enter activates a page already rendered, but the
183
+ * site's scripts run in it (only APIs with visible effects wait for the
172
184
  * activation: tags that defer to `prerenderingchange`, as Google's do, count
173
- * nothing until then, others count a page view). Ours renders from the stash
174
- * (no request) and defers onRender to the activation. Off by default.
185
+ * nothing until then, others count a page view); ours renders from the stash
186
+ * (no request) and defers onRender to the activation. A newer phrase replaces
187
+ * the rule (removing it cancels a prerender): one page at most. Not on the
188
+ * search page itself when queries there are in place (no page load to save).
189
+ * The cost either way: a document from their server per settled phrase the
190
+ * shopper may never submit.
175
191
  */
176
- const startPrerender = (cfg: SearchConfigType, route: Route) => {
177
- if (!cfg.prerenderWhileTyping || !(HTMLScriptElement as any).supports?.('speculationrules')) return null;
192
+ const startPage = (cfg: SearchConfigType, route: Route) => {
193
+ const mode = cfg.pageWhileTyping ?? (PRERENDER_TYPING && cfg.prerenderWhileTyping ? 'prerender' : 'prefetch');
194
+ if (!mode || !(HTMLScriptElement as any).supports?.('speculationrules')) return null;
178
195
  let el: HTMLScriptElement | null = null;
179
196
  let at = '';
180
197
  const stop = () => {
@@ -183,20 +200,24 @@ const startPrerender = (cfg: SearchConfigType, route: Route) => {
183
200
  at = '';
184
201
  };
185
202
  return {
186
- /** The phrase now settled: a prerender of another page goes. */
203
+ /** The phrase now settled: another page's rule goes. */
187
204
  keep: (key: string) => void (at && at !== key && stop()),
188
205
  start: (href: string, key: string) => {
189
206
  if (at === key) return;
190
207
  // A search page in place has no page load to save.
191
- if (cfg.inPlaceQuery !== false && route.accepts(new URL(location.href))) return;
208
+ if ((NAMED_IN_PLACE_QUERY ? cfg.inPlaceQuery !== false : true) && route.accepts(new URL(location.href))) return;
192
209
  stop();
193
210
  el = document.createElement('script');
194
211
  el.type = 'speculationrules';
195
- el.setAttribute('data-lbx', 'search-prerender');
196
- el.textContent = JSON.stringify({ prerender: [{ source: 'list', urls: [href], eagerness: cfg.prerenderEagerness ?? 'immediate' }] });
212
+ el.setAttribute('data-lbx', 'search-page');
213
+ el.nonce = cspNonce(); // a strict script-src applies to inline speculation rules
214
+ // A list rule's eagerness is `immediate` unless said otherwise.
215
+ const rules: Record<string, unknown[]> = { prefetch: [{ source: 'list', urls: [href] }] };
216
+ if (PRERENDER_TYPING && mode === 'prerender') rules.prerender = [{ source: 'list', urls: [href], eagerness: cfg.prerenderEagerness }]; // unset: not written (JSON.stringify)
217
+ el.textContent = JSON.stringify(rules);
197
218
  document.head.append(el);
198
219
  at = key;
199
- mark(`search prerender: ${href}`);
220
+ mark(`search page ${mode}: ${href}`);
200
221
  },
201
222
  };
202
223
  };
@@ -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
+ };