@luigisbox/luma 0.0.0-stage → 0.2.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/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The URL a GET form navigates to (formUrl): what in-place search
|
|
2
|
+
// (./in-place.ts, the search page) and search while typing (./speculate.ts,
|
|
3
|
+
// any page) both read. Its own module: neither chunk loads the other for it.
|
|
4
|
+
|
|
5
|
+
export type Submitter = HTMLButtonElement | HTMLInputElement;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The URL a GET form navigates to, as the browser builds it: the submitter's
|
|
9
|
+
* formaction / formmethod / formtarget over the form's, the action's query
|
|
10
|
+
* replaced by the form data. null for anything else (POST, dialog, another
|
|
11
|
+
* window, a file field, a non-http action). Attributes are read with
|
|
12
|
+
* getAttribute: a field named `action` or `method` shadows the form's properties.
|
|
13
|
+
*/
|
|
14
|
+
let withSubmitter: boolean | undefined;
|
|
15
|
+
const submitterSupported = () => {
|
|
16
|
+
if (withSubmitter === undefined) {
|
|
17
|
+
try {
|
|
18
|
+
const f = document.createElement('form');
|
|
19
|
+
const b = f.appendChild(document.createElement('button'));
|
|
20
|
+
b.name = 'x';
|
|
21
|
+
b.value = '1';
|
|
22
|
+
withSubmitter = new FormData(f, b).get('x') === '1';
|
|
23
|
+
} catch {
|
|
24
|
+
withSubmitter = false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return withSubmitter;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const formUrl = (form: HTMLFormElement, submitter?: HTMLElement | null): URL | null => {
|
|
31
|
+
const sub = submitter && 'form' in submitter ? (submitter as Submitter) : null;
|
|
32
|
+
const attr = (name: string) => (sub?.hasAttribute(`form${name}`) ? sub.getAttribute(`form${name}`) : form.getAttribute(name));
|
|
33
|
+
if ((attr('method') || 'get').trim().toLowerCase() !== 'get') return null;
|
|
34
|
+
const target = (attr('target') ?? document.querySelector('base[target]')?.getAttribute('target') ?? '').trim().toLowerCase();
|
|
35
|
+
if (target && target !== '_self') return null;
|
|
36
|
+
const action = attr('action')?.trim();
|
|
37
|
+
const url = new URL(action ? action : location.href, action ? document.baseURI : undefined);
|
|
38
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
39
|
+
// FormData(form, submitter) is newer (Chrome 112, Safari 16.4, Firefox
|
|
40
|
+
// 111): before, the second argument is ignored (it does not throw), so the
|
|
41
|
+
// submitter's own entry is added by hand there.
|
|
42
|
+
const native = submitterSupported();
|
|
43
|
+
let own = !native; // the submitter's entry is ours to add
|
|
44
|
+
let data: FormData;
|
|
45
|
+
try {
|
|
46
|
+
data = new FormData(form, native && sub ? sub : undefined);
|
|
47
|
+
} catch {
|
|
48
|
+
data = new FormData(form); // not a submit button of this form
|
|
49
|
+
own = true;
|
|
50
|
+
}
|
|
51
|
+
if (own && sub?.name) data.append(sub.name, sub.value);
|
|
52
|
+
const params = new URLSearchParams();
|
|
53
|
+
for (const [k, v] of data) {
|
|
54
|
+
if (typeof v !== 'string') return null;
|
|
55
|
+
params.append(k, v);
|
|
56
|
+
}
|
|
57
|
+
url.search = params.toString();
|
|
58
|
+
return url;
|
|
59
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// In-place search: on the search page, a new query from the host's search form
|
|
2
|
+
// (typed + Enter, its search button, and our autocomplete's Enter / "show all"
|
|
3
|
+
// / a query suggestion, which submit that form) becomes a new history entry
|
|
4
|
+
// and a re-render of our listing, not a page load. Anywhere else, and for
|
|
5
|
+
// anything we cannot do exactly as the browser would, the form submits as usual.
|
|
6
|
+
//
|
|
7
|
+
// A window-capture `submit` listener, nothing bound to the host's form. The
|
|
8
|
+
// form's target is resolved as the browser would (method GET, action, target,
|
|
9
|
+
// its fields): only when the page we are on and that URL are both search
|
|
10
|
+
// pages of ours, the navigation is ours. What the page load did for free is
|
|
11
|
+
// done here: the listing starts the new query anew (its filters and sort
|
|
12
|
+
// are our own URL params, which the form's URL does not carry), the input
|
|
13
|
+
// loses focus (the autocomplete closes, a phone's keyboard goes), the page
|
|
14
|
+
// scrolls to the top, the host's native modal or popover around the form
|
|
15
|
+
// closes (a <dialog>, a [popover]; a scripted one is the host's to close), the
|
|
16
|
+
// host's inputs show the query on back/forward. A query our listing cannot show
|
|
17
|
+
// (it hands the page back) is loaded for real.
|
|
18
|
+
//
|
|
19
|
+
// Opt-out per integration: `inPlaceQuery: false` in search/_config.ts.
|
|
20
|
+
// Loaded with the search widget (not in the entry): until it has loaded, a
|
|
21
|
+
// search submits as usual.
|
|
22
|
+
|
|
23
|
+
import { mark, reportError } from '../../core/health';
|
|
24
|
+
import { searchReadQuery } from '../../core/routes';
|
|
25
|
+
import { onUrlChange } from '../../core/sense';
|
|
26
|
+
import type { Takeover } from '../../core/takeover';
|
|
27
|
+
import { canon, writeUrl } from '../../core/url';
|
|
28
|
+
import type { SearchConfigType } from '../../types';
|
|
29
|
+
import type { Route } from '../listing/state';
|
|
30
|
+
import { formUrl } from './form-url';
|
|
31
|
+
|
|
32
|
+
export const startInPlace = (cfg: SearchConfigType, inputSelector: string | undefined, tk: Takeover, route: Route) => {
|
|
33
|
+
const read = searchReadQuery(cfg);
|
|
34
|
+
const bootQuery = read(new URL(location.href));
|
|
35
|
+
const POPOVER = 'showPopover' in HTMLElement.prototype;
|
|
36
|
+
/** Queries whose visit we created in this document (the page they would load is not this one). */
|
|
37
|
+
const ours = new Set<string>();
|
|
38
|
+
/** Our own history write is running (its URL change is not a back/forward). */
|
|
39
|
+
let pushing = false;
|
|
40
|
+
const isInput = (el: Element | null): el is HTMLInputElement => !!inputSelector && el instanceof HTMLInputElement && el.matches(inputSelector);
|
|
41
|
+
/** The focused element, inside shadow roots too. */
|
|
42
|
+
const focused = () => {
|
|
43
|
+
let a = document.activeElement;
|
|
44
|
+
while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
|
|
45
|
+
return a;
|
|
46
|
+
};
|
|
47
|
+
// A search input in a shadow root (a web-component header): its key events
|
|
48
|
+
// reach window retargeted to the shadow host, and its form's `submit` does
|
|
49
|
+
// not leave the shadow root at all (it is not composed). Such an input is
|
|
50
|
+
// read from the event's composed path, its root gets the same submit
|
|
51
|
+
// listener once one of its inputs is used, and it is remembered for the
|
|
52
|
+
// query on back/forward (document queries do not reach it).
|
|
53
|
+
const shadowInputs = new Set<HTMLInputElement>();
|
|
54
|
+
const roots = new WeakSet<ShadowRoot>();
|
|
55
|
+
const realTarget = (e: Event) => (e.composedPath?.()[0] ?? e.target) as Element | null;
|
|
56
|
+
const seen = (e: Event) => {
|
|
57
|
+
const i = realTarget(e);
|
|
58
|
+
const root = isInput(i) ? i.getRootNode() : null;
|
|
59
|
+
if (!(root instanceof ShadowRoot)) return;
|
|
60
|
+
shadowInputs.add(i as HTMLInputElement);
|
|
61
|
+
if (roots.has(root)) return;
|
|
62
|
+
roots.add(root);
|
|
63
|
+
root.addEventListener('submit', onSubmit as EventListener, true);
|
|
64
|
+
};
|
|
65
|
+
addEventListener('focusin', seen, true);
|
|
66
|
+
const ourElement = () => document.querySelector<HTMLElement>(`${route.tag}[data-lbx]`);
|
|
67
|
+
/**
|
|
68
|
+
* The host's open <dialog>s and popovers around `el`, across shadow roots,
|
|
69
|
+
* closed as a page load would have (a new document): innermost first.
|
|
70
|
+
*/
|
|
71
|
+
const closeAround = (el: Element) => {
|
|
72
|
+
for (let n: Element | null = el; n; ) {
|
|
73
|
+
if (n instanceof HTMLDialogElement && n.open) n.close();
|
|
74
|
+
// Not without the Popover API (newer than Baseline Widely available):
|
|
75
|
+
// there a [popover] is a plain element, and `:popover-open` an invalid
|
|
76
|
+
// selector (matches() throws, and the dialogs further out stay open).
|
|
77
|
+
else if (n instanceof HTMLElement && POPOVER && n.hasAttribute('popover') && n.matches(':popover-open')) n.hidePopover();
|
|
78
|
+
const root = n.getRootNode();
|
|
79
|
+
n = n.parentElement ?? (root instanceof ShadowRoot ? root.host : null);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const top = () => scrollTo({ top: 0, left: 0, behavior: 'instant' } as unknown as ScrollToOptions);
|
|
83
|
+
|
|
84
|
+
// A form submitted with a modifier (a new tab or window, where the browser supports it) stays the browser's.
|
|
85
|
+
let mods = { on: false, t: -1 };
|
|
86
|
+
const track = (e: KeyboardEvent | MouseEvent) => void (mods = { on: e.ctrlKey || e.metaKey || e.shiftKey || e.altKey, t: e.timeStamp });
|
|
87
|
+
addEventListener('keydown', track, true);
|
|
88
|
+
addEventListener('click', track, true);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The visit settles: ours once it renders (then the page scrolls to the top,
|
|
92
|
+
* after host code that restores its own scroll position, a search overlay
|
|
93
|
+
* closing), or handed back, where the host's page in this document is about
|
|
94
|
+
* another query: loaded for real.
|
|
95
|
+
*/
|
|
96
|
+
const settle = (url: URL, q: string, scroll: boolean) => {
|
|
97
|
+
const href = canon(url.href);
|
|
98
|
+
const started = performance.now();
|
|
99
|
+
const tick = () => {
|
|
100
|
+
if (canon(location.href) !== href) return;
|
|
101
|
+
const el = ourElement();
|
|
102
|
+
if (tk.state === 'ours' && el?.dataset.q === q && el.getAttribute('aria-busy') !== 'true') {
|
|
103
|
+
if (scroll && el.getBoundingClientRect().top < 0) top();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (tk.state === 'released' || tk.state === 'native') {
|
|
107
|
+
if (ours.has(q) && q !== bootQuery) {
|
|
108
|
+
mark(`search in place: handed back, loading ${q}`);
|
|
109
|
+
location.reload();
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (performance.now() - started < 20000) setTimeout(tick, 100);
|
|
114
|
+
};
|
|
115
|
+
tick();
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The search `form` would do, in place if it is ours to do: true when taken
|
|
120
|
+
* (the caller's event is cancelled here). `submitter`: the button, for a submit.
|
|
121
|
+
*/
|
|
122
|
+
const take = (form: HTMLFormElement, submitter: HTMLElement | null | undefined, e: Event): boolean => {
|
|
123
|
+
let url: URL | null = null;
|
|
124
|
+
try {
|
|
125
|
+
if (inputSelector && ![...form.elements].some(isInput)) return false; // not the site's search form
|
|
126
|
+
if (mods.on && e.timeStamp - mods.t < 100) return false;
|
|
127
|
+
const here = new URL(location.href);
|
|
128
|
+
// Only while our listing shows this search page (not one handed back, or failing this session).
|
|
129
|
+
if (!route.accepts(here) || tk.failing || (tk.state !== 'ours' && tk.state !== 'hold') || !ourElement()) return false;
|
|
130
|
+
url = formUrl(form, submitter);
|
|
131
|
+
if (!url || url.origin !== location.origin || !route.accepts(url)) return false;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
reportError('search in place', err);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
const to = url;
|
|
138
|
+
const q = read(to);
|
|
139
|
+
ours.add(q);
|
|
140
|
+
mark(`search in place: ${q}`);
|
|
141
|
+
// Respond first: the input lets go (the autocomplete closes, a phone's
|
|
142
|
+
// keyboard hides); the history write and the fetch run in a later task.
|
|
143
|
+
const a = focused();
|
|
144
|
+
if (isInput(a) || (a instanceof HTMLElement && form.contains(a))) a.blur();
|
|
145
|
+
try {
|
|
146
|
+
closeAround(form);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
reportError('search in place', err);
|
|
149
|
+
}
|
|
150
|
+
setTimeout(() => {
|
|
151
|
+
try {
|
|
152
|
+
pushing = true;
|
|
153
|
+
writeUrl(to);
|
|
154
|
+
top();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
reportError('search in place', err);
|
|
157
|
+
location.assign(to.href);
|
|
158
|
+
} finally {
|
|
159
|
+
pushing = false;
|
|
160
|
+
}
|
|
161
|
+
}, 0);
|
|
162
|
+
return true;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// A submit we take goes no further: a host handler that ignores
|
|
166
|
+
// defaultPrevented would navigate as well (htmx's boosted form: its own
|
|
167
|
+
// fetch, swap and second history entry for the same query). The Enter path
|
|
168
|
+
// below never lets a submit happen at all.
|
|
169
|
+
const onSubmit = (e: SubmitEvent) => {
|
|
170
|
+
const form = e.target;
|
|
171
|
+
if (e.defaultPrevented || !(form instanceof HTMLFormElement)) return;
|
|
172
|
+
if (take(form, e.submitter, e)) e.stopPropagation();
|
|
173
|
+
};
|
|
174
|
+
addEventListener('submit', onSubmit, true);
|
|
175
|
+
|
|
176
|
+
// Enter in the site's search input, ahead of the site's own handlers: some
|
|
177
|
+
// navigate with form.submit() or location on Enter, which fires no submit
|
|
178
|
+
// event (a storefront's own autocomplete script: form.submit() on every
|
|
179
|
+
// Enter). An Enter on a suggestion selected in our
|
|
180
|
+
// autocomplete is the autocomplete's (it opens that one); a plain Enter,
|
|
181
|
+
// and the autocomplete's own hand-off of a query (a synthetic Enter on the
|
|
182
|
+
// input), are taken here.
|
|
183
|
+
// In its shadow root by default (light DOM with dom: 'light'); only while it is open.
|
|
184
|
+
const acSelection = () => {
|
|
185
|
+
const o = document.querySelector('[data-lbx="ac-overlay"]');
|
|
186
|
+
return !!o?.matches(':popover-open') && !!(o.shadowRoot ?? o).querySelector('[aria-selected="true"]');
|
|
187
|
+
};
|
|
188
|
+
addEventListener(
|
|
189
|
+
'keydown',
|
|
190
|
+
(e) => {
|
|
191
|
+
if (e.key !== 'Enter' || e.isComposing || e.defaultPrevented || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
|
|
192
|
+
const input = realTarget(e);
|
|
193
|
+
if (!isInput(input) || !input.form) return;
|
|
194
|
+
seen(e);
|
|
195
|
+
if (e.isTrusted && acSelection()) return;
|
|
196
|
+
if (take(input.form, null, e)) e.stopImmediatePropagation();
|
|
197
|
+
},
|
|
198
|
+
true,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Another query in this document (ours, or back/forward between them): the
|
|
202
|
+
// host's inputs show it, as their server renders them on a page load. As a
|
|
203
|
+
// typed value (the native setter, then an input event): a framework that
|
|
204
|
+
// controls the input (React's onChange, v-model, ngModel) takes it into its
|
|
205
|
+
// state; a value set behind React's back is put back by its next render
|
|
206
|
+
// (React Router never heard of our entries, so its state stays the old query).
|
|
207
|
+
const show = (i: HTMLInputElement, q: string) => {
|
|
208
|
+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(i, q);
|
|
209
|
+
i.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
210
|
+
};
|
|
211
|
+
let prev = new URL(location.href);
|
|
212
|
+
onUrlChange((url) => {
|
|
213
|
+
const was = prev;
|
|
214
|
+
prev = url;
|
|
215
|
+
if (!route.accepts(url) || !route.accepts(was)) return;
|
|
216
|
+
const q = read(url);
|
|
217
|
+
if (q === read(was)) return;
|
|
218
|
+
for (const i of shadowInputs) if (!i.isConnected) shadowInputs.delete(i);
|
|
219
|
+
const inputs = inputSelector ? [...document.querySelectorAll(inputSelector), ...shadowInputs] : [];
|
|
220
|
+
for (const i of inputs) if (isInput(i) && i !== focused() && i.value !== q) show(i, q);
|
|
221
|
+
// Back/forward with the browser's scroll restoration off (the host restores
|
|
222
|
+
// its own entries, not ours): a page load would have landed at the top.
|
|
223
|
+
if (!pushing && history.scrollRestoration === 'manual') top();
|
|
224
|
+
settle(url, q, pushing);
|
|
225
|
+
});
|
|
226
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Search results (route: ./route.ts; a new query in place: ./in-place.ts).
|
|
2
|
+
|
|
3
|
+
import type { Takeover } from '../../core/takeover';
|
|
4
|
+
import type { WidgetContext } from '../../main';
|
|
5
|
+
import type { SearchConfigType } from '../../types';
|
|
6
|
+
import { startListing } from '../listing/element';
|
|
7
|
+
import { startInPlace } from './in-place';
|
|
8
|
+
import { searchRoute } from './route';
|
|
9
|
+
|
|
10
|
+
export { templates, css } from './assets';
|
|
11
|
+
|
|
12
|
+
export const start = (ctx: WidgetContext<SearchConfigType>, tk: Takeover) => {
|
|
13
|
+
const route = searchRoute(ctx.config);
|
|
14
|
+
if (ctx.config.inPlaceQuery !== false) startInPlace(ctx.config, ctx.global.inputSelector, tk, route);
|
|
15
|
+
return startListing(ctx, tk, route);
|
|
16
|
+
};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// A range facet's dual-thumb slider kept in step with its two number inputs
|
|
2
|
+
// (the built-in template-search-range-default.ts; an integration's own markup
|
|
3
|
+
// may use the same handlers). Two native `<input type="range">` laid over one
|
|
4
|
+
// track: each thumb is a slider to assistive technology (role, value, name,
|
|
5
|
+
// the platform's own adjust gestures), and the pointer reaches whichever thumb
|
|
6
|
+
// is under it (the inputs ignore the pointer, their thumbs do not: the CSS).
|
|
7
|
+
//
|
|
8
|
+
// Moving changes nothing but the controls (the other thumb, the input beside
|
|
9
|
+
// it, the fill): the range is committed once, when the shopper lets go (the
|
|
10
|
+
// pointer released, a pause after the keys, an input's change or Enter),
|
|
11
|
+
// through the scope's `set` (one request, or held by a staged drawer).
|
|
12
|
+
//
|
|
13
|
+
// No DOM access at import or render time (the server renderer imports the
|
|
14
|
+
// templates): only in the handlers.
|
|
15
|
+
import type { SearchScope } from '../../types';
|
|
16
|
+
|
|
17
|
+
type Range = SearchScope['ranges'][number];
|
|
18
|
+
|
|
19
|
+
/** A pause after the last key before a thumb moved from the keyboard commits (one request for a run of presses). */
|
|
20
|
+
export const KEY_COMMIT_MS = 600;
|
|
21
|
+
|
|
22
|
+
interface Parts {
|
|
23
|
+
root: HTMLElement;
|
|
24
|
+
slider: HTMLElement | null;
|
|
25
|
+
lo: HTMLInputElement | null;
|
|
26
|
+
hi: HTMLInputElement | null;
|
|
27
|
+
min: HTMLInputElement | null;
|
|
28
|
+
max: HTMLInputElement | null;
|
|
29
|
+
}
|
|
30
|
+
interface State {
|
|
31
|
+
/** The committed values this root was last drawn with, and what was sent since. */
|
|
32
|
+
drawn: string;
|
|
33
|
+
sent: string;
|
|
34
|
+
timer: number;
|
|
35
|
+
/** A pointer is pressing a thumb or the track: committed when it lets go. */
|
|
36
|
+
pressed: boolean;
|
|
37
|
+
}
|
|
38
|
+
const states = new WeakMap<Element, State>();
|
|
39
|
+
|
|
40
|
+
const partsOf = (el: Element): Parts | null => {
|
|
41
|
+
const root = el.closest<HTMLElement>('[data-lbx-range]');
|
|
42
|
+
if (!root) return null;
|
|
43
|
+
const q = (s: string) => root.querySelector<HTMLInputElement>(s);
|
|
44
|
+
return {
|
|
45
|
+
root,
|
|
46
|
+
slider: root.querySelector<HTMLElement>('[data-lbx-part="range-slider"]'),
|
|
47
|
+
lo: q('input[data-lbx-thumb="min"]'),
|
|
48
|
+
hi: q('input[data-lbx-thumb="max"]'),
|
|
49
|
+
min: q('input[data-lbx-part="range-min"]'),
|
|
50
|
+
max: q('input[data-lbx-part="range-max"]'),
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
const stateOf = (p: Parts, r: Range): State => {
|
|
54
|
+
const drawn = `${r.from}|${r.to}`;
|
|
55
|
+
let st = states.get(p.root);
|
|
56
|
+
// Drawn anew with other values (committed, or another state): what was sent is behind us.
|
|
57
|
+
if (!st || st.drawn !== drawn) {
|
|
58
|
+
if (st) clearTimeout(st.timer);
|
|
59
|
+
st = { drawn, sent: drawn, timer: 0, pressed: st?.pressed ?? false };
|
|
60
|
+
states.set(p.root, st);
|
|
61
|
+
}
|
|
62
|
+
return st;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The value on the range's scale: snapped to its step from `min`, within [lo, hi]. */
|
|
66
|
+
export const snap = (v: number, r: Pick<Range, 'min' | 'max' | 'step'>, lo = r.min, hi = r.max) => {
|
|
67
|
+
const s = r.step > 0 ? r.step : 1;
|
|
68
|
+
const n = Math.round((v - r.min) / s) * s + r.min;
|
|
69
|
+
// Float steps (0.1): no 0.30000000000000004.
|
|
70
|
+
const d = (String(s).split('.')[1] ?? '').length;
|
|
71
|
+
return Math.min(hi, Math.max(lo, Number(n.toFixed(d))));
|
|
72
|
+
};
|
|
73
|
+
/** Where a value sits on the track, 0-100. */
|
|
74
|
+
export const percent = (v: number, r: Pick<Range, 'min' | 'max'>) => (r.max > r.min ? ((Math.min(r.max, Math.max(r.min, v)) - r.min) / (r.max - r.min)) * 100 : 0);
|
|
75
|
+
/** A thumb's value as read: in the facet's unit when it has one. */
|
|
76
|
+
export const valueText = (v: number, r: Pick<Range, 'unit'>) => (r.unit ? `${v} ${r.unit}` : String(v));
|
|
77
|
+
/** The value a key moves a thumb to (WAI-ARIA APG slider): arrows a step, Page Up / Down a tenth (whole steps), Home / End the end it may reach. */
|
|
78
|
+
export const keyed = (key: string, v: number, r: Pick<Range, 'min' | 'max' | 'step'>, lo: number, hi: number, rtl = false): number | null => {
|
|
79
|
+
const big = Math.max(r.step, Math.round((r.max - r.min) / 10 / r.step) * r.step);
|
|
80
|
+
const k = rtl && (key === 'ArrowLeft' || key === 'ArrowRight') ? (key === 'ArrowLeft' ? 'ArrowRight' : 'ArrowLeft') : key;
|
|
81
|
+
const to = k === 'ArrowRight' || k === 'ArrowUp' ? v + r.step : k === 'ArrowLeft' || k === 'ArrowDown' ? v - r.step : k === 'PageUp' ? v + big : k === 'PageDown' ? v - big : k === 'Home' ? lo : k === 'End' ? hi : null;
|
|
82
|
+
return to == null ? null : snap(to, r, lo, hi);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** The thumb's width (--lbx-range-thumb-size, px), 24 by default. */
|
|
86
|
+
const thumbSize = (slider: HTMLElement) => parseFloat(getComputedStyle(slider).getPropertyValue('--lbx-range-thumb-size')) || 24;
|
|
87
|
+
|
|
88
|
+
/** The controls to the values (from, to): the thumbs, their text, the fill, the inputs unless one is being typed into. */
|
|
89
|
+
const show = (p: Parts, r: Range, from: number, to: number, typing?: HTMLInputElement) => {
|
|
90
|
+
if (p.lo) (p.lo.value = String(from)), p.lo.setAttribute('aria-valuetext', valueText(from, r));
|
|
91
|
+
if (p.hi) (p.hi.value = String(to)), p.hi.setAttribute('aria-valuetext', valueText(to, r));
|
|
92
|
+
if (p.min && p.min !== typing) p.min.value = String(from);
|
|
93
|
+
if (p.max && p.max !== typing) p.max.value = String(to);
|
|
94
|
+
if (p.slider) {
|
|
95
|
+
p.slider.style.setProperty('--lbx-from', `${percent(from, r)}%`);
|
|
96
|
+
p.slider.style.setProperty('--lbx-to', `${percent(to, r)}%`);
|
|
97
|
+
}
|
|
98
|
+
// Two thumbs on one spot: the one that can move away from the end it is near goes on top.
|
|
99
|
+
p.lo?.toggleAttribute('data-top', percent(from, r) > 50);
|
|
100
|
+
};
|
|
101
|
+
const values = (p: Parts, r: Range) => {
|
|
102
|
+
const from = p.lo ? p.lo.valueAsNumber : r.from;
|
|
103
|
+
const to = p.hi ? p.hi.valueAsNumber : r.to;
|
|
104
|
+
return { from: Number.isFinite(from) ? from : r.from, to: Number.isFinite(to) ? to : r.to };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Commit what the controls show, once: nothing when it is what is drawn or was already sent. */
|
|
108
|
+
const commit = (p: Parts, r: Range) => {
|
|
109
|
+
const st = stateOf(p, r);
|
|
110
|
+
clearTimeout(st.timer);
|
|
111
|
+
st.timer = 0;
|
|
112
|
+
const { from, to } = values(p, r);
|
|
113
|
+
const key = `${from}|${to}`;
|
|
114
|
+
if (key === st.sent) return;
|
|
115
|
+
st.sent = key;
|
|
116
|
+
r.set(from, to);
|
|
117
|
+
};
|
|
118
|
+
const later = (p: Parts, r: Range, ms: number) => {
|
|
119
|
+
const st = stateOf(p, r);
|
|
120
|
+
clearTimeout(st.timer);
|
|
121
|
+
st.timer = window.setTimeout(() => commit(p, r), ms);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The handlers of one range's controls, for a template: bind them on the
|
|
126
|
+
* root (`data-lbx-range`), the slider (`data-lbx-part="range-slider"`), the
|
|
127
|
+
* thumbs (`input[type=range][data-lbx-thumb="min|max"]`) and the inputs
|
|
128
|
+
* (`data-lbx-part="range-min|range-max"`).
|
|
129
|
+
*/
|
|
130
|
+
export const rangeHandlers = (r: Range) => {
|
|
131
|
+
const at = (e: Event) => partsOf(e.currentTarget as Element);
|
|
132
|
+
/** A thumb moved (a drag, the platform's adjust gesture): the other controls follow; the thumbs never cross. */
|
|
133
|
+
const thumbInput = (e: Event) => {
|
|
134
|
+
const p = at(e);
|
|
135
|
+
if (!p) return;
|
|
136
|
+
stateOf(p, r);
|
|
137
|
+
let { from, to } = values(p, r);
|
|
138
|
+
if (from > to) (e.currentTarget === p.lo ? (from = to) : (to = from));
|
|
139
|
+
show(p, r, from, to);
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
thumbInput,
|
|
143
|
+
/** A thumb let go by the pointer, or moved by an assistive technology's gesture: commit (a pointer still down commits on its release). */
|
|
144
|
+
thumbChange: (e: Event) => {
|
|
145
|
+
const p = at(e);
|
|
146
|
+
if (!p) return;
|
|
147
|
+
if (stateOf(p, r).pressed) return;
|
|
148
|
+
commit(p, r);
|
|
149
|
+
},
|
|
150
|
+
/** Keys as the APG slider has them, the same on every engine; the commit waits for a pause, or Enter. */
|
|
151
|
+
thumbKey: (e: KeyboardEvent) => {
|
|
152
|
+
const p = at(e);
|
|
153
|
+
if (!p || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
154
|
+
if (e.key === 'Enter') return commit(p, r);
|
|
155
|
+
const el = e.currentTarget as HTMLInputElement;
|
|
156
|
+
const { from, to } = values(p, r);
|
|
157
|
+
const isLo = el === p.lo;
|
|
158
|
+
const next = keyed(e.key, isLo ? from : to, r, isLo ? r.min : from, isLo ? to : r.max, getComputedStyle(el).direction === 'rtl');
|
|
159
|
+
if (next == null) return;
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
show(p, r, isLo ? next : from, isLo ? to : next);
|
|
162
|
+
later(p, r, KEY_COMMIT_MS);
|
|
163
|
+
},
|
|
164
|
+
/** Focus leaving a control: a pending commit goes now. */
|
|
165
|
+
blur: (e: Event) => {
|
|
166
|
+
const p = at(e);
|
|
167
|
+
if (p && stateOf(p, r).timer) commit(p, r);
|
|
168
|
+
},
|
|
169
|
+
/**
|
|
170
|
+
* A press on the slider: on a thumb, the native drag (committed when the
|
|
171
|
+
* pointer lets go); on the track, the nearer thumb jumps there and follows
|
|
172
|
+
* the pointer until it lets go.
|
|
173
|
+
*/
|
|
174
|
+
press: (e: PointerEvent) => {
|
|
175
|
+
const p = at(e);
|
|
176
|
+
if (!p || !p.slider || !p.lo || !p.hi || e.button > 0) return;
|
|
177
|
+
const st = stateOf(p, r);
|
|
178
|
+
st.pressed = true;
|
|
179
|
+
const target = e.target as Element;
|
|
180
|
+
const track = target !== p.lo && target !== p.hi;
|
|
181
|
+
const box = p.slider.getBoundingClientRect();
|
|
182
|
+
const inset = thumbSize(p.slider) / 2; // the thumb's centre runs from half a thumb in
|
|
183
|
+
const valueAt = (x: number) => {
|
|
184
|
+
const w = box.width - 2 * inset;
|
|
185
|
+
let f = w > 0 ? (x - box.left - inset) / w : 0;
|
|
186
|
+
if (getComputedStyle(p.slider!).direction === 'rtl') f = 1 - f;
|
|
187
|
+
return snap(r.min + Math.min(1, Math.max(0, f)) * (r.max - r.min), r);
|
|
188
|
+
};
|
|
189
|
+
let thumb: HTMLInputElement | null = null;
|
|
190
|
+
if (track) {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
const v = valueAt(e.clientX);
|
|
193
|
+
const { from, to } = values(p, r);
|
|
194
|
+
thumb = Math.abs(v - from) < Math.abs(v - to) || (v < from && from === to) ? p.lo : p.hi;
|
|
195
|
+
thumb.focus({ preventScroll: true });
|
|
196
|
+
const move = (x: number) => {
|
|
197
|
+
const { from: f, to: t } = values(p, r);
|
|
198
|
+
const n = valueAt(x);
|
|
199
|
+
show(p, r, thumb === p.lo ? Math.min(n, t) : f, thumb === p.lo ? t : Math.max(n, f));
|
|
200
|
+
};
|
|
201
|
+
move(e.clientX);
|
|
202
|
+
try {
|
|
203
|
+
p.slider.setPointerCapture(e.pointerId);
|
|
204
|
+
} catch {}
|
|
205
|
+
const onMove = (m: PointerEvent) => m.pointerId === e.pointerId && move(m.clientX);
|
|
206
|
+
p.slider.addEventListener('pointermove', onMove);
|
|
207
|
+
const done = () => p.slider!.removeEventListener('pointermove', onMove);
|
|
208
|
+
p.slider.addEventListener('lostpointercapture', done, { once: true });
|
|
209
|
+
}
|
|
210
|
+
const up = (u: PointerEvent) => {
|
|
211
|
+
if (u.pointerId !== e.pointerId) return;
|
|
212
|
+
window.removeEventListener('pointerup', up, true);
|
|
213
|
+
window.removeEventListener('pointercancel', up, true);
|
|
214
|
+
st.pressed = false;
|
|
215
|
+
// The native drag's own `change` may come after this: commit() sends a value once.
|
|
216
|
+
setTimeout(() => commit(p, r), 0);
|
|
217
|
+
};
|
|
218
|
+
window.addEventListener('pointerup', up, true);
|
|
219
|
+
window.addEventListener('pointercancel', up, true);
|
|
220
|
+
},
|
|
221
|
+
/** Typed into an input: the thumbs follow (within the range); nothing is committed yet. */
|
|
222
|
+
numberInput: (e: Event) => {
|
|
223
|
+
const p = at(e);
|
|
224
|
+
if (!p) return;
|
|
225
|
+
stateOf(p, r);
|
|
226
|
+
const el = e.currentTarget as HTMLInputElement;
|
|
227
|
+
const v = el.valueAsNumber;
|
|
228
|
+
if (!Number.isFinite(v)) return;
|
|
229
|
+
const cur = values(p, r);
|
|
230
|
+
const isMin = el === p.min;
|
|
231
|
+
const from = isMin ? snap(v, r, r.min, cur.to) : cur.from;
|
|
232
|
+
const to = isMin ? cur.to : snap(v, r, cur.from, r.max);
|
|
233
|
+
show(p, r, from, to, el);
|
|
234
|
+
},
|
|
235
|
+
/** An input's change (Enter, focus leaving it, its spin buttons): committed, the inputs showing what was. Spin buttons wait for a pause too. */
|
|
236
|
+
numberChange: (e: Event) => {
|
|
237
|
+
const p = at(e);
|
|
238
|
+
if (!p) return;
|
|
239
|
+
const el = e.currentTarget as HTMLInputElement;
|
|
240
|
+
const cur = values(p, r);
|
|
241
|
+
const v = el.valueAsNumber;
|
|
242
|
+
// Emptied: that end open again.
|
|
243
|
+
const isMin = el === p.min;
|
|
244
|
+
const from = isMin ? (Number.isFinite(v) ? snap(v, r, r.min, cur.to) : r.min) : cur.from;
|
|
245
|
+
const to = isMin ? cur.to : Number.isFinite(v) ? snap(v, r, cur.from, r.max) : r.max;
|
|
246
|
+
show(p, r, from, to);
|
|
247
|
+
if (el.ownerDocument.activeElement === el) later(p, r, KEY_COMMIT_MS);
|
|
248
|
+
else commit(p, r);
|
|
249
|
+
},
|
|
250
|
+
numberKey: (e: KeyboardEvent) => {
|
|
251
|
+
if (e.key !== 'Enter') return;
|
|
252
|
+
e.preventDefault(); // nothing to submit: a host form around the listing must not
|
|
253
|
+
const p = at(e);
|
|
254
|
+
if (!p) return;
|
|
255
|
+
// Enter commits what is typed (some engines fire no change for it).
|
|
256
|
+
(e.currentTarget as HTMLInputElement).dispatchEvent(new Event('change', { bubbles: true }));
|
|
257
|
+
commit(p, r);
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Search results: the host's search URL (?q=…, /browse/<q>, …) gives the
|
|
2
|
+
// query; our own state (facets, flags, sort) lives in namespaced lbx.*
|
|
3
|
+
// params that never collide with the host's. No DOM: the edge render
|
|
4
|
+
// service resolves URLs with it too.
|
|
5
|
+
|
|
6
|
+
import { P, searchAccepts, searchReadQuery } from '../../core/routes';
|
|
7
|
+
import type { SearchConfigType } from '../../types';
|
|
8
|
+
import { clearOwn, readOwn, type Query, type Route } from '../listing/state';
|
|
9
|
+
|
|
10
|
+
export const searchRoute = (c: SearchConfigType): Route => {
|
|
11
|
+
const read = searchReadQuery(c);
|
|
12
|
+
const flagDefs = c.flags ?? [];
|
|
13
|
+
return {
|
|
14
|
+
id: 'search',
|
|
15
|
+
tag: 'lbx-search',
|
|
16
|
+
endpoint: 'search',
|
|
17
|
+
flagDefs,
|
|
18
|
+
accepts: searchAccepts(c),
|
|
19
|
+
resolve: (url): Query => {
|
|
20
|
+
const q = read(url);
|
|
21
|
+
const flags = url.searchParams.getAll(P + 'flag').filter((id) => flagDefs.some((f) => f.id === id));
|
|
22
|
+
return { q, title: q, ...readOwn(url), flags };
|
|
23
|
+
},
|
|
24
|
+
write: (url, next) => {
|
|
25
|
+
clearOwn(url);
|
|
26
|
+
for (const [f, vs] of Object.entries(next.filters)) for (const v of vs) url.searchParams.append(P + f, v);
|
|
27
|
+
for (const id of next.flags) url.searchParams.append(P + 'flag', id);
|
|
28
|
+
if (next.sort) url.searchParams.set(P + 'sort', next.sort);
|
|
29
|
+
return url;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
};
|