@luigisbox/luma 0.0.0-stage → 0.1.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 (127) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +488 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +498 -0
  16. package/chrome/run.mjs +145 -0
  17. package/docs/ACCESSIBILITY.md +502 -0
  18. package/docs/INTEGRATION-PERFORMANCE.md +214 -0
  19. package/docs/PARITY.md +946 -0
  20. package/footprint/model.mjs +184 -0
  21. package/footprint/run.mjs +331 -0
  22. package/package.json +58 -4
  23. package/parity/alignment.mjs +109 -0
  24. package/parity/archive.mjs +223 -0
  25. package/parity/args.mjs +137 -0
  26. package/parity/behaviour.mjs +379 -0
  27. package/parity/compare.mjs +985 -0
  28. package/parity/engine.mjs +848 -0
  29. package/parity/hints.mjs +132 -0
  30. package/parity/hold.mjs +54 -0
  31. package/parity/init.mjs +703 -0
  32. package/parity/map.mjs +640 -0
  33. package/parity/owner.mjs +170 -0
  34. package/parity/pairing.mjs +110 -0
  35. package/parity/pixels.mjs +33 -0
  36. package/parity/probe.mjs +1239 -0
  37. package/parity/report.mjs +236 -0
  38. package/parity/run.mjs +267 -0
  39. package/snippet/early-hold.js +64 -0
  40. package/snippet/early-hold.min.js +1 -0
  41. package/src/api/ai.ts +98 -0
  42. package/src/api/index.ts +87 -0
  43. package/src/api/token-key.ts +42 -0
  44. package/src/api/token.ts +311 -0
  45. package/src/core/a11y-keys.ts +60 -0
  46. package/src/core/a11y.ts +160 -0
  47. package/src/core/boot-state.ts +6 -0
  48. package/src/core/color.ts +42 -0
  49. package/src/core/context.ts +108 -0
  50. package/src/core/early-rules.ts +96 -0
  51. package/src/core/entries.ts +25 -0
  52. package/src/core/fold.ts +7 -0
  53. package/src/core/health.ts +26 -0
  54. package/src/core/hold.ts +74 -0
  55. package/src/core/i18n.ts +66 -0
  56. package/src/core/image-load.ts +102 -0
  57. package/src/core/image-tags.ts +26 -0
  58. package/src/core/images.ts +170 -0
  59. package/src/core/integration.ts +120 -0
  60. package/src/core/listings.ts +100 -0
  61. package/src/core/locales.ts +14 -0
  62. package/src/core/own.ts +49 -0
  63. package/src/core/page.ts +14 -0
  64. package/src/core/prefetch.ts +88 -0
  65. package/src/core/recommenders.ts +166 -0
  66. package/src/core/reconciler.ts +397 -0
  67. package/src/core/result.ts +73 -0
  68. package/src/core/routes.ts +115 -0
  69. package/src/core/scheduler.ts +83 -0
  70. package/src/core/sense.ts +107 -0
  71. package/src/core/speculation.ts +59 -0
  72. package/src/core/stash.ts +38 -0
  73. package/src/core/storage.ts +45 -0
  74. package/src/core/styles.ts +106 -0
  75. package/src/core/support.ts +12 -0
  76. package/src/core/takeover.ts +220 -0
  77. package/src/core/templates.ts +74 -0
  78. package/src/core/turbo.ts +64 -0
  79. package/src/core/url.ts +164 -0
  80. package/src/core/widget-inits.ts +40 -0
  81. package/src/env.d.ts +3 -0
  82. package/src/lit.ts +11 -0
  83. package/src/locales/cs.json +94 -0
  84. package/src/locales/de.json +88 -0
  85. package/src/locales/en.json +88 -0
  86. package/src/main.ts +443 -0
  87. package/src/types.ts +760 -0
  88. package/src/widgets/autocomplete/index.ts +1367 -0
  89. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  90. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  91. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  92. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  93. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  94. package/src/widgets/listing/element.ts +1769 -0
  95. package/src/widgets/listing/flow.ts +30 -0
  96. package/src/widgets/listing/instant.ts +133 -0
  97. package/src/widgets/listing/intent.ts +193 -0
  98. package/src/widgets/listing/query.ts +298 -0
  99. package/src/widgets/listing/stash.ts +97 -0
  100. package/src/widgets/listing/state.ts +131 -0
  101. package/src/widgets/listing/view.ts +257 -0
  102. package/src/widgets/plp/index.ts +11 -0
  103. package/src/widgets/plp/route.ts +263 -0
  104. package/src/widgets/recommender/index.ts +250 -0
  105. package/src/widgets/recommender/style-recommender.css +26 -0
  106. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  107. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  108. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  109. package/src/widgets/search/assets.ts +7 -0
  110. package/src/widgets/search/first.ts +15 -0
  111. package/src/widgets/search/form-url.ts +59 -0
  112. package/src/widgets/search/in-place.ts +226 -0
  113. package/src/widgets/search/index.ts +16 -0
  114. package/src/widgets/search/range.ts +260 -0
  115. package/src/widgets/search/route.ts +32 -0
  116. package/src/widgets/search/speculate.ts +202 -0
  117. package/src/widgets/search/style-search.css +65 -0
  118. package/src/widgets/search/template-search-box.ts +17 -0
  119. package/src/widgets/search/template-search-facets-default.ts +73 -0
  120. package/src/widgets/search/template-search-header.ts +14 -0
  121. package/src/widgets/search/template-search-pagination.ts +12 -0
  122. package/src/widgets/search/template-search-range-default.ts +48 -0
  123. package/src/widgets/search/template-search-result-default.ts +13 -0
  124. package/src/widgets/search/template-search-results-default.ts +8 -0
  125. package/tasks/model.mjs +116 -0
  126. package/tasks/run.mjs +305 -0
  127. package/tsconfig.json +14 -0
