@luigisbox/luma 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,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
|
+
};
|