@luigisbox/luma 0.4.0 → 0.5.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 (61) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +75 -8
  3. package/build/baseline.mjs +2 -2
  4. package/build/vite.mjs +253 -20
  5. package/docs/ACCESSIBILITY.md +9 -2
  6. package/package.json +3 -2
  7. package/src/analytics/chain.ts +1 -1
  8. package/src/analytics/pageview.ts +1 -1
  9. package/src/analytics/start.ts +3 -4
  10. package/src/analytics/wire.ts +1 -1
  11. package/src/api/ai.ts +32 -20
  12. package/src/api/token.ts +7 -7
  13. package/src/core/a11y.ts +6 -35
  14. package/src/core/context.ts +2 -0
  15. package/src/core/contrast-toggle.ts +32 -0
  16. package/src/core/default-button.ts +66 -0
  17. package/src/core/early-rules.ts +3 -2
  18. package/src/core/features.ts +109 -0
  19. package/src/core/frameworks-check.ts +32 -0
  20. package/src/core/frameworks.ts +35 -0
  21. package/src/core/image-load.ts +0 -26
  22. package/src/core/image-preload.ts +29 -0
  23. package/src/core/image-tags.ts +7 -1
  24. package/src/core/images.ts +3 -2
  25. package/src/core/integration.ts +42 -26
  26. package/src/core/listings.ts +9 -5
  27. package/src/core/locales.ts +2 -1
  28. package/src/core/own.ts +7 -3
  29. package/src/core/recommenders.ts +2 -2
  30. package/src/core/reconciler.ts +32 -23
  31. package/src/core/routes.ts +7 -6
  32. package/src/core/scheduler.ts +39 -5
  33. package/src/core/speculation.ts +2 -0
  34. package/src/core/stash.ts +15 -6
  35. package/src/core/styles.ts +6 -5
  36. package/src/core/support.ts +1 -1
  37. package/src/core/takeover.ts +6 -5
  38. package/src/core/templates.ts +5 -2
  39. package/src/core/tiles.ts +33 -10
  40. package/src/core/url.ts +6 -3
  41. package/src/core/widget-inits.ts +4 -1
  42. package/src/lit.ts +2 -1
  43. package/src/locales/cs.json +0 -1
  44. package/src/locales/de.json +0 -1
  45. package/src/locales/en.json +0 -1
  46. package/src/main.ts +70 -53
  47. package/src/types.ts +29 -9
  48. package/src/widgets/autocomplete/index.ts +58 -19
  49. package/src/widgets/listing/element.ts +81 -58
  50. package/src/widgets/listing/query.ts +6 -3
  51. package/src/widgets/listing/stash.ts +33 -16
  52. package/src/widgets/listing/state.ts +12 -0
  53. package/src/widgets/listing/view.ts +2 -1
  54. package/src/widgets/plp/route.ts +3 -2
  55. package/src/widgets/recommender/index.ts +3 -2
  56. package/src/widgets/search/direct.ts +39 -0
  57. package/src/widgets/search/form-url.ts +38 -34
  58. package/src/widgets/search/in-place.ts +43 -108
  59. package/src/widgets/search/index.ts +7 -2
  60. package/src/widgets/search/speculate.ts +48 -27
  61. package/src/widgets/search/submit.ts +136 -0
package/src/main.ts CHANGED
@@ -25,6 +25,7 @@ import { plpAccepts, searchAccepts, searchReadQuery } from './core/routes';
25
25
  import { clearEarlyRules, earlyHold, plpRule, searchRule, writeEarlyRules, type EarlyRoute } from './core/early-rules';
26
26
  import { askChallenge, storedToken } from './api/token-key';
27
27
  import { loadDictionary } from './core/locales';
28
+ import { ANALYTICS, AUTOCOMPLETE, NAMED_PRECONNECT_IMAGES, NAMED_PREFETCH_WHILE_TYPING, NAMED_SPECULATION, PLP, QUERY_URL, RECOMMENDER, SEARCH, SSR } from './core/features';
28
29
  import { keepDl } from './core/kept';
29
30
  import './core/boot-state'; // the page's state at boot, for lbx-place's reconciler
30
31
  import { onFlush, schedule } from './core/scheduler';
@@ -32,7 +33,7 @@ import { onUrlChange, startSensing } from './core/sense';
32
33
  import { layered, setCss, whenApplied } from './core/styles';
33
34
  import type { ListingRoute } from './core/listings';
34
35
  import type { Reco } from './core/recommenders';
35
- import type { GlobalConfigType, LumaConfigType, Result } from './types';
36
+ import type { GlobalConfigType, LumaConfigType, Result, SearchConfigType } from './types';
36
37
 
37
38
  declare const __VERSION__: string;
38
39
  // Replaced at build time; the dev server (the loader's development mode) does not replace it.
@@ -54,6 +55,8 @@ export interface WidgetContext<C = any> {
54
55
  result: (hit: any) => Result;
55
56
  /** Helpers every template scope gets. */
56
57
  helpers: Record<string, unknown>;
58
+ /** The search page of a phrase where the site's server redirects its form there (`search.queryUrl`): the autocomplete's "show all" link. */
59
+ queryUrl?: SearchConfigType['queryUrl'];
57
60
  }
58
61
 
59
62
  export const startIntegration = async (files: IntegrationFiles) => {
@@ -78,7 +81,7 @@ export const startIntegration = async (files: IntegrationFiles) => {
78
81
  // its solver is plain JS since (api/token.ts), the rule stands. Said
79
82
  // once, with the fix; nothing starts and the site keeps its own UI.
80
83
  if (!isSecureContext)
81
- return void (reportError('boot', `not a secure context (${location.origin}): Luma runs on HTTPS pages only. Serve the site over HTTPS; Luma's widgets did not start (the site keeps its own).`), off());
84
+ return void (reportError('boot', `not a secure context (${location.origin}): Luma runs on HTTPS pages only; its widgets did not start (the site keeps its own).`), off());
82
85
  return boot(config, files);
83
86
  }
84
87
  } catch (e) {
@@ -105,11 +108,12 @@ type Loaded = { mod: any; ctx: WidgetContext };
105
108
  const run = (where: string, p: Promise<Loaded>, f: (m: Loaded) => unknown, onFail?: () => void) =>
106
109
  p.then(f, () => onFail?.()).catch((e) => reportError(where, e));
107
110
 
111
+ // A widget the integration has no folder for (core/features.ts): no chunk.
108
112
  const modules = {
109
- autocomplete: () => import('./widgets/autocomplete'),
110
- search: () => import('./widgets/search'),
111
- plp: () => import('./widgets/plp'),
112
- recommender: () => import('./widgets/recommender'),
113
+ autocomplete: AUTOCOMPLETE ? () => import('./widgets/autocomplete') : null,
114
+ search: SEARCH ? () => import('./widgets/search') : null,
115
+ plp: PLP ? () => import('./widgets/plp') : null,
116
+ recommender: RECOMMENDER ? () => import('./widgets/recommender') : null,
113
117
  };
114
118
  type Kind = keyof typeof modules;
115
119
 
@@ -121,7 +125,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
121
125
  // The product image host, learnt from an earlier listing (widgets/listing/instant.ts).
122
126
  let img: string | null = null;
123
127
  try {
124
- img = g.preconnectImages !== false ? localStorage.getItem('lbx:img') : null;
128
+ img = (NAMED_PRECONNECT_IMAGES ? g.preconnectImages !== false : true) ? localStorage.getItem('lbx:img') : null;
125
129
  } catch {}
126
130
  const api = `https://api.${region}.luigisbox.ai`;
127
131
  const preconnect = (host: string, cors = true) => {
@@ -160,6 +164,9 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
160
164
  else addEventListener('load', idle, { once: true });
161
165
  };
162
166
  afterLoad(warm);
167
+ // A framework on the page whose support the build left out (clientConfig
168
+ // `framework`): said in development (core/frameworks-check.ts, not in a build).
169
+ if (import.meta.env.DEV) afterLoad(() => void import('./core/frameworks-check').then((m) => m.checkFrameworks()).catch((e) => reportError('frameworks check', e)));
163
170
  startSensing();
164
171
 
165
172
  // Analytics starts once the page has loaded, or with the first widget (its
@@ -177,7 +184,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
177
184
  startAnalytics();
178
185
  const support = import('./core/support');
179
186
  const p = Promise.all([
180
- modules[kind]() as Promise<any>,
187
+ modules[kind]!() as Promise<any>,
181
188
  support,
182
189
  widgetFiles(kind, [...folders], files.templates),
183
190
  widgetFiles(kind, [...folders], files.styles),
@@ -188,7 +195,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
188
195
  ]).then(([mod, s, tpls, css, dict]) => {
189
196
  if (mod.css) setCss(`default:${kind}`, layered(mod.css));
190
197
  // Product pages on intent (default on, core/speculation.ts): our links come with the first widget.
191
- if (g.speculation !== false) s.speculate(g, onUrlChange);
198
+ if ((NAMED_SPECULATION ? g.speculation !== false : true)) s.speculate(g, onUrlChange);
192
199
  const ctx = s.widgetContext(s, config, { id, folders: [...folders], app, scopes: [...scopes] }, mod.templates, tpls, css, dict);
193
200
  mark(`loaded ${id}`);
194
201
  return { mod, ctx };
@@ -207,7 +214,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
207
214
  // --- autocomplete: the first interaction with the search input loads it
208
215
  const ac = config.autocomplete;
209
216
  // The dropdown is a popover (top layer): without it (Safari < 17, Firefox < 125) the site keeps its own.
210
- if (ac && !ac.disabled && g.inputSelector && 'showPopover' in HTMLElement.prototype) {
217
+ if (AUTOCOMPLETE && ac && !ac.disabled && g.inputSelector && 'showPopover' in HTMLElement.prototype) {
211
218
  const css = hostCss('autocomplete');
212
219
  const TYPES = ['pointerdown', 'focusin', 'input'];
213
220
  const loadAc = () => load('autocomplete', 'autocomplete', ['_global', 'autocomplete'], ac, ['autocomplete']);
@@ -297,9 +304,11 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
297
304
  clearTimeout(lateTimer);
298
305
  lateTimer = window.setTimeout(() => (recently = false), LATE_MS);
299
306
  };
300
- late();
301
- if (document.readyState !== 'complete') addEventListener('load', late, { once: true });
302
- onUrlChange(late);
307
+ if (SEARCH || PLP || RECOMMENDER) {
308
+ late();
309
+ if (document.readyState !== 'complete') addEventListener('load', late, { once: true });
310
+ onUrlChange(late);
311
+ }
303
312
  const recent = () => recently;
304
313
 
305
314
  // --- listings: search results and category pages. Their boot (takeover,
@@ -310,8 +319,8 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
310
319
  // timer), or writes the hold CSS itself before first paint and in the same
311
320
  // task as an SPA's URL change into the route; the takeover continues that hold.
312
321
  const routes: ListingRoute[] = [];
313
- const search = config.search && !config.search.disabled ? config.search : null;
314
- const plp = config.plp && !config.plp.disabled ? config.plp : null;
322
+ const search = SEARCH ? (config.search && !config.search.disabled ? config.search : null) : null;
323
+ const plp = PLP ? (config.plp && !config.plp.disabled ? config.plp : null) : null;
315
324
  if (search) setUp('search', () => routes.push({ kind: 'search', cfg: search, accepts: searchAccepts(search), hostCss: hostCss('search') }));
316
325
  if (plp) setUp('plp', () => routes.push({ kind: 'plp', cfg: plp, accepts: plpAccepts(plp), hostCss: hostCss('plp') }));
317
326
  const early = earlyHold();
@@ -359,7 +368,7 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
359
368
  any = true;
360
369
  // Theirs shows at once where we failed too often; a server-rendered page holds nothing.
361
370
  // Moving within the route (their filters) keeps what is shown.
362
- if (held.has(r.kind) || (spa && prev && r.accepts(prev)) || failures(r.kind) >= MAX_FAILURES || document.querySelector(`lbx-${r.kind}[data-lbx-ssr], template[data-lbx-ssr="${r.kind}"]`)) continue;
371
+ if (held.has(r.kind) || (spa && prev && r.accepts(prev)) || failures(r.kind) >= MAX_FAILURES || (SSR ? !!document.querySelector(`lbx-${r.kind}[data-lbx-ssr], template[data-lbx-ssr="${r.kind}"]`) : false)) continue;
363
372
  const claimed = !spa && !!early?.claim?.(r.kind);
364
373
  if (!claimed && !spa && painted()) continue; // booted after first paint: theirs stays until ours renders
365
374
  // Ours too where the early hold is claimed: with the height ours had on this
@@ -376,37 +385,44 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
376
385
  // Loading adds modulepreload links to <head>: not while it is being parsed.
377
386
  for (const r of routes) if (r.accepts(u)) whenApplied(() => void loadListing(r.kind).catch(() => {}));
378
387
  };
379
- enter(new URL(location.href), false);
380
- onUrlChange((u) => enter(u, true));
381
- // A page of a listing route's URL shape whose hook accepts it only later
382
- // (their data layer, after our boot): its token from now, not from then
383
- // (a production storefront's category pages: ~0.3 s sooner).
384
- if (routes.some((r) => r.cfg.urlMatch && setUp(`${r.kind} urlMatch`, () => new RegExp(r.cfg.urlMatch!).test(location.pathname)))) warm();
385
- // A route whose hook reads the page (an `active()` on their data layer or a
386
- // node) may accept it only once the page has that: looked at again on
387
- // reconciles (while `recent()`) until the listings' boot is asked for (a failed one: on the next URL).
388
- if (routes.length) onFlush(() => recent() && !booting && failedOn !== location.href && enter(new URL(location.href), false));
389
- // Results ready on Enter: the first input into the search field loads the prefetch-while-typing chunk.
390
- if (search && routes.some((r) => r.kind === 'search') && search.prefetchWhileTyping !== false && g.inputSelector) {
388
+ if (SEARCH || PLP) {
389
+ enter(new URL(location.href), false);
390
+ onUrlChange((u) => enter(u, true));
391
+ // A page of a listing route's URL shape whose hook accepts it only later
392
+ // (their data layer, after our boot): its token from now, not from then
393
+ // (a production storefront's category pages: ~0.3 s sooner).
394
+ if (routes.some((r) => r.cfg.urlMatch && setUp(`${r.kind} urlMatch`, () => RegExp(r.cfg.urlMatch!).test(location.pathname)))) warm();
395
+ // A route whose hook reads the page (an `active()` on their data layer or a
396
+ // node) may accept it only once the page has that: looked at again on
397
+ // reconciles (while `recent()`) until the listings' boot is asked for (a failed one: on the next URL).
398
+ if (routes.length) onFlush(() => recent() && !booting && failedOn !== location.href && enter(new URL(location.href), false));
399
+ }
400
+ // Results ready on Enter, and Enter straight to the query's page (search.queryUrl):
401
+ // the first input into the search field loads the typing chunk (both are in it;
402
+ // the second only where the integration names the hook: QUERY_URL).
403
+ if (search && routes.some((r) => r.kind === 'search') && ((NAMED_PREFETCH_WHILE_TYPING ? search.prefetchWhileTyping !== false : true) || (QUERY_URL ? !!search.queryUrl : false)) && g.inputSelector) {
391
404
  const first = (e: Event) => {
392
405
  const i = e.composedPath?.()[0] ?? e.target;
393
406
  if (!(i instanceof HTMLInputElement) || !i.matches(g.inputSelector!)) return;
394
407
  removeEventListener('input', first, true);
395
- import('./widgets/search/speculate').then((m) => m.startSpeculation(g, search, i)).catch((err) => reportError('search prefetch', err));
408
+ if (NAMED_PREFETCH_WHILE_TYPING ? search.prefetchWhileTyping !== false : true) import('./widgets/search/speculate').then((m) => m.startSpeculation(g, search, i)).catch((err) => reportError('search prefetch', err));
409
+ if (QUERY_URL && search.queryUrl) import('./widgets/search/direct').then((m) => m.startDirect(search, g.inputSelector!)).catch((err) => reportError('search direct', err));
396
410
  };
397
411
  addEventListener('input', first, true);
398
412
  }
399
413
  // A hold no route of ours continues (stale rules, a URL the snippet
400
414
  // over-matched) ends now, not at its timeout; a held route's ends with its takeover.
401
- if (!held.size) whenApplied(() => early?.drop?.());
415
+ if (!(SEARCH || PLP) || !held.size) whenApplied(() => early?.drop?.());
402
416
  // Rules for the inline early-hold snippet's next hard load (src/core/early-rules.ts).
403
417
  // Dev bundles boot late (unbundled modules): a longer snippet timeout there.
404
418
  // A route failing this session gets no rule: the snippet must not hold it on every page view.
405
419
  const ok = (kind: string) => routes.some((r) => r.kind === kind) && failures(kind) < MAX_FAILURES;
406
- writeEarlyRules(
407
- [search && ok('search') ? searchRule(search, hostCss('search')) : null, plp && ok('plp') ? plpRule(plp, hostCss('plp')) : null].filter((r): r is EarlyRoute => !!r),
408
- VERSION === 'dev' ? 6000 : 1500,
409
- );
420
+ if (SEARCH || PLP)
421
+ writeEarlyRules(
422
+ [search && ok('search') ? searchRule(search, hostCss('search')) : null, plp && ok('plp') ? plpRule(plp, hostCss('plp')) : null].filter((r): r is EarlyRoute => !!r),
423
+ VERSION === 'dev' ? 6000 : 1500,
424
+ );
425
+ else clearEarlyRules(); // none to hold (a listing folder removed: its stale rules go)
410
426
 
411
427
  // --- recommenders: their triggers and placement (core/recommenders.ts, in
412
428
  // lbx-place with the reconciler) load on a page an inline one's urlMatch /
@@ -416,22 +432,23 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
416
432
  const pages: ((u: URL) => boolean)[] = [];
417
433
  /** Our recommenders by name, and the pages each runs on: where ours runs, the site's of that name is not tracked. */
418
434
  const recoPages = new Map<string, (u: URL) => boolean>();
419
- for (const [name, rc] of Object.entries(config.recommenders ?? {})) {
420
- if (rc.disabled) continue;
421
- const id = `reco-${name}`;
422
- const folders = ['_global', `recommender ${name}`];
423
- // style-host.css of the folder: e.g. hides the host's own box our widget replaces
424
- // (scoped by our element's state, so it is inert where the widget is not).
425
- const css = hostCss(`recommender ${name}`);
426
- if (css) setCss(`host-${id}`, css);
427
- // The popup is a popover (top layer): without the API there is no popup.
428
- if (rc.confirm && !('showPopover' in HTMLElement.prototype)) continue;
429
- recos.push({ id, rc, get: () => load('recommender', id, folders, rc, ['recommender']) });
430
- const re = !rc.confirm && rc.urlMatch ? setUp(id, () => new RegExp(rc.urlMatch!)) : null;
431
- pages.push(rc.confirm ? () => true : (u) => (rc.active ? rc.active(u) : !re || re.test(u.pathname)));
432
- recoPages.set(name, pages[pages.length - 1]);
433
- }
434
- if (recos.length) {
435
+ if (RECOMMENDER)
436
+ for (const [name, rc] of Object.entries(config.recommenders ?? {})) {
437
+ if (rc.disabled) continue;
438
+ const id = `reco-${name}`;
439
+ const folders = ['_global', `recommender ${name}`];
440
+ // style-host.css of the folder: e.g. hides the host's own box our widget replaces
441
+ // (scoped by our element's state, so it is inert where the widget is not).
442
+ const css = hostCss(`recommender ${name}`);
443
+ if (css) setCss(`host-${id}`, css);
444
+ // The popup is a popover (top layer): without the API there is no popup.
445
+ if (rc.confirm && !('showPopover' in HTMLElement.prototype)) continue;
446
+ recos.push({ id, rc, get: () => load('recommender', id, folders, rc, ['recommender']) });
447
+ const re = !rc.confirm && rc.urlMatch ? setUp(id, () => RegExp(rc.urlMatch!)) : null;
448
+ pages.push(rc.confirm ? () => true : (u) => (rc.active ? rc.active(u) : !re || re.test(u.pathname)));
449
+ recoPages.set(name, pages[pages.length - 1]);
450
+ }
451
+ if (RECOMMENDER && recos.length) {
435
452
  let asked = false;
436
453
  let failedOn = '';
437
454
  const check = () => {
@@ -456,12 +473,12 @@ function boot(config: LumaConfigType, files: IntegrationFiles) {
456
473
  // _analytics.ts (nothing is sent that it does not say), every page once it
457
474
  // has loaded or with the first widget (its chains on our tiles listen then).
458
475
  // Without one, `analytics: false`: our tiles queue nothing (core/tiles.ts).
459
- if (!Object.keys(files.analytics ?? {}).length) g.analytics = false;
460
- if (g.analytics !== false) {
476
+ if (!ANALYTICS || !Object.keys(files.analytics ?? {}).length) g.analytics = false;
477
+ if (ANALYTICS && g.analytics !== false) {
461
478
  const here = () => new URL(location.href);
462
479
  const readQ = search ? searchReadQuery(search) : null;
463
480
  const owns = (s: string) => {
464
- if (s === 'autocomplete') return !!(ac && !ac.disabled && g.inputSelector);
481
+ if (s === 'autocomplete') return AUTOCOMPLETE ? !!(ac && !ac.disabled && g.inputSelector) : false;
465
482
  if (s === 'search') return routes.some((r) => r.kind === 'search' && r.accepts(here()));
466
483
  const reco = s.startsWith('recommender:') ? recoPages.get(s.slice(12)) : undefined;
467
484
  return !!reco && !!setUp(`${s} active`, () => reco(here()));
package/src/types.ts CHANGED
@@ -109,7 +109,8 @@ export interface GlobalConfigType {
109
109
  * Puts a product in the site's cart (`_init.ts`, or `_global/_widget.ts`:
110
110
  * only widgets call it): what a click on `[data-lbx-cart]` in one of our
111
111
  * tiles runs (announced to analytics first: `lumaCarts()`). `count`: the
112
- * attribute's value (default 1).
112
+ * attribute's value (default 1). The build has it only where a file of the
113
+ * integration contains `data-lbx-cart` (a template: core/features.ts CART).
113
114
  */
114
115
  addToCart?: (r: Result, o: { count: number; el: HTMLElement; event: MouseEvent }) => unknown;
115
116
  }
@@ -375,6 +376,18 @@ export interface SearchConfigType extends AppConfig<SearchConfigType>, ListingCo
375
376
  /** _init.ts overrides for URLs a regex cannot describe. */
376
377
  active?: (url: URL) => boolean;
377
378
  readQuery?: (url: URL) => string;
379
+ /**
380
+ * _init.ts: the search page of a phrase, where the site's server sends the
381
+ * search form (a form that submits to `/search?q=…` and is redirected to
382
+ * `/search/<phrase>/`). `form` is the URL the form would submit to (its other
383
+ * fields). The URL returned must be one `urlMatch` / `readQuery` accept;
384
+ * `''` means no page of its own for this phrase (the form submits as it is). With
385
+ * it, Enter in the site's search input (and the autocomplete's "show all" and
386
+ * its hand-off of a query) loads that page directly, without the redirect;
387
+ * the result fetched while typing, the page prerendered and a query in place
388
+ * are that page's. An empty phrase submits the form as the site has it.
389
+ */
390
+ queryUrl?: (q: string, form: URL) => string | URL;
378
391
  /** Extra result types shown above the results (category and brand tiles), one request each. */
379
392
  quick?: { type: string; size: number; fields?: string[] }[];
380
393
  /**
@@ -400,17 +413,24 @@ export interface SearchConfigType extends AppConfig<SearchConfigType>, ListingCo
400
413
  /** How long a result fetched while typing is used (ms; older: asked anew). Default 120000. */
401
414
  prefetchMaxAgeMs?: number;
402
415
  /**
403
- * Also prerender the search page (Speculation Rules: Chrome, Edge) of the
404
- * phrase whose result was just fetched while typing, if it is still the one
405
- * typed, so Enter from another page activates a page already rendered. One
406
- * at a time (a newer phrase replaces it). It costs a host page render on
407
- * the site's server per settled phrase, and runs the site's scripts in it:
416
+ * The search page itself, ahead of Enter (Speculation Rules): once the
417
+ * phrase's result is fetched while typing and the phrase is still the one
418
+ * typed, one list rule for that page. `'prefetch'` (default): its document
419
+ * is fetched (Chrome, Edge; Firefox and Safari as they ship it), so Enter
420
+ * from another page skips the site's server render and still renders from
421
+ * the stash: a document fetch on the site's server per settled phrase, no
422
+ * scripts run. `'prerender'`: the page is rendered ahead as well (Chrome,
423
+ * Edge; a prefetch rule too, for engines without prerendering): Enter
424
+ * activates a page already rendered, but the site's scripts run in it, and
408
425
  * analytics that do not wait for `prerenderingchange` count a page view the
409
- * shopper may never see. Not on the search page when `inPlaceQuery` is on.
410
- * Default false.
426
+ * shopper may never see. `false`: neither. One page at a time (a newer
427
+ * phrase replaces it); not on the search page when `inPlaceQuery` is on
428
+ * (no page load to save); nothing offline, with Save-Data or on 2G.
411
429
  */
430
+ pageWhileTyping?: 'prefetch' | 'prerender' | false;
431
+ /** `true` is `pageWhileTyping: 'prerender'`. */
412
432
  prerenderWhileTyping?: boolean;
413
- /** Speculation Rules eagerness of that list rule: 'immediate' (default) or 'eager'. */
433
+ /** Speculation Rules eagerness of the prerender rule: 'immediate' (default) or 'eager'. */
414
434
  prerenderEagerness?: 'immediate' | 'eager';
415
435
  }
416
436
 
@@ -16,7 +16,9 @@
16
16
 
17
17
  import { borrow, liveRegion, useContrast } from '../../core/a11y';
18
18
  import { luminanceOf, readable } from '../../core/color';
19
+ import { defaultSubmitter, formDataOf } from '../../core/default-button';
19
20
  import { onOwnBack } from '../../core/entries';
21
+ import { AC_HOOKS, ANALYTICS, QUERY_URL } from '../../core/features';
20
22
  import { mark, reportError } from '../../core/health';
21
23
  import { modalOf } from '../../core/own';
22
24
  import { byName, view } from '../../core/templates';
@@ -341,13 +343,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
341
343
  const intoMirror = (e: Event) => sheet && e.composedPath()[0] === mirror;
342
344
  box.addEventListener('pointerdown', (e) => e.pointerType !== 'touch' && !intoMirror(e) && e.preventDefault());
343
345
  box.addEventListener('mousedown', (e) => !intoMirror(e) && e.preventDefault());
344
- // A middle click opens a suggestion in a new tab (no choose()): a click too (core/tiles.ts).
345
- box.addEventListener('auxclick', (e) => {
346
- if ((e as MouseEvent).button !== 1) return;
347
- const a = (e.composedPath()[0] as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
348
- const i = a ? items.findIndex((r) => r.type !== 'query' && !!r.c.url && new URL(r.c.url, location.href).href === a.href) : -1;
349
- if (i >= 0) track(i);
350
- });
346
+ // A middle click opens a suggestion in a new tab (no choose()): a click too
347
+ // (core/tiles.ts), announced for analytics only (core/features.ts ANALYTICS).
348
+ if (ANALYTICS)
349
+ box.addEventListener('auxclick', (e) => {
350
+ if ((e as MouseEvent).button !== 1) return;
351
+ const a = (e.composedPath()[0] as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
352
+ const i = a ? items.findIndex((r) => r.type !== 'query' && !!r.c.url && new URL(r.c.url, location.href).href === a.href) : -1;
353
+ if (i >= 0) track(i);
354
+ });
351
355
  // "Show all products" has focus (Tab from the input): Shift+Tab goes back
352
356
  // to the input, Escape closes and goes back, Tab moves on as from the
353
357
  // input (focus put back on it, the browser's own Tab then leaves it: the
@@ -591,12 +595,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
591
595
  host!.showPopover();
592
596
  open = true;
593
597
  aria?.set('aria-expanded', 'true');
594
- hook('onOpen', ac.onOpen);
598
+ if (AC_HOOKS) hook('onOpen', ac.onOpen);
595
599
  cancelAnimationFrame(raf);
596
600
  // A hook may move the field (a storefront's opens its search strip, the
597
601
  // input lower): placed from where it is now, not from `m`, read before
598
602
  // it (otherwise the dropdown opens at the old place, then jumps).
599
- position(false, ac.onOpen ? undefined : m);
603
+ position(false, AC_HOOKS && ac.onOpen ? undefined : m);
600
604
  }
601
605
  };
602
606
 
@@ -605,11 +609,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
605
609
  * the focus's frame has rendered (layout clean then), ahead of the first
606
610
  * opening: read at the opening, after the page and our render had changed,
607
611
  * they forced a style and layout into the task that shows the results.
612
+ * Not once focus has left the input: putting the dropdown in the page
613
+ * borrows its ARIA, which focus leaving had put back (Shift+Tab back and
614
+ * Tab on within a frame left role=combobox on their input: WebKit on a slow
615
+ * machine, 2026-10-15).
608
616
  */
609
617
  let prepared: HTMLInputElement | null = null;
610
618
  const prepare = (i: HTMLInputElement) =>
611
619
  afterFrame(() => {
612
- if (open || input !== i || !i.isConnected || sheet) return;
620
+ if (open || input !== i || !i.isConnected || sheet || focused() !== i) return;
613
621
  lineUp(i);
614
622
  if (!light) sampleTokens(i, field!);
615
623
  prepared = i;
@@ -741,7 +749,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
741
749
  host!.style.setProperty('display', 'block', 'important');
742
750
  host!.showPopover();
743
751
  open = true;
744
- hook('onOpen', ac.onOpen);
752
+ if (AC_HOOKS) hook('onOpen', ac.onOpen);
745
753
  setCss('ac:sheet', ':root { overflow: hidden !important; }');
746
754
  try {
747
755
  history.pushState(null, '', location.href);
@@ -847,7 +855,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
847
855
  aria?.set('aria-activedescendant', null);
848
856
  live.clear();
849
857
  said = '';
850
- hook('onClose', ac.onClose, reason);
858
+ if (AC_HOOKS) hook('onClose', ac.onClose, reason);
851
859
  };
852
860
 
853
861
  /** The sheet input's value the last render drew (its clear button). */
@@ -956,16 +964,37 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
956
964
  * target only: its click still hands the query to the host (submitViaHost).
957
965
  * `value`: the phrase instead of the input's (the phone sheet's own input).
958
966
  */
967
+ // Where the site's server redirects the form to the phrase's page (search.queryUrl): that page,
968
+ // same origin and http(s) as the form's (as every other use of the hook checks), else the form's.
969
+ // Decided outside searchHref's try (a flag in a try block is never folded: build/vite.mjs).
970
+ const pageOf = QUERY_URL
971
+ ? (q: string, u: URL) => {
972
+ if (!q || !ctx.queryUrl) return u.href;
973
+ try {
974
+ const to = ctx.queryUrl(q, new URL(u.href));
975
+ if (to === '') return u.href;
976
+ const r = new URL(to, location.href);
977
+ return (r.protocol === 'https:' || r.protocol === 'http:') && r.origin === location.origin ? r.href : u.href;
978
+ } catch (e) {
979
+ reportError('search queryUrl', e);
980
+ return u.href;
981
+ }
982
+ }
983
+ : null;
959
984
  const searchHref = (i: HTMLInputElement | null, value?: string) => {
960
985
  const f = i?.form;
961
986
  if (!f || (f.getAttribute('method') || 'get').trim().toLowerCase() !== 'get') return '';
962
987
  try {
963
988
  const u = new URL(f.getAttribute('action')?.trim() || location.href, document.baseURI);
964
989
  const p = new URLSearchParams();
965
- for (const [k, v] of new FormData(f)) if (typeof v === 'string') p.append(k, v);
990
+ // With the form's default button, as Enter submits (its name=value, or an
991
+ // image button's x and y): the URL the result and the page were fetched
992
+ // ahead for (widgets/search/form-url.ts reads the same, core/default-button.ts).
993
+ for (const [k, v] of formDataOf(f, defaultSubmitter(f))) if (typeof v === 'string') p.append(k, v);
966
994
  if (value !== undefined && i!.name) p.set(i!.name, value);
967
995
  u.search = p.toString();
968
- return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : '';
996
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return '';
997
+ return pageOf ? pageOf((value ?? i!.value).trim(), u) : u.href;
969
998
  } catch {
970
999
  return '';
971
1000
  }
@@ -1214,7 +1243,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1214
1243
  /** Hand a query to the host so its own router/form performs navigation. */
1215
1244
  const submitViaHost = (q: string) => {
1216
1245
  const i = input!;
1217
- hook('onSelect', ac.onSelect, null);
1246
+ if (AC_HOOKS) hook('onSelect', ac.onSelect, null);
1218
1247
  // Paint our close first; the host's router render then runs in its own task.
1219
1248
  if (sheet) return leave('select', () => handToHost(i, q));
1220
1249
  close('select');
@@ -1254,7 +1283,17 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1254
1283
  const opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true } as KeyboardEventInit;
1255
1284
  const notCancelled = i.dispatchEvent(new KeyboardEvent('keydown', opts));
1256
1285
  i.dispatchEvent(new KeyboardEvent('keyup', opts));
1257
- if (notCancelled && i.form) i.form.requestSubmit();
1286
+ // As Enter would: with the form's default button (its name=value, formaction), so
1287
+ // the page loads at the URL the result and the document were fetched ahead for
1288
+ // (instant-search.spec 'a search button with a name', on a phone: here, not an Enter).
1289
+ if (notCancelled && i.form) {
1290
+ const f = i.form;
1291
+ try {
1292
+ f.requestSubmit(defaultSubmitter(f) ?? undefined);
1293
+ } catch {
1294
+ f.requestSubmit(); // a button the engine refuses as a submitter (none of this form)
1295
+ }
1296
+ }
1258
1297
  };
1259
1298
 
1260
1299
  /**
@@ -1274,8 +1313,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1274
1313
  if (r.type === 'query') submitViaHost(plain(r.c.title));
1275
1314
  else if (safeUrl(r.c.url)) {
1276
1315
  const url = safeUrl(r.c.url);
1277
- track(idx);
1278
- hook('onSelect', ac.onSelect, (r as any).__empty ? null : r);
1316
+ if (ANALYTICS) track(idx);
1317
+ if (AC_HOOKS) hook('onSelect', ac.onSelect, (r as any).__empty ? null : r);
1279
1318
  leave('select', () => location.assign(url));
1280
1319
  }
1281
1320
  }
@@ -1534,7 +1573,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1534
1573
  close('navigate');
1535
1574
  });
1536
1575
 
1537
- hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && lineUp(input), position()) });
1576
+ if (AC_HOOKS) hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && lineUp(input), position()) });
1538
1577
 
1539
1578
  // The interaction that loaded us has already happened: pick it up.
1540
1579
  const now = focused();