@@ -0,0 +1,1367 @@
1
+ // Autocomplete without touching the host's input or its framework:
2
+ // - document-level capture listeners matched against event.target, so the
3
+ // host can re-render / duplicate / replace its input freely;
4
+ // - the dropdown lives in the top layer (popover) outside the host tree, so
5
+ // no z-index wars, no layout shift and no framework can remove it. By
6
+ // default its markup and CSS are ours, in a shadow root (the house design:
7
+ // a wide panel under the input, a full-screen sheet on a phone); with
8
+ // dom: 'light' it is a light-DOM element the site's own CSS styles (a 1:1
9
+ // port of the site's existing dropdown design);
10
+ // - one search request per configured type (products, categories, brands),
11
+ // in parallel, on the autocomplete's search surface;
12
+ // - plain Enter is left to the host, so its own router performs the
13
+ // navigation to its search page (SPA-friendly by construction); on our
14
+ // search page, its form's submit is a new query in place (search/in-place.ts).
15
+ // Loaded by the entry on the first interaction with the search input.
16
+
17
+ import { borrow, liveRegion, useContrast } from '../../core/a11y';
18
+ import { luminanceOf, readable } from '../../core/color';
19
+ import { onOwnBack } from '../../core/entries';
20
+ import { mark, reportError } from '../../core/health';
21
+ import { modalOf } from '../../core/own';
22
+ import { byName, view } from '../../core/templates';
23
+ import { first, navigable, plain, safeUrl } from '../../core/result';
24
+ import { afterFrame } from '../../core/scheduler';
25
+ import { onUrlChange } from '../../core/sense';
26
+ import { cspNonce, setCss } from '../../core/styles';
27
+ import type { WidgetContext } from '../../main';
28
+ import type { AutocompleteConfigType, AutocompleteScope, Result } from '../../types';
29
+ import css from './style-autocomplete.css?raw';
30
+
31
+ export const templates = byName(import.meta.glob('./template-*.ts', { eager: true, import: 'default' }));
32
+
33
+ /** Result pictures kept loaded, so a batch renders with them. */
34
+ const PRELOAD_MAX = 60;
35
+ /** How long a batch waits for its pictures. */
36
+ const PICTURE_WAIT = 100;
37
+
38
+ type CloseReason = NonNullable<Parameters<NonNullable<AutocompleteConfigType['onClose']>>[0]>;
39
+
40
+ /** How far up from the input its visible field is looked for (never past its form). */
41
+ const FIELD_LEVELS = 4;
42
+ /** The dropdown's narrowest width where the screen has room for it. */
43
+ const MIN_WIDTH = 360;
44
+ /** The house panel: its widest, the list-only panel's width, the gutter it keeps from the screen's edges, and the width below which the top product has no column. */
45
+ const PANEL_MAX = 1200;
46
+ const PANEL_LIST = 560;
47
+ const PANEL_GUTTER = 24;
48
+ const PANEL_WIDE = 1000;
49
+ /** Products beside the top product on the panel (2 x 3). */
50
+ const GRID_MAX = 6;
51
+ /** Phones (the viewport up to this width): the house design is a full-screen sheet. */
52
+ const SHEET_MEDIA = '(max-width: 600px)';
53
+
54
+ /** A computed colour that paints nothing (`rgba(0, 0, 0, 0)`, `transparent`, `color(srgb 0 0 0 / 0)`). */
55
+ const clear = (c: string) => !c || c === 'transparent' || /(?:,|\/)\s*0(?:\.0*)?\)$/.test(c);
56
+
57
+ /**
58
+ * The box draws a field: a visible border on any side, a shadow, or (an
59
+ * ancestor of the input only) a background. An input reset to `border: 0;
60
+ * background: none` inside a bordered wrapper draws nothing itself.
61
+ */
62
+ const paints = (cs: CSSStyleDeclaration, background: boolean) =>
63
+ (['Top', 'Right', 'Bottom', 'Left'] as const).some((d) => parseFloat(cs[`border${d}Width`]) > 0 && !/^(?:none|hidden)$/.test(cs[`border${d}Style`]) && !clear(cs[`border${d}Color`])) ||
64
+ cs.boxShadow !== 'none' ||
65
+ (background && (!clear(cs.backgroundColor) || cs.backgroundImage !== 'none'));
66
+
67
+ /** The parent across a shadow boundary (a web component's search box) and into a slot. */
68
+ const up = (el: Element): Element | null => el.assignedSlot ?? el.parentElement ?? ((el.getRootNode() as ShadowRoot).host as Element | undefined) ?? null;
69
+
70
+ /**
71
+ * The element the dropdown lines up with (left edge and width, just below
72
+ * it), read once per opening (styles and boxes, before anything of ours is
73
+ * written in that task):
74
+ * - `alignTo: 'input'`: the input;
75
+ * - `alignTo: '<selector>'`: the input's nearest ancestor matching it (across
76
+ * shadow roots), else the input;
77
+ * - by default, the visible search field: the input when it draws its own
78
+ * border or shadow; else the nearest ancestor, up to its form or
79
+ * FIELD_LEVELS levels, that draws one (a border, a shadow or a background)
80
+ * and is the field, not the bar it sits in: at least the input's width, not
81
+ * much taller than it, not as wide as the page. Else the input.
82
+ */
83
+ export const fieldOf = (input: HTMLInputElement, alignTo?: string): Element => {
84
+ if (alignTo === 'input') return input;
85
+ if (alignTo) {
86
+ for (let el = up(input); el; el = up(el)) {
87
+ try {
88
+ if (el.matches(alignTo)) return el;
89
+ } catch {
90
+ return input; // not a selector: reported by nobody, aligned as before
91
+ }
92
+ }
93
+ return input;
94
+ }
95
+ if (paints(getComputedStyle(input), false)) return input;
96
+ const r = input.getBoundingClientRect();
97
+ const vw = document.documentElement.clientWidth;
98
+ let el: Element | null = input;
99
+ for (let n = 0; n < FIELD_LEVELS; n++) {
100
+ el = up(el);
101
+ if (!el || el === document.body || el === document.documentElement) break;
102
+ const b = el.getBoundingClientRect();
103
+ if (b.width >= r.width && b.height <= r.height * 2 + 16 && b.width < vw - 16 && paints(getComputedStyle(el), true)) return el;
104
+ if (el.localName === 'form' || el.localName === 'dialog') break;
105
+ }
106
+ return input;
107
+ };
108
+
109
+ /**
110
+ * Where a light-DOM dropdown goes for a field's box in a viewport `vw` wide:
111
+ * its left edge and width the field's, at least MIN_WIDTH (a narrow field's
112
+ * dropdown still has room for a product row), lined up with the field's
113
+ * right edge instead when it would leave the screen. On a phone (narrower
114
+ * than 480 px): a field spanning most of the screen exactly, else the
115
+ * screen's width. Never closer than 8 px to an edge.
116
+ */
117
+ export const dropdownBox = (r: { left: number; right: number; width: number }, vw: number) => {
118
+ const full = { left: 8, width: Math.max(0, vw - 16) };
119
+ if (vw < 480) return r.width >= vw * 0.75 ? { left: Math.max(8, r.left), width: Math.min(r.width, vw - 8 - Math.max(8, r.left)) } : full;
120
+ const width = Math.max(r.width, MIN_WIDTH);
121
+ if (width > vw - 16) return full;
122
+ const left = r.left + width <= vw - 8 ? Math.max(8, r.left) : Math.max(8, r.right - width);
123
+ return { left, width };
124
+ };
125
+
126
+ /**
127
+ * Where the house panel goes: `min(1200px, 100vw - 48px)` wide (560
128
+ * for a batch without products), centred under the field, kept 24 px from
129
+ * the screen's edges; its caret under the field's left edge + 30 px (inside
130
+ * the panel's corners). `wide`: the top product has its own column.
131
+ */
132
+ export const panelBox = (r: { left: number; width: number }, vw: number, listOnly = false) => {
133
+ const width = Math.max(0, Math.min(listOnly ? PANEL_LIST : PANEL_MAX, vw - 2 * PANEL_GUTTER));
134
+ const left = Math.max(PANEL_GUTTER, Math.min(r.left + r.width / 2 - width / 2, vw - PANEL_GUTTER - width));
135
+ const caret = Math.max(16, Math.min(r.left + 30 - left, width - 30));
136
+ return { left, width, caret, wide: width >= PANEL_WIDE };
137
+ };
138
+
139
+ /** The house tokens a config may set (`autocomplete.tokens`), as their custom properties. */
140
+ const TOKEN_PROPS: Record<string, string> = {
141
+ font: 'font',
142
+ fg: 'fg',
143
+ bg: 'bg',
144
+ accent: 'accent',
145
+ radius: 'radius',
146
+ muted: 'muted',
147
+ line: 'line',
148
+ soft: 'soft',
149
+ tint: 'tint',
150
+ tintStrong: 'tint-strong',
151
+ onAccent: 'on-accent',
152
+ ok: 'ok',
153
+ radiusRow: 'radius-row',
154
+ radiusTile: 'radius-tile',
155
+ };
156
+ /** `autocomplete.tokens` as a `:host` rule after ours (a value that could end the rule is dropped). */
157
+ export const tokenCss = (tokens: AutocompleteConfigType['tokens']) => {
158
+ const decl = Object.entries(tokens ?? {})
159
+ .filter(([k, v]) => TOKEN_PROPS[k] && typeof v === 'string' && v.trim() && !/[;{}<>\\]/.test(v))
160
+ .map(([k, v]) => `--lbx-${TOKEN_PROPS[k]}: ${v!.trim()};`);
161
+ return decl.length ? `:host { ${decl.join(' ')} }` : '';
162
+ };
163
+ /** Text on an accent fill: white, or near-black on a light accent (luminance over 0.4). */
164
+ export const onAccent = (accent: string) => ((luminanceOf(accent) ?? 0) > 0.4 ? '#0a0f12' : '#fff');
165
+
166
+ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
167
+ const ac = ctx.config;
168
+ const light = ac.dom === 'light';
169
+ const ORDER = ac.types.map((x) => x.type);
170
+ const PRODUCT = ctx.global.itemType ?? 'product';
171
+ const selector = ctx.global.inputSelector!;
172
+ // An empty phrase never asks (it closes the dropdown), whatever minChars says:
173
+ // the default 0 means the first letter asks.
174
+ const minChars = Math.max(1, ac.minChars ?? 0);
175
+ const t = ctx.t;
176
+ const empty = ac.emptyState ?? {};
177
+
178
+ let host: HTMLElement | null = null;
179
+ let box: HTMLElement | ShadowRoot;
180
+ let v: ReturnType<typeof view>;
181
+ let input: HTMLInputElement | null = null;
182
+ /** The batch's results (config order), what "Suggestions: N" counts. */
183
+ let results: Result[] = [];
184
+ /** The options on screen: the results, or with none, the "No results" layout's (phrases to try, links, fallback products). */
185
+ let items: Result[] = [];
186
+ /** The arrow keys' order over `items` (the house design's reading order: pills, lists, the top product, products). */
187
+ let order: number[] = [];
188
+ let lastQuery = '';
189
+ let active = -1;
190
+ let open = false;
191
+ let raf = 0;
192
+ let suppressInput = false;
193
+ /** Focus handed back to the input by us (Escape or Tab on "Show all products"): not the visitor coming back, nothing reopens. */
194
+ let quietFocus = false;
195
+ /** The phrase the dropdown is for (normalised input), '' while closed by the visitor or below minChars. */
196
+ let want = '';
197
+ /** Per type, what the dropdown shows. */
198
+ const shown = new Map<string, Result[]>();
199
+ /** The house panel: wide enough for the top product's column; a batch without products (the list alone, narrower). */
200
+ let wide = true;
201
+ let listOnly = false;
202
+ /** The phone sheet is what is open (house design, phone viewport); its input, its tab, our history entry. */
203
+ let sheet = false;
204
+ let mirror: HTMLInputElement | null = null;
205
+ let tab = '';
206
+ let entry = false;
207
+ let afterBack: (() => void) | null = null;
208
+ // Accessibility (docs/ACCESSIBILITY.md, "Autocomplete"): the host's input is
209
+ // the combobox. Its ARIA is borrowed while it has focus and put back when
210
+ // focus leaves it; the result count is announced politely, once typing pauses.
211
+ let aria: ReturnType<typeof borrow> | null = null;
212
+ const live = liveRegion();
213
+ /** What the region last said in this opening (a pause with the same count says nothing new). */
214
+ let said = '';
215
+ useContrast();
216
+
217
+ // Integration hooks: one that throws must not leave the dropdown half-open.
218
+ const hook = <A extends unknown[]>(name: string, fn: ((...a: A) => void) | undefined, ...args: A) => {
219
+ try {
220
+ fn?.(...args);
221
+ } catch (e) {
222
+ reportError(`autocomplete ${name}`, e);
223
+ }
224
+ };
225
+
226
+ /** The house design's phone sheet applies here (shadow DOM, a phone's viewport). */
227
+ const phone = () => !light && typeof matchMedia === 'function' && matchMedia(SHEET_MEDIA).matches;
228
+
229
+ /** Our shadow root on `el` with our CSS; the box templates render into. */
230
+ const shadowBox = (el: HTMLElement, ...before: Node[]) => {
231
+ const root = el.attachShadow({ mode: 'open' });
232
+ // Elements, not innerHTML: sites enforcing Trusted Types reject string HTML.
233
+ const style = document.createElement('style');
234
+ style.nonce = cspNonce();
235
+ // Ours, then the config's tokens, then the integration's CSS (which may set the same properties).
236
+ style.textContent = [css, tokenCss(ac.tokens), ...ctx.styles].join('\n');
237
+ const b = document.createElement('div');
238
+ b.className = 'lbx-root'; // the sheet fills the host (style-autocomplete.css)
239
+ root.append(style, ...before, b);
240
+ return b;
241
+ };
242
+
243
+ const ensureHost = () => {
244
+ if (host) return;
245
+ host = document.createElement('div');
246
+ host.setAttribute('data-lbx', 'ac-overlay');
247
+ host.setAttribute('popover', 'manual');
248
+ host.id = (light && ac.hostId) || 'lbx-ac-overlay';
249
+ if (light) {
250
+ // The site's CSS positions and paints it (their classes); only the UA
251
+ // popover styles the site's CSS does not expect are neutralised.
252
+ if (ac.hostClass) host.className = ac.hostClass;
253
+ host.style.cssText = 'position:fixed;inset:auto;border:0;color:inherit;pointer-events:auto;display:none !important;';
254
+ // The UA popover's height: fit-content makes a fixed panel the site pins
255
+ // with top + bottom (a full-height sheet on phones) grow past the screen
256
+ // instead of scrolling. Zero specificity: any site rule still wins.
257
+ setCss('ac:light', ':where([data-lbx="ac-overlay"]) { height: auto; }');
258
+ ctx.styles.forEach((c, i) => setCss(`itg:autocomplete:${i}`, c));
259
+ box = host;
260
+ host.append(live.el); // the templates render after it
261
+ } else {
262
+ // Neutralise UA popover styles; we position it ourselves. pointer-events:
263
+ // a modal library may set `pointer-events: none` on body, which inherits.
264
+ host.style.cssText = 'position:fixed;inset:auto;margin:0;padding:0;border:0;background:transparent;overflow:visible;pointer-events:auto;display:none !important;';
265
+ box = shadowBox(host, live.el);
266
+ }
267
+ v = view(box, ctx.templates);
268
+ // Keep focus in the host input (also keeps Bootstrap/Angular focus traps happy).
269
+ // A touch keeps it by its compatibility mousedown instead: WebKit (iOS)
270
+ // sends no click for a tap whose pointerdown was cancelled, so a tapped
271
+ // suggestion did nothing. In the phone sheet focus stays in its own
272
+ // input the same way (the keyboard stays up over a tab or a clear), and
273
+ // a press into that input itself places its caret.
274
+ const intoMirror = (e: Event) => sheet && e.composedPath()[0] === mirror;
275
+ box.addEventListener('pointerdown', (e) => e.pointerType !== 'touch' && !intoMirror(e) && e.preventDefault());
276
+ box.addEventListener('mousedown', (e) => !intoMirror(e) && e.preventDefault());
277
+ // "Show all products" has focus (Tab from the input): Shift+Tab goes back
278
+ // to the input, Escape closes and goes back, Tab moves on as from the
279
+ // input (focus put back on it, the browser's own Tab then leaves it: the
280
+ // page's next control after the input, wherever our dropdown sits). In
281
+ // the sheet, Tab is the browser's (back, input, clear, tabs, show all),
282
+ // the arrow keys move between tabs, Escape is its back button.
283
+ box.addEventListener('keydown', (e) => {
284
+ const el = e.composedPath()[0] as Element;
285
+ if (!inOverlay(el) || e.ctrlKey || e.metaKey) return;
286
+ if (sheet) {
287
+ if (e.key === 'Escape') {
288
+ e.preventDefault();
289
+ e.stopPropagation();
290
+ leave('back');
291
+ } else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && el.getAttribute?.('role') === 'tab') {
292
+ e.preventDefault();
293
+ const tabs = [...(el.parentElement?.children ?? [])] as HTMLElement[];
294
+ const next = tabs[(tabs.indexOf(el as HTMLElement) + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length];
295
+ next?.click();
296
+ next?.focus();
297
+ }
298
+ return;
299
+ }
300
+ if (e.key !== 'Tab' && e.key !== 'Escape') return;
301
+ if (e.key === 'Escape') {
302
+ e.preventDefault();
303
+ e.stopPropagation(); // ours, not a host modal's
304
+ close('escape');
305
+ backToInput();
306
+ } else if (e.shiftKey) {
307
+ e.preventDefault();
308
+ backToInput();
309
+ } else {
310
+ close('blur');
311
+ backToInput();
312
+ }
313
+ });
314
+ // Focus leaving the dropdown for anywhere but the input: closed, as when
315
+ // it leaves the input. Not the sheet: it covers the page, and its input
316
+ // lets go whenever the shopper puts the keyboard away to read.
317
+ box.addEventListener('focusout', () =>
318
+ setTimeout(() => {
319
+ if (sheet) return;
320
+ const f = focused();
321
+ if (f === input || inOverlay(f)) return;
322
+ close('blur');
323
+ release();
324
+ }, 0),
325
+ );
326
+ };
327
+
328
+ const sampleTokens = (i: HTMLInputElement, at: Element) => {
329
+ const cs = getComputedStyle(i);
330
+ const body = getComputedStyle(document.body);
331
+ const radius = at === i ? cs.borderRadius : getComputedStyle(at).borderRadius;
332
+ const accentEl = ac.accentFrom ? document.querySelector(ac.accentFrom) : null;
333
+ const accentCs = accentEl ? getComputedStyle(accentEl) : null;
334
+ const sampled = accentCs && !clear(accentCs.backgroundColor) ? accentCs.backgroundColor : accentCs?.color || body.color;
335
+ const s = host!.style;
336
+ // On our white panel, the host's colours only where they read (WCAG 1.4.3):
337
+ // a dark site's light body text, or a pale brand colour, would not.
338
+ const fg = readable(body.color, '#fff', '#1a1a1a');
339
+ const accent = ac.tokens?.accent ?? readable(sampled, '#fff', fg);
340
+ // The house design's five site tokens (style-autocomplete.css derives the
341
+ // rest; the config's and the integration's CSS override either).
342
+ s.setProperty('--lbx-s-font', cs.fontFamily);
343
+ s.setProperty('--lbx-s-fg', fg);
344
+ s.setProperty('--lbx-s-bg', '#fff');
345
+ s.setProperty('--lbx-s-accent', accent);
346
+ s.setProperty('--lbx-s-on-accent', onAccent(accent));
347
+ s.setProperty('--lbx-s-radius', `${Math.max(0, parseFloat(radius) || 0)}px`);
348
+ };
349
+
350
+ /** What the dropdown lines up with for this opening (`alignTo`): the visible search field, else the input. */
351
+ let field: Element | null = null;
352
+
353
+ /** Where the field is (null: hidden), read before anything of ours is written in the task. */
354
+ const measure = () => {
355
+ if (!input) return null;
356
+ // checkVisibility: not in Safari before 17.4.
357
+ if (!input.isConnected || input.getBoundingClientRect().width === 0 || (input.checkVisibility && !input.checkVisibility({ checkVisibilityCSS: true }))) return null;
358
+ const at = (field?.isConnected ? field : input).getBoundingClientRect();
359
+ return { r: at.width ? at : input.getBoundingClientRect(), vw: document.documentElement.clientWidth };
360
+ };
361
+ /**
362
+ * Where the dropdown is, every frame while open; `frame`: from the frame
363
+ * loop (else ahead of a render, which follows); `m`: the field measured
364
+ * before this task's writes (an opening: read after showing the popover,
365
+ * it forced a layout).
366
+ */
367
+ const position = (frame = false, m?: ReturnType<typeof measure>) => {
368
+ if (!open || !input) return;
369
+ const s = host!.style;
370
+ if (sheet) {
371
+ // Full screen: what shows of the page (the visual viewport, above the
372
+ // on-screen keyboard), so the sticky "Show all" sits above the keys.
373
+ const vv = window.visualViewport;
374
+ s.top = `${Math.round(vv?.offsetTop ?? 0)}px`;
375
+ s.left = `${Math.round(vv?.offsetLeft ?? 0)}px`;
376
+ s.width = `${Math.round(vv?.width ?? innerWidth)}px`;
377
+ s.height = `${Math.round(vv?.height ?? innerHeight)}px`;
378
+ raf = requestAnimationFrame(() => position(true));
379
+ return;
380
+ }
381
+ const got = m === undefined ? measure() : m;
382
+ if (!got) return close('hidden');
383
+ const { r, vw } = got;
384
+ s.top = `${Math.round(r.bottom + 4)}px`;
385
+ if (light) {
386
+ const { left, width } = dropdownBox(r, vw);
387
+ s.left = `${Math.round(left)}px`;
388
+ s.width = `${Math.round(width)}px`;
389
+ } else {
390
+ const p = panelBox(r, vw, listOnly);
391
+ s.left = `${Math.round(p.left)}px`;
392
+ s.width = `${Math.round(p.width)}px`;
393
+ s.setProperty('--lbx-caret', `${Math.round(p.caret)}px`);
394
+ s.setProperty('--lbx-max-h', `${Math.max(240, Math.round(innerHeight - r.bottom - 32))}px`);
395
+ // The window crossed the top product's breakpoint: drawn again for it (from the frame, not a layout read after a render).
396
+ if (p.wide !== wide) {
397
+ wide = p.wide;
398
+ if (frame) render();
399
+ }
400
+ }
401
+ raf = requestAnimationFrame(() => position(true));
402
+ };
403
+
404
+ /**
405
+ * The (hidden) dropdown into the document, ahead of showing it: inserting a
406
+ * node into <body> restyles the whole host page, and showPopover() would
407
+ * force that into the task that shows our results. Done on focus, it is part
408
+ * of the next frame's own rendering.
409
+ */
410
+ const place = () => {
411
+ ensureHost();
412
+ const parent = modalOf(input!) ?? document.body;
413
+ if (host!.parentNode !== parent) parent.append(host!);
414
+ watchRoots(input!);
415
+ if (!sheet) adopt(input!);
416
+ };
417
+
418
+ /**
419
+ * The input as a combobox (WAI-ARIA APG, list autocomplete): role, its
420
+ * popup, open or not. Set while the input has focus, put back as it was
421
+ * once focus leaves it (or another input takes over); attributes the host
422
+ * changes meanwhile are its own and stay.
423
+ */
424
+ const adopt = (i: HTMLInputElement) => {
425
+ if (aria?.el === i) return;
426
+ aria?.restore();
427
+ aria = borrow(i);
428
+ if (i.getAttribute('role') !== 'combobox') aria.set('role', 'combobox');
429
+ aria.set('aria-autocomplete', 'list');
430
+ aria.set('aria-expanded', String(open));
431
+ aria.set('aria-controls', host!.id);
432
+ };
433
+ const release = () => {
434
+ aria?.restore();
435
+ aria = null;
436
+ };
437
+ /**
438
+ * The selected row (the screen reader's focus stays on the input). An id
439
+ * reference resolves only in the input's own tree, never across a shadow
440
+ * boundary:
441
+ * - light DOM, the input in the document: aria-activedescendant, its id;
442
+ * - light DOM, the input in a shadow root (a web-component header): our
443
+ * dropdown is in the document, which encloses that root, so the option is
444
+ * referenced by element reflection (`ariaActiveDescendantElement`) where
445
+ * the engine has it; never an id that would name nothing there;
446
+ * - the phone sheet: its own input is the combobox, in the same shadow root
447
+ * as the options: aria-activedescendant, its id;
448
+ * - our shadow root (default), or no reflection: nothing can reference the
449
+ * row, so it is announced through the dropdown's status region instead
450
+ * (not for a row the pointer is over: `say` false).
451
+ */
452
+ const markActive = (say = true) => {
453
+ const row = active >= 0 ? (box.getRootNode() as Document | ShadowRoot).getElementById(`lbx-ac-${active}`) : null;
454
+ if (sheet && mirror) {
455
+ if (row) mirror.setAttribute('aria-activedescendant', row.id);
456
+ else mirror.removeAttribute('aria-activedescendant');
457
+ } else if (light && aria && (!row || row.getRootNode() === aria.el.getRootNode())) aria.set('aria-activedescendant', row?.id ?? null);
458
+ else if (light && aria && 'ariaActiveDescendantElement' in aria.el) {
459
+ // Reflection writes the attribute as '': borrowed, so it is put back as the host had it.
460
+ aria.set('aria-activedescendant', '');
461
+ (aria.el as any).ariaActiveDescendantElement = row;
462
+ } else if (row) {
463
+ if (light) aria?.set('aria-activedescendant', null);
464
+ if (say) live.say((row.textContent ?? '').replace(/\s+/g, ' ').trim(), 0);
465
+ }
466
+ return row;
467
+ };
468
+
469
+ const show = () => {
470
+ place();
471
+ if (!open) {
472
+ // The field and the site's colours: read after the focus's frame (prepare), else now.
473
+ // Reads first (the field, where it is, the colours), then our writes.
474
+ const fresh = prepared !== input;
475
+ if (fresh) field = fieldOf(input!, ac.alignTo);
476
+ const m = measure();
477
+ if (fresh && !light) sampleTokens(input!, field!);
478
+ prepared = null;
479
+ // Never trust UA popover styles for visibility: host CSS can override
480
+ // [popover]:not(:popover-open){display:none} (some sites do). Inline
481
+ // !important beats any host stylesheet.
482
+ host!.style.setProperty('display', 'block', 'important');
483
+ host!.style.removeProperty('visibility'); // a rehearsal's, still going on
484
+ host!.showPopover();
485
+ open = true;
486
+ aria?.set('aria-expanded', 'true');
487
+ hook('onOpen', ac.onOpen);
488
+ cancelAnimationFrame(raf);
489
+ // A hook may move the field (a storefront's opens its search strip, the
490
+ // input lower): placed from where it is now, not from `m`, read before
491
+ // it (otherwise the dropdown opens at the old place, then jumps).
492
+ position(false, ac.onOpen ? undefined : m);
493
+ }
494
+ };
495
+
496
+ /**
497
+ * The field the dropdown lines up with and the site's colours, read once
498
+ * the focus's frame has rendered (layout clean then), ahead of the first
499
+ * opening: read at the opening, after the page and our render had changed,
500
+ * they forced a style and layout into the task that shows the results.
501
+ */
502
+ let prepared: HTMLInputElement | null = null;
503
+ const prepare = (i: HTMLInputElement) =>
504
+ afterFrame(() => {
505
+ if (open || input !== i || !i.isConnected || sheet) return;
506
+ field = fieldOf(i, ac.alignTo);
507
+ if (!light) sampleTokens(i, field);
508
+ prepared = i;
509
+ place(); // after the reads: it dirties the page's style
510
+ rehearse();
511
+ });
512
+
513
+ /**
514
+ * The first results drawn twice as fast: a page's first opening paid
515
+ * one-time costs (lit preparing every template, the first style and
516
+ * layout of each text style: font matching, shaping) in the task that
517
+ * showed them and in the next frame, which a keystroke then waited for (on
518
+ * a production storefront at 4x CPU: a 91 ms render and a 38-48 ms layout; a later opening
519
+ * of the same page: 42 and 12 ms). Paid ahead, while the visitor starts
520
+ * typing and the first answers are on their way: a rehearsal renders the
521
+ * dropdown once, in steps (a few types at a time, each its own idle task),
522
+ * with placeholder rows of every configured type, laid out but invisible,
523
+ * then hidden and emptied: the real render finds lit's templates prepared
524
+ * and the browser's style and font work for each text style done (its rows
525
+ * would be new nodes anyway: keyed by result). The phone sheet is on
526
+ * screen when it renders (empty until the first results): rehearsed in an
527
+ * invisible twin of it, removed once done. The house design only (a
528
+ * light-DOM integration's host is their markup). Given up when the real
529
+ * results come first, or a template throws on the placeholders.
530
+ */
531
+ let rehearsed = false;
532
+ const rehearse = () => {
533
+ if (rehearsed || light || !host || results.length || (open && !sheet)) return;
534
+ rehearsed = true;
535
+ const fill = (f: string): unknown => (/price/i.test(f) ? 1290 : /image|img/i.test(f) ? '' : /url/i.test(f) ? '/' : /avail|stock/i.test(f) ? 1 : 'Lorem ipsum dolor sit amet');
536
+ const fake = ac.types.flatMap((x) =>
537
+ Array.from({ length: Math.min(x.size, 12) }, (_, n) =>
538
+ ctx.result({ ...Object.fromEntries((x.fields ?? []).map((f) => [f, fill(f)])), '@id': `rehearsal/${x.type}/${n}`, '@type': x.type, '@title': 'Lorem ipsum dolor sit amet consectetur' }),
539
+ ),
540
+ );
541
+ // A type at a time (the lists' small rows first), the products a third at a time.
542
+ const lists = fake.filter((r) => r.type !== PRODUCT);
543
+ const products = fake.filter((r) => r.type === PRODUCT);
544
+ const steps: Result[][] = [];
545
+ for (const type of ORDER.filter((x) => x !== PRODUCT)) steps.push([...(steps.at(-1) ?? []), ...lists.filter((r) => r.type === type)]);
546
+ const third = Math.ceil(products.length / 3);
547
+ for (let n = third; products.length && n < products.length + third; n += third) steps.push([...lists, ...products.slice(0, n)]);
548
+ // The sheet is on screen (open, empty until the first results): an
549
+ // invisible twin of it, the same CSS and the site's tokens, full screen.
550
+ const mode = sheet;
551
+ let twin: HTMLElement | null = null;
552
+ let tv: ReturnType<typeof view> | null = null;
553
+ const st = host.style;
554
+ const done = () => {
555
+ if (twin) twin.remove();
556
+ else {
557
+ st.removeProperty('visibility');
558
+ // Its rows emptied: showing them again would style placeholders the
559
+ // first results replace (in showPopover's forced style, the opening's task).
560
+ // Not real results drawn meanwhile, to be shown a frame later (an
561
+ // integration with onOpen, `later` in paint): it never draws once they
562
+ // are here, and emptying them opened an empty dropdown (2026-10-12).
563
+ if (!open) {
564
+ st.setProperty('display', 'none', 'important');
565
+ if (!results.length) v.clear();
566
+ }
567
+ }
568
+ mark('ac rehearsed');
569
+ };
570
+ const step = (k: number) => {
571
+ // Real results since (or the dropdown shown, the sheet closed): theirs from now on.
572
+ if (results.length || !input || sheet !== mode || (mode ? !open : open)) return done();
573
+ const was = { results, items, lastQuery, active, order, tab, v };
574
+ try {
575
+ if (k === 0) {
576
+ if (mode) {
577
+ twin = document.createElement('div');
578
+ twin.style.cssText = `${st.cssText};position:fixed;left:0;top:0;width:${innerWidth}px;height:${innerHeight}px;visibility:hidden;pointer-events:none;display:block !important`;
579
+ twin.setAttribute('aria-hidden', 'true');
580
+ tv = view(shadowBox(twin), ctx.templates);
581
+ document.body.append(twin);
582
+ } else {
583
+ const m = measure();
584
+ if (!m) return done();
585
+ st.setProperty('visibility', 'hidden');
586
+ st.top = '0px';
587
+ st.left = '0px';
588
+ st.width = `${Math.round(panelBox(m.r, m.vw).width)}px`;
589
+ st.setProperty('display', 'block', 'important');
590
+ }
591
+ }
592
+ if (tv) v = tv;
593
+ results = items = steps[k];
594
+ lastQuery = 'Lorem ipsum';
595
+ active = -1;
596
+ render();
597
+ } catch (e) {
598
+ reportError('autocomplete rehearsal', e);
599
+ return done();
600
+ } finally {
601
+ ({ results, items, lastQuery, active, order, tab, v } = was);
602
+ }
603
+ // The next step once this one is laid out (its frame), when the browser is idle again.
604
+ if (k + 1 < steps.length) idle(() => step(k + 1));
605
+ else afterFrame(done);
606
+ };
607
+ idle(() => step(0));
608
+ };
609
+ const idle = (f: () => void) => afterFrame(() => (typeof requestIdleCallback === 'function' ? requestIdleCallback(f, { timeout: 300 }) : setTimeout(f, 0)));
610
+
611
+ /**
612
+ * The phone sheet (house design): full screen over the page,
613
+ * opened when the site's input gets focus. Its own input takes the focus
614
+ * at once (in the tap's task: iOS shows its keyboard only for a focus in
615
+ * the gesture) with what the site's input holds; it is the combobox there.
616
+ * A history entry of ours (null state, the same URL) stands for it: Back
617
+ * closes it first (core/entries.ts `onOwnBack`). The page behind does not
618
+ * scroll while it is open.
619
+ */
620
+ const openSheet = () => {
621
+ const i = input!;
622
+ if (!open) {
623
+ sheet = true;
624
+ tab = '';
625
+ place();
626
+ sampleTokens(i, i);
627
+ host!.style.setProperty('display', 'block', 'important');
628
+ host!.showPopover();
629
+ open = true;
630
+ hook('onOpen', ac.onOpen);
631
+ setCss('ac:sheet', ':root { overflow: hidden !important; }');
632
+ try {
633
+ history.pushState(null, '', location.href);
634
+ entry = true;
635
+ } catch {}
636
+ cancelAnimationFrame(raf);
637
+ position();
638
+ render(); // with what the site's input holds
639
+ mirror = (box as ShadowRoot | HTMLElement).querySelector<HTMLInputElement>('[data-lbx-sheet-input]');
640
+ }
641
+ if (mirror) {
642
+ mirror.focus();
643
+ // The caret or selection the site's input has (where a tap put it, all
644
+ // of it after a Tab): typing goes on as it would have there.
645
+ const end = mirror.value.length;
646
+ try {
647
+ mirror.setSelectionRange(Math.min(i.selectionStart ?? end, end), Math.min(i.selectionEnd ?? end, end));
648
+ } catch {}
649
+ }
650
+ query(mirror?.value ?? i.value);
651
+ rehearse(); // the first results' one-time costs, while the visitor starts typing
652
+ };
653
+
654
+ // Back on our entry (the browser's Back, our back button's history.back()): the sheet closes, then what was waiting for it.
655
+ onOwnBack(() => {
656
+ if (!entry) return false;
657
+ entry = false;
658
+ const then = afterBack;
659
+ afterBack = null;
660
+ close(then ? pendingReason : 'back');
661
+ // After the frame: the browser restores the entry's scroll position
662
+ // after its popstate, which would undo a scroll of what follows (a new
663
+ // query in place goes to the top).
664
+ if (then) requestAnimationFrame(() => setTimeout(then, 0));
665
+ return true;
666
+ });
667
+ let pendingReason: CloseReason = 'back';
668
+ /**
669
+ * Close the sheet: our history entry first (history.back()), then `then`
670
+ * (a navigation, the site's search) once it is gone, so the shopper's Back
671
+ * from what follows is the page they were on, not our entry. Without one
672
+ * (or anywhere but the sheet): closed now, then `then`.
673
+ */
674
+ const leave = (reason: CloseReason, then?: () => void) => {
675
+ if (!sheet || !entry) {
676
+ close(reason);
677
+ then?.();
678
+ return;
679
+ }
680
+ const done = then ?? (() => {});
681
+ afterBack = done;
682
+ pendingReason = reason;
683
+ history.back();
684
+ // A Back the browser does not deliver (an entry it dropped): on after a while.
685
+ setTimeout(() => {
686
+ if (afterBack !== done) return;
687
+ afterBack = null;
688
+ entry = false;
689
+ close(reason);
690
+ done();
691
+ }, 600);
692
+ };
693
+
694
+ const close = (reason: CloseReason = 'api') => {
695
+ // Answers still on their way fill the cache but do not reopen it: only the
696
+ // visitor does (typing, focus, a press into the input).
697
+ want = '';
698
+ clearTimeout(timer); // a debounce still pending asks for nothing
699
+ timer = waiting = 0;
700
+ if (!open) return;
701
+ open = false;
702
+ active = -1;
703
+ cancelAnimationFrame(raf);
704
+ const wasSheet = sheet;
705
+ // The sheet had the focus: a browser that hands it back to the site's
706
+ // input as the popover hides must not reopen it (and its keyboard).
707
+ quietFocus = wasSheet;
708
+ try {
709
+ host!.hidePopover();
710
+ } catch {}
711
+ quietFocus = false;
712
+ host!.style.setProperty('display', 'none', 'important');
713
+ if (wasSheet) {
714
+ sheet = false;
715
+ entry = false; // left in history (the same URL) unless Back took it
716
+ setCss('ac:sheet', null);
717
+ host!.style.height = '';
718
+ mirror?.blur();
719
+ if (focused() === input) input?.blur();
720
+ }
721
+ aria?.set('aria-expanded', 'false');
722
+ aria?.set('aria-activedescendant', null);
723
+ live.clear();
724
+ said = '';
725
+ hook('onClose', ac.onClose, reason);
726
+ };
727
+
728
+ /** The sheet input's value the last render drew (its clear button). */
729
+ let drawnValue = '';
730
+ /** Rows as templates get them: the result and its index in `items`. */
731
+ const rowsOf = (pred: (r: Result) => boolean) => items.map((r, i) => ({ r, i })).filter(({ r }) => pred(r));
732
+
733
+ const render = () => {
734
+ if (!host) return;
735
+ const rows = items.map((r, i) => ({ r, i }));
736
+ const state: AutocompleteScope['state'] = results.length ? 'results' : lastQuery || !sheet ? 'empty' : 'idle';
737
+ const inResults = state === 'results';
738
+ const byType = (type: string) => (inResults ? rows.filter(({ r }) => r.type === type) : []);
739
+ const groups = inResults ? ORDER.map((kind) => ({ kind, label: t(`types.${kind}`), rows: byType(kind) })).filter((g) => g.rows.length) : [];
740
+ const products = byType(PRODUCT);
741
+ const lists = groups.filter((g) => g.kind !== PRODUCT && g.kind !== 'query');
742
+ // The sheet's tabs: one per type with results, products first.
743
+ const kinds = [...(products.length ? [PRODUCT] : []), ...lists.map((g) => g.kind)];
744
+ if (!kinds.includes(tab)) tab = kinds[0] ?? '';
745
+ const hero = products[0] ?? null;
746
+ const grid = sheet ? products.slice(1) : products.slice(wide ? 1 : 0, (wide ? 1 : 0) + GRID_MAX);
747
+ const emptyRows = (kind: string) => (state === 'empty' ? rowsOf((r) => (r as any).__empty === kind) : []);
748
+ const tries = emptyRows('try');
749
+ const browse = emptyRows('browse');
750
+ const fallback = emptyRows('fallback');
751
+ // The arrow keys' order: pills, the lists in config order, the
752
+ // top product, the products in reading order; in the sheet, the pills and
753
+ // the open tab's options; with no results, phrases to try, links, picks.
754
+ // A light-DOM integration's own markup keeps the config order.
755
+ const phrases = byType('query');
756
+ order = light
757
+ ? rows.map((x) => x.i)
758
+ : state === 'empty'
759
+ ? [...tries, ...browse, ...fallback].map((x) => x.i)
760
+ : sheet
761
+ ? [...phrases, ...(tab === PRODUCT ? products : byType(tab))].map((x) => x.i)
762
+ : [...phrases, ...lists.flatMap((g) => g.rows), ...(wide && hero ? [hero] : []), ...grid].map((x) => x.i);
763
+ if (active >= 0 && !order.includes(active)) active = -1;
764
+ const phoneNumber = t('help.phone');
765
+ const help = phoneNumber && phoneNumber !== 'help.phone' ? { title: t('help.title'), phone: phoneNumber, tel: phoneNumber.replace(/[^\d+]/g, ''), hours: t('help.hours').replace(/^help\.hours$/, '') } : null;
766
+ const value = (drawnValue = mirror?.value ?? input?.value ?? '');
767
+ const pick = (r: Result, name: string) => {
768
+ const x = first(r.c[name] ?? r.a[name]);
769
+ return x == null ? '' : x;
770
+ };
771
+ v.render('autocomplete-box', {
772
+ ...ctx.helpers,
773
+ query: lastQuery,
774
+ groups,
775
+ byType,
776
+ count: results.length,
777
+ active,
778
+ choose,
779
+ submitAll: () => submitAll(),
780
+ allHref: searchHref(input, sheet ? (mirror?.value ?? '') : undefined),
781
+ state,
782
+ sheet,
783
+ wide,
784
+ phrases,
785
+ lists,
786
+ hero: sheet || wide ? hero : null,
787
+ products: grid,
788
+ tabs: kinds.map((kind) => ({ kind, label: t(`types.${kind}`), selected: kind === tab, select: () => ((tab = kind), active >= 0 && (active = -1), render(), markActive()) })),
789
+ tab,
790
+ empty: { tries, browse, fallback, help },
791
+ hover,
792
+ unhover,
793
+ heroVariant: ac.hero?.variant === 'spec' ? 'spec' : 'image',
794
+ productLayout: ac.productLayout === 'list' ? 'list' : 'grid',
795
+ field: (r: Result, name: string) => {
796
+ const f = name === 'badge' ? ac.hero?.badgeField : (ac.productFields as Record<string, string | undefined> | undefined)?.[name];
797
+ if (!f) return '';
798
+ const x = pick(r, f);
799
+ if (name === 'availability') return typeof x === 'string' ? x : x === true || (typeof x === 'number' && x > 0) ? t('inStock') : '';
800
+ return x === '' || x === false ? '' : x;
801
+ },
802
+ specs: (r: Result) =>
803
+ (ac.hero?.specFields ?? [])
804
+ .map((f) => {
805
+ const label = t(`specs.${f}`);
806
+ return { label: label === `specs.${f}` ? f : label, value: String(pick(r, f) ?? '') };
807
+ })
808
+ .filter((x) => x.value)
809
+ .slice(0, 4),
810
+ value,
811
+ placeholder: input?.placeholder ?? '',
812
+ inputLabel: input?.getAttribute('aria-label') || input?.placeholder || t('label'),
813
+ clear: () => {
814
+ if (!mirror) return;
815
+ mirror.value = '';
816
+ mirror.focus();
817
+ typed();
818
+ query('');
819
+ },
820
+ back: () => leave('back'),
821
+ });
822
+ };
823
+
824
+ /**
825
+ * The search page of what the input holds: its GET form's action with the
826
+ * form's fields ('' for another form, a POST, a non-web action). The link's
827
+ * target only: its click still hands the query to the host (submitViaHost).
828
+ * `value`: the phrase instead of the input's (the phone sheet's own input).
829
+ */
830
+ const searchHref = (i: HTMLInputElement | null, value?: string) => {
831
+ const f = i?.form;
832
+ if (!f || (f.getAttribute('method') || 'get').trim().toLowerCase() !== 'get') return '';
833
+ try {
834
+ const u = new URL(f.getAttribute('action')?.trim() || location.href, document.baseURI);
835
+ const p = new URLSearchParams();
836
+ for (const [k, v] of new FormData(f)) if (typeof v === 'string') p.append(k, v);
837
+ if (value !== undefined && i!.name) p.set(i!.name, value);
838
+ u.search = p.toString();
839
+ return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : '';
840
+ } catch {
841
+ return '';
842
+ }
843
+ };
844
+ /** "Show all products" (`data-lbx-all`), the dropdown's one tab stop, while it is open. */
845
+ const allLink = () => (open ? (box.querySelector('[data-lbx-all]') as HTMLElement | null) : null);
846
+ /** Inside our dropdown (its shadow root too). */
847
+ const inOverlay = (el: Element | null) => !!el && !!host && (host.contains(el) || !!host.shadowRoot?.contains(el));
848
+ /** Focus back on the input from our dropdown, without it counting as the visitor's focus. */
849
+ const backToInput = () => {
850
+ const i = input;
851
+ if (!i?.isConnected) return;
852
+ quietFocus = true;
853
+ try {
854
+ i.focus();
855
+ } finally {
856
+ quietFocus = false;
857
+ }
858
+ };
859
+
860
+ // --- Requests and renders: one batch per query.
861
+ // A query fires the requests for every type in parallel, waits for all of
862
+ // them, then renders once: everything in the dropdown comes from one batch,
863
+ // one query. The first characters (up to instantChars) fire on the
864
+ // keystroke; longer phrases once typing pauses for debounceMs, but a steady
865
+ // typist still gets a batch every maxWaitMs (the box neither freezes during
866
+ // a long burst nor redraws on every key). At most MAX_BATCHES batches are in
867
+ // flight: a query that finds no slot waits, and when one frees only the
868
+ // latest waiting query fires (those typed in between are skipped). Nothing
869
+ // is aborted while typing.
870
+ const INSTANT_CHARS = ac.instantChars ?? 3;
871
+ const DEBOUNCE = ac.debounceMs ?? 300;
872
+ const MAX_WAIT = ac.maxWaitMs ?? 800;
873
+ const MAX_BATCHES = 2;
874
+ const CACHE_MAX = 300;
875
+ /** One phrase, however it is spaced: "red s " asks what "red s" asked. */
876
+ const norm = (s: string) => s.trim().replace(/\s+/g, ' ');
877
+
878
+ // Answers per (type, filters as sent, phrase) for the page's life, least
879
+ // recently used out first: a query whose types are all here resolves at once
880
+ // (backspace, retype, reopen). A failed request is not kept (asked again).
881
+ // The filters are in the key because an integration may make them follow
882
+ // the page (a getter on the URL's shop section): after an in-app navigation
883
+ // the same phrase is another question (otherwise the previous section's
884
+ // suggestions would come from the cache).
885
+ const cache = new Map<string, Result[]>();
886
+ const pending = new Map<string, Promise<Result[]>>();
887
+ type Type = AutocompleteConfigType['types'][number];
888
+ const keyOf = (slot: string, filters: Type['filters'], q: string) => `${slot}\n${JSON.stringify(filters) ?? ''}\n${q}`;
889
+ const cached = (k: string) => {
890
+ const v = cache.get(k);
891
+ if (v) cache.delete(k), cache.set(k, v);
892
+ return v;
893
+ };
894
+ const allCached = (q: string) => ac.types.every((x) => cached(keyOf(x.type, x.filters, q)));
895
+
896
+ let nav = new AbortController();
897
+ /** One type's answer for a phrase; `slot` keys the cache (the fallback's products apart from the typed ones). */
898
+ const answer = (x: Type, q: string, slot = x.type): Promise<Result[]> => {
899
+ // Read once: what is sent is what keys it.
900
+ const filters = x.filters;
901
+ const k = keyOf(slot, filters, q);
902
+ const hit = cached(k);
903
+ if (hit) return Promise.resolve(hit);
904
+ const known = pending.get(k);
905
+ if (known) return known;
906
+ const signal = nav.signal;
907
+ const p = ctx.api
908
+ .search({ endpoint: 'search', surface: ac.surface, type: x.type, query: q, size: x.size, fields: x.fields, filters }, signal)
909
+ .then(
910
+ (page) => {
911
+ const rs = page.hits.map(ctx.result).filter(navigable);
912
+ cache.set(k, rs);
913
+ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value!);
914
+ return rs;
915
+ },
916
+ (e) => {
917
+ if (!signal.aborted) reportError(`autocomplete ${x.type}`, e);
918
+ throw e;
919
+ },
920
+ )
921
+ .finally(() => pending.delete(k));
922
+ pending.set(k, p);
923
+ return p;
924
+ };
925
+
926
+ /**
927
+ * "No results" fallback products (`emptyState.fallback`: a fixed product
928
+ * search), part of the batch that found nothing. Asked once per page (the
929
+ * cache); none on a failure.
930
+ */
931
+ const fallbackFor = (): Promise<Result[]> => {
932
+ const fb = empty.fallback;
933
+ if (!fb?.query) return Promise.resolve([]);
934
+ const product = ac.types.find((x) => x.type === PRODUCT);
935
+ return answer({ type: PRODUCT, size: fb.size ?? 4, fields: product?.fields }, norm(fb.query), '\nfallback').catch(() => []);
936
+ };
937
+ /** The "No results" layout's options: phrases to try, links to browse, fallback products. */
938
+ const emptyOptions = (fallback: Result[]) => {
939
+ const tag = (r: Result, kind: string) => Object.assign(r, { __empty: kind });
940
+ return [
941
+ ...(empty.tryQueries ?? []).filter((q) => typeof q === 'string' && q.trim()).map((q) => tag(ctx.result({ '@id': `try/${q}`, '@type': 'query', '@title': q }), 'try')),
942
+ ...(empty.browse ?? []).filter((b) => b && safeUrl(b.url)).map((b) => tag(ctx.result({ '@id': `browse/${b.url}`, '@type': 'browse', '@title': b.title, url: b.url }), 'browse')),
943
+ ...fallback.map((r) => tag({ ...r }, 'fallback')),
944
+ ];
945
+ };
946
+
947
+ // Pictures belong to the batch, so a row's title and picture change
948
+ // together. Bounded: a slow picture does not hold the batch past PICTURE_WAIT.
949
+ const images = new Map<string, HTMLImageElement>();
950
+ const pictures = (rs: Result[]) => {
951
+ const loading: Promise<unknown>[] = [];
952
+ for (const r of rs) {
953
+ const src = typeof r.c.image === 'string' ? r.c.image : '';
954
+ if (!src) continue;
955
+ let img = images.get(src);
956
+ if (!img) {
957
+ img = new Image();
958
+ img.decoding = 'async';
959
+ img.src = src;
960
+ images.set(src, img);
961
+ if (images.size > PRELOAD_MAX) images.delete(images.keys().next().value!);
962
+ }
963
+ if (!img.complete) loading.push(img.decode().catch(() => {}));
964
+ }
965
+ return loading.length ? Promise.race([Promise.all(loading), new Promise((r) => setTimeout(r, PICTURE_WAIT))]) : null;
966
+ };
967
+
968
+ let issued = 0; // batches, in the order they were issued
969
+ let onScreen = 0; // the batch the dropdown shows
970
+ let inFlight = 0; // batches waiting for the network
971
+ const flying = new Set<string>();
972
+ let queued = ''; // the latest query waiting for a slot
973
+ let timer = 0;
974
+ let waiting = 0; // since when a batch is being put off
975
+
976
+ const batch = async (q: string) => {
977
+ const id = ++issued;
978
+ const signal = nav.signal;
979
+ const network = !allCached(q);
980
+ if (network) inFlight++, flying.add(q), mark(`ac ask ${q}`);
981
+ const settled = await Promise.allSettled(ac.types.map((x) => answer(x, q)));
982
+ // Every type answered nothing: the "No results" layout's fallback products belong to this batch.
983
+ const none = !light && settled.every((s) => s.status === 'fulfilled' && !s.value.length);
984
+ const fallback = none ? await fallbackFor() : [];
985
+ // Its answers are here: the query waiting for a slot goes now, not after
986
+ // this one's pictures and render (up to PICTURE_WAIT later, and its
987
+ // requests' cost in the task that shows these).
988
+ if (network) inFlight--, flying.delete(q);
989
+ if (queued && inFlight < MAX_BATCHES) launch(queued);
990
+ const wait = pictures([...settled.flatMap((s) => (s.status === 'fulfilled' ? s.value : [])), ...fallback]);
991
+ if (wait) await wait;
992
+ if (signal.aborted) return;
993
+ paint(id, q, settled, fallback);
994
+ };
995
+
996
+ const launch = (q: string) => {
997
+ if (!q || flying.has(q)) return;
998
+ if (inFlight >= MAX_BATCHES && !allCached(q)) return void (queued = q);
999
+ queued = '';
1000
+ void batch(q);
1001
+ };
1002
+
1003
+ const fire = () => {
1004
+ timer = waiting = 0;
1005
+ launch(want);
1006
+ };
1007
+
1008
+ const query = (value: string) => {
1009
+ const q = norm(value);
1010
+ clearTimeout(timer);
1011
+ timer = 0;
1012
+ if (q.length < minChars) {
1013
+ waiting = 0;
1014
+ queued = '';
1015
+ shown.clear();
1016
+ if (!sheet) return close('empty');
1017
+ // The sheet stays (it is where the shopper types), empty below minChars.
1018
+ want = lastQuery = '';
1019
+ results = items = order = [];
1020
+ active = -1;
1021
+ live.clear();
1022
+ said = '';
1023
+ return render();
1024
+ }
1025
+ want = q;
1026
+ // From the cache: at once (backspacing, retyping, reopening).
1027
+ if (allCached(q)) return void ((waiting = 0), launch(q));
1028
+ const now = performance.now();
1029
+ waiting ||= now;
1030
+ const d = q.length <= INSTANT_CHARS ? 0 : Math.min(DEBOUNCE, waiting + MAX_WAIT - now);
1031
+ if (d > 0) timer = window.setTimeout(fire, d);
1032
+ else fire();
1033
+ };
1034
+
1035
+ // A completed batch renders, once, if it was issued after the one on screen
1036
+ // and the input still starts with its query; otherwise it is dropped. A type
1037
+ // that failed keeps what the previous render showed for it; "No results"
1038
+ // only when every type of the batch answered empty.
1039
+ const paint = (id: number, q: string, settled: PromiseSettledResult<Result[]>[], fallback: Result[] = []) => {
1040
+ if (!want || !input || id <= onScreen || !want.startsWith(q)) return;
1041
+ if (settled.every((s) => s.status === 'rejected')) return; // nothing came back: keep what is shown
1042
+ const lists = new Map<string, Result[]>();
1043
+ ORDER.forEach((type, i) => {
1044
+ const s = settled[i];
1045
+ lists.set(type, s.status === 'fulfilled' ? s.value : (shown.get(type) ?? []));
1046
+ });
1047
+ const list = ORDER.flatMap((type) => lists.get(type)!);
1048
+ if (!list.length && settled.some((s) => s.status === 'rejected')) return;
1049
+ // The same rows as on screen, for a phrase the visitor has already typed
1050
+ // past: nothing to redraw. The batch for what the input holds always
1051
+ // renders (its query is the one announced).
1052
+ const same = open && q !== want && list.length === results.length && list.every((r, i) => r.type === results[i].type && r.id === results[i].id);
1053
+ onScreen = id;
1054
+ if (same) return;
1055
+ shown.clear();
1056
+ lists.forEach((rs, type) => shown.set(type, rs));
1057
+ // The keyboard selection follows its item, wherever the new list puts it.
1058
+ const sel = items[active];
1059
+ results = list;
1060
+ items = list.length || light ? list : emptyOptions(fallback);
1061
+ active = sel ? items.findIndex((r) => r.type === sel.type && r.id === sel.id) : -1;
1062
+ lastQuery = q;
1063
+ listOnly = results.length > 0 && !results.some((r) => r.type === PRODUCT);
1064
+ // The sheet takes what arrives while its input has let go (the keyboard put away to read).
1065
+ if (!sheet && focused() !== input) return;
1066
+ // The first opening with an onOpen (the site's own code: one storefront opens its
1067
+ // search strip, 9 ms of jQuery and layout reads at 4x CPU) was one task of
1068
+ // 20 ms with our render: the rows drawn now into the hidden dropdown, shown
1069
+ // (the hook, the placing) in the next frame.
1070
+ const later = !sheet && !open && !!ac.onOpen;
1071
+ if (later) place();
1072
+ else if (!sheet) show();
1073
+ render();
1074
+ // Shown unless closed meanwhile (close() clears want) or focus left: a newer
1075
+ // batch in between does not stop it (Firefox: neither showed, 2026-10-12).
1076
+ if (later) afterFrame(() => !open && !!want && !!input && focused() === input && show());
1077
+ mark(`ac shown ${q}`); // with the ask's: a phrase's wait, in the timeline (docs/INTEGRATION-PERFORMANCE.md)
1078
+ markActive();
1079
+ // Status (WCAG 4.1.3): how many, or none, once the visitor pauses; not per keystroke.
1080
+ const msg = results.length ? t('count', { count: results.length, query: q }) : t('empty', { query: q });
1081
+ if (msg !== said) (said = msg), live.say(msg, 700);
1082
+ };
1083
+
1084
+ /** Hand a query to the host so its own router/form performs navigation. */
1085
+ const submitViaHost = (q: string) => {
1086
+ const i = input!;
1087
+ hook('onSelect', ac.onSelect, null);
1088
+ // Paint our close first; the host's router render then runs in its own task.
1089
+ if (sheet) return leave('select', () => handToHost(i, q));
1090
+ close('select');
1091
+ setTimeout(() => handToHost(i, q), 0);
1092
+ };
1093
+ const submitAll = () => submitViaHost(sheet && mirror ? mirror.value : input!.value);
1094
+ /**
1095
+ * What is typed into the sheet's input, into the site's as well (its value
1096
+ * and an `input` event, as typing there would): the site's form, its own
1097
+ * scripts and our search prefetch (search/speculate.ts) see the phrase as
1098
+ * on a desktop. Ours ignores that event (suppressInput).
1099
+ */
1100
+ const typed = () => {
1101
+ const i = input;
1102
+ if (!i || !mirror || i.value === mirror.value) return;
1103
+ suppressInput = true;
1104
+ try {
1105
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(i, mirror.value);
1106
+ i.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
1107
+ } finally {
1108
+ suppressInput = false;
1109
+ }
1110
+ };
1111
+ const handToHost = (from: HTMLInputElement, q: string) => {
1112
+ // A host that re-rendered its header meanwhile: its input as it is now.
1113
+ const i = from.isConnected ? from : (document.querySelector(selector) as HTMLInputElement | null) ?? from;
1114
+ suppressInput = true;
1115
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(i, q);
1116
+ // composed: listeners outside a shadow root around the input hear them too.
1117
+ i.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
1118
+ i.dispatchEvent(new Event('change', { bubbles: true, composed: true })); // lazy bindings (v-model.lazy, updateOn: 'change')
1119
+ suppressInput = false;
1120
+ const opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true } as KeyboardEventInit;
1121
+ const notCancelled = i.dispatchEvent(new KeyboardEvent('keydown', opts));
1122
+ i.dispatchEvent(new KeyboardEvent('keyup', opts));
1123
+ if (notCancelled && i.form) i.form.requestSubmit();
1124
+ };
1125
+
1126
+ function choose(idx: number) {
1127
+ const r = items[idx];
1128
+ if (!r) return;
1129
+ if (r.type === 'query') submitViaHost(plain(r.c.title));
1130
+ else if (safeUrl(r.c.url)) {
1131
+ const url = safeUrl(r.c.url);
1132
+ hook('onSelect', ac.onSelect, (r as any).__empty ? null : r);
1133
+ leave('select', () => location.assign(url));
1134
+ }
1135
+ }
1136
+
1137
+ /** The arrow keys: the next option in `order`, or back to the input (-1) past either end. */
1138
+ const step = (by: 1 | -1) => {
1139
+ const n = order.length + 1; // positions: -1 (back in input) .. order.length-1
1140
+ const at = order.indexOf(active);
1141
+ const next = ((at + by + 1 + n) % n) - 1;
1142
+ active = next < 0 ? -1 : order[next];
1143
+ byPointer = false;
1144
+ render();
1145
+ // Into view once the frame with it has rendered (layout clean then): in
1146
+ // the key's task, right after our render, it forced a style and layout of
1147
+ // the page (most of typing's forced layout; the key's response waited).
1148
+ const row = markActive();
1149
+ if (row) afterFrame(() => row.isConnected && row.id === `lbx-ac-${active}` && row.scrollIntoView({ block: 'nearest' }));
1150
+ };
1151
+ /** The pointer over an option: the same selection the arrow keys move. A
1152
+ * touch is not a hover (a tap's pointerenter would leave the row lit). */
1153
+ let byPointer = false;
1154
+ function hover(i: number, e?: PointerEvent) {
1155
+ if (e && e.pointerType === 'touch') return;
1156
+ if (i === active || !open || !order.includes(i)) return;
1157
+ active = i;
1158
+ byPointer = true;
1159
+ render();
1160
+ markActive(false);
1161
+ }
1162
+ /** The pointer leaves the option it selected: the selection goes with it (a
1163
+ * keyboard selection stays): no card stays lit once the pointer has left
1164
+ * the panel. */
1165
+ function unhover(i: number, e?: PointerEvent) {
1166
+ if (e && e.pointerType === 'touch') return;
1167
+ if (!byPointer || i !== active || !open) return;
1168
+ active = -1;
1169
+ byPointer = false;
1170
+ render();
1171
+ markActive(false);
1172
+ }
1173
+
1174
+ // Window capture: the earliest point in dispatch, ahead of any host listener
1175
+ // on document/body/the input itself.
1176
+ // The event's real target: an input inside a shadow root (web-component
1177
+ // storefronts) reaches window with its host as event.target.
1178
+ const listener = <K extends keyof WindowEventMap>(fn: (e: WindowEventMap[K], target: EventTarget | null) => void) =>
1179
+ ((e: Event) => fn(e as WindowEventMap[K], e.composedPath?.()[0] ?? e.target)) as EventListener;
1180
+ const on = <K extends keyof WindowEventMap>(type: K, fn: (e: WindowEventMap[K], target: EventTarget | null) => void) => addEventListener(type, listener(fn), true);
1181
+ const matches = (x: EventTarget | null): x is HTMLInputElement => x instanceof HTMLInputElement && x.matches(selector);
1182
+ const focused = () => {
1183
+ let a = document.activeElement;
1184
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement;
1185
+ return a;
1186
+ };
1187
+
1188
+ on('input', (_, target) => {
1189
+ // The phone sheet's own input.
1190
+ if (sheet && mirror && target === mirror) {
1191
+ typed();
1192
+ query(mirror.value);
1193
+ // Drawn again only for the clear button (the next batch's render brings "Show all"'s link up to date).
1194
+ if (!mirror.value !== !drawnValue) render();
1195
+ return;
1196
+ }
1197
+ // Not one being typed into: a value set for the visitor (search/in-place.ts
1198
+ // on back/forward) asks for nothing; a focus later asks for what it holds.
1199
+ if (suppressInput || !matches(target) || target !== focused()) return;
1200
+ input = target;
1201
+ query(input.value);
1202
+ });
1203
+
1204
+ // Focus moving within a shadow root never reaches window: a focusout or
1205
+ // focusin whose relatedTarget is in the same shadow root stops at that root
1206
+ // (past it, target and relatedTarget would both be the host). Tab from a
1207
+ // shadow-root input to a button beside it (a web-component header) left our
1208
+ // dropdown open and the borrowed ARIA on their input. So the shadow roots
1209
+ // around an input we serve (its own, and any enclosing it) get the same
1210
+ // listeners once it is used. An event that reaches both (focus leaving the
1211
+ // shadow root altogether) is handled once.
1212
+ const handled = new WeakSet<Event>();
1213
+ const once = <K extends keyof WindowEventMap>(fn: (e: WindowEventMap[K], target: EventTarget | null) => void) =>
1214
+ listener<K>((e, target) => {
1215
+ if (handled.has(e)) return;
1216
+ handled.add(e);
1217
+ fn(e, target);
1218
+ });
1219
+ const roots = new WeakSet<ShadowRoot>();
1220
+ function watchRoots(i: Element) {
1221
+ for (let r = i.getRootNode(); r instanceof ShadowRoot && !roots.has(r); r = r.host.getRootNode()) {
1222
+ roots.add(r);
1223
+ r.addEventListener('focusin', onFocusIn, true);
1224
+ r.addEventListener('focusout', onFocusOut, true);
1225
+ }
1226
+ }
1227
+
1228
+ /** The visitor is at the site's input: the sheet on a phone, else the dropdown for what it holds. */
1229
+ const engage = (i: HTMLInputElement) => {
1230
+ input = i;
1231
+ if (sheet || (!open && phone())) return openSheet();
1232
+ // The combobox now (its popup created, detached: aria-controls names
1233
+ // it); our dropdown into the page a frame later (prepare): inserting it
1234
+ // restyles the host page, in the task of the click.
1235
+ ensureHost();
1236
+ watchRoots(i);
1237
+ adopt(i);
1238
+ prepare(i);
1239
+ if (input.value.trim().length >= minChars) query(input.value);
1240
+ };
1241
+
1242
+ const onFocusIn = once<'focusin'>((_, target) => {
1243
+ if (!matches(target) || quietFocus) return;
1244
+ engage(target);
1245
+ });
1246
+ addEventListener('focusin', onFocusIn, true);
1247
+
1248
+ // Leaving the page (a navigation, or into the back/forward cache): closed, so a
1249
+ // page the browser restores from memory never comes back with our dropdown open.
1250
+ addEventListener('pagehide', () => close('blur'));
1251
+
1252
+ // The phone sheet prepared ahead (on a touch screen the entry starts us
1253
+ // once the page is idle, src/main.ts): the dropdown in the page, hidden,
1254
+ // the empty sheet drawn once (lit's templates for it prepared). The tap
1255
+ // that opens it then shows, draws and focuses it, nothing more (a production storefront at
1256
+ // 4x CPU: its task 78 ms with these in it).
1257
+ if (phone())
1258
+ idle(() => {
1259
+ if (open || host) return;
1260
+ ensureHost();
1261
+ document.body.append(host!);
1262
+ const was = sheet;
1263
+ sheet = true;
1264
+ try {
1265
+ render();
1266
+ } catch (e) {
1267
+ reportError('autocomplete sheet', e);
1268
+ } finally {
1269
+ sheet = was;
1270
+ }
1271
+ mark('ac sheet prepared');
1272
+ });
1273
+
1274
+ // Also any focusout dispatched by script (an integration's re-dispatch, no
1275
+ // relatedTarget): what counts is where focus is a task later.
1276
+ const onFocusOut = once<'focusout'>((_, target) => {
1277
+ if (target !== input) return;
1278
+ setTimeout(() => {
1279
+ const f = focused();
1280
+ // Into our dropdown ("Show all products" by Tab, the sheet's input): still open.
1281
+ if (f === input || inOverlay(f)) {
1282
+ if (sheet) release(); // the sheet's own input is the combobox now
1283
+ return;
1284
+ }
1285
+ close('blur');
1286
+ release(); // nothing of ours left on their input
1287
+ }, 0);
1288
+ });
1289
+ addEventListener('focusout', onFocusOut, true);
1290
+
1291
+ // A key we act on is consumed as a whole gesture (keydown + keypress + keyup).
1292
+ // Letting half of it through leaves the host in an inconsistent state: a
1293
+ // modal that closes on keydown Escape while the component inside it resets
1294
+ // on keyup Escape.
1295
+ const consumed = new Set<string>();
1296
+ const consume = (e: KeyboardEvent) => {
1297
+ e.preventDefault();
1298
+ e.stopImmediatePropagation();
1299
+ consumed.add(e.key);
1300
+ };
1301
+
1302
+ on('keydown', (e, target) => {
1303
+ if (!open || !(target === input || (sheet && target === mirror))) return;
1304
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1305
+ consume(e); // also keeps the host's hidden suggestion list from moving
1306
+ step(e.key === 'ArrowDown' ? 1 : -1);
1307
+ } else if (e.key === 'Enter') {
1308
+ if (active >= 0) {
1309
+ consume(e); // our selection wins over the host's Enter handler
1310
+ choose(active);
1311
+ } else if (sheet) {
1312
+ // The sheet's own input: its phrase goes to the site's search.
1313
+ consume(e);
1314
+ if (mirror && norm(mirror.value)) submitViaHost(mirror.value);
1315
+ } else close('enter'); // plain Enter: the host performs its own search navigation
1316
+ } else if (e.key === 'Escape') {
1317
+ consume(e); // close us first, not the host modal
1318
+ if (sheet) leave('back');
1319
+ else close('escape');
1320
+ } else if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !sheet) {
1321
+ // "Show all products" is the dropdown's tab stop: Tab reaches it (it sits
1322
+ // at the end of <body> or in the top layer, never next to the input in
1323
+ // the page's own order). Alt+Tab too (Safari's Option+Tab).
1324
+ const all = allLink();
1325
+ if (!all) return;
1326
+ consume(e);
1327
+ all.focus();
1328
+ }
1329
+ });
1330
+ const finishGesture = (e: KeyboardEvent) => {
1331
+ if (!consumed.has(e.key)) return;
1332
+ e.preventDefault();
1333
+ e.stopImmediatePropagation();
1334
+ if (e.type === 'keyup') consumed.delete(e.key);
1335
+ };
1336
+ on('keypress', finishGesture);
1337
+ on('keyup', finishGesture);
1338
+
1339
+ on('pointerdown', (e, target) => {
1340
+ // Click into an already-focused input (no focusin) reopens the dropdown.
1341
+ if (matches(target) && !open && target === focused()) {
1342
+ if (phone()) return engage(target);
1343
+ input = target;
1344
+ if (input.value.trim().length >= minChars) query(input.value);
1345
+ return;
1346
+ }
1347
+ if (!open || target === input || (host && e.composedPath().includes(host))) return;
1348
+ close('outside');
1349
+ });
1350
+
1351
+ // Host SPA navigations (pushState or popstate): never leave a dropdown
1352
+ // hanging over a new route.
1353
+ onUrlChange(() => {
1354
+ clearTimeout(timer);
1355
+ timer = waiting = 0;
1356
+ queued = '';
1357
+ nav.abort(); // answers for the old page's input are not wanted
1358
+ nav = new AbortController();
1359
+ close('navigate');
1360
+ });
1361
+
1362
+ hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && (field = fieldOf(input, ac.alignTo)), position()) });
1363
+
1364
+ // The interaction that loaded us has already happened: pick it up.
1365
+ const now = focused();
1366
+ if (matches(now)) engage(now);
1367
+ };