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