@luigisbox/luma 0.4.0 → 0.6.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 +152 -0
- package/README.md +76 -9
- package/build/baseline.mjs +2 -2
- package/build/vite.mjs +255 -20
- package/docs/ACCESSIBILITY.md +31 -5
- 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 +1 -1
- package/src/locales/de.json +1 -1
- package/src/locales/en.json +1 -1
- package/src/main.ts +70 -53
- package/src/types.ts +48 -14
- package/src/widgets/autocomplete/index.ts +167 -34
- package/src/widgets/autocomplete/style-autocomplete.css +16 -8
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +1 -1
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +13 -9
- package/src/widgets/listing/applied.ts +87 -0
- package/src/widgets/listing/element.ts +279 -78
- package/src/widgets/listing/intent.ts +15 -0
- 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
|
@@ -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
|
-
// -
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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 {
|
|
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 =
|
|
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()
|
|
88
|
-
const url =
|
|
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 =
|
|
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
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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)
|
|
174
|
-
* (no request) and defers onRender to the activation.
|
|
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
|
|
177
|
-
|
|
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:
|
|
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-
|
|
196
|
-
el.
|
|
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
|
|
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
|
+
};
|