@luigisbox/luma 0.4.0 → 0.6.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 (66) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +76 -9
  3. package/build/baseline.mjs +2 -2
  4. package/build/vite.mjs +255 -20
  5. package/docs/ACCESSIBILITY.md +31 -5
  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 +1 -1
  44. package/src/locales/de.json +1 -1
  45. package/src/locales/en.json +1 -1
  46. package/src/main.ts +70 -53
  47. package/src/types.ts +48 -14
  48. package/src/widgets/autocomplete/index.ts +167 -34
  49. package/src/widgets/autocomplete/style-autocomplete.css +16 -8
  50. package/src/widgets/autocomplete/template-autocomplete-empty.ts +1 -1
  51. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +13 -9
  52. package/src/widgets/listing/applied.ts +87 -0
  53. package/src/widgets/listing/element.ts +279 -78
  54. package/src/widgets/listing/intent.ts +15 -0
  55. package/src/widgets/listing/query.ts +6 -3
  56. package/src/widgets/listing/stash.ts +33 -16
  57. package/src/widgets/listing/state.ts +12 -0
  58. package/src/widgets/listing/view.ts +2 -1
  59. package/src/widgets/plp/route.ts +3 -2
  60. package/src/widgets/recommender/index.ts +3 -2
  61. package/src/widgets/search/direct.ts +39 -0
  62. package/src/widgets/search/form-url.ts +38 -34
  63. package/src/widgets/search/in-place.ts +43 -108
  64. package/src/widgets/search/index.ts +7 -2
  65. package/src/widgets/search/speculate.ts +48 -27
  66. package/src/widgets/search/submit.ts +136 -0
@@ -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';
@@ -64,6 +66,12 @@ const GRID_MAX = 6;
64
66
  export const SHEET_MEDIA = '(max-width: 600px), (hover: none) and (max-height: 500px)';
65
67
  /** The sheet's visible height below which its top product is drawn smaller (the keyboard up): more of the list shows. */
66
68
  const SHEET_SHORT = 480;
69
+ /** How far (px, vertically) a finger moves in the sheet's list before it is a swipe, not a tap: the keyboard goes away. */
70
+ const SWIPE = 10;
71
+ /** A move this far (px), then the browser taking the gesture over (pointercancel), is a swipe too; less is a finger's jitter. */
72
+ const SWIPE_START = 4;
73
+ /** How long (ms) after the browser took a finger's gesture over a scroll of the list is still that finger's. */
74
+ const SWIPE_SCROLL_MS = 200;
67
75
 
68
76
  /** A computed colour that paints nothing (`rgba(0, 0, 0, 0)`, `transparent`, `color(srgb 0 0 0 / 0)`). */
69
77
  const clear = (c: string) => !c || c === 'transparent' || /(?:,|\/)\s*0(?:\.0*)?\)$/.test(c);
@@ -255,7 +263,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
255
263
  let want = '';
256
264
  /** Scrolled lists in the dropdown (the panel's columns, the sheet's list): back to their top for a new result set. */
257
265
  const scrolled = new Set<Element>();
266
+ /** A finger down in the sheet's list (a swipe through it puts the keyboard away: ensureHost); `ours`: the list's next scroll is our reset. */
267
+ let swipe: { list: Element; x: number; y: number; moved: boolean; cancelled?: number; ours?: boolean } | null = null;
258
268
  const toTop = () => {
269
+ // Our scroll, not the finger's: a finger at rest on the list is no swipe, the same finger's drag
270
+ // after it still is. Review (PR 26): a new result set's reset put the keyboard away.
271
+ if (swipe && scrolled.has(swipe.list)) swipe.ours = true;
259
272
  for (const el of scrolled) if (el.isConnected) el.scrollTop = 0;
260
273
  scrolled.clear();
261
274
  };
@@ -341,13 +354,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
341
354
  const intoMirror = (e: Event) => sheet && e.composedPath()[0] === mirror;
342
355
  box.addEventListener('pointerdown', (e) => e.pointerType !== 'touch' && !intoMirror(e) && e.preventDefault());
343
356
  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
- });
357
+ // A middle click opens a suggestion in a new tab (no choose()): a click too
358
+ // (core/tiles.ts), announced for analytics only (core/features.ts ANALYTICS).
359
+ if (ANALYTICS)
360
+ box.addEventListener('auxclick', (e) => {
361
+ if ((e as MouseEvent).button !== 1) return;
362
+ const a = (e.composedPath()[0] as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
363
+ const i = a ? items.findIndex((r) => r.type !== 'query' && !!r.c.url && new URL(r.c.url, location.href).href === a.href) : -1;
364
+ if (i >= 0) track(i);
365
+ });
351
366
  // "Show all products" has focus (Tab from the input): Shift+Tab goes back
352
367
  // to the input, Escape closes and goes back, Tab moves on as from the
353
368
  // input (focus put back on it, the browser's own Tab then leaves it: the
@@ -359,8 +374,9 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
359
374
  if (!inOverlay(el) || e.ctrlKey || e.metaKey) return;
360
375
  if (sheet) {
361
376
  if (e.key === 'Escape') {
362
- e.preventDefault();
363
- e.stopPropagation();
377
+ // A whole gesture, as from the input: focus is on the sheet after a swipe (or on its buttons),
378
+ // and its keyup reached the host's handlers. Review (PR 26).
379
+ consume(e);
364
380
  leave('back');
365
381
  } else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && el.getAttribute?.('role') === 'tab') {
366
382
  e.preventDefault();
@@ -396,6 +412,61 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
396
412
  if (!(el instanceof Element)) return;
397
413
  if (el.scrollTop > 0) scrolled.add(el);
398
414
  else scrolled.delete(el);
415
+ if (swipe?.list === el && swipe.ours) swipe.ours = false;
416
+ else if (swipe?.list === el && (!swipe.cancelled || performance.now() - swipe.cancelled < SWIPE_SCROLL_MS)) putKeyboardAway();
417
+ },
418
+ { capture: true, passive: true },
419
+ );
420
+ // A swipe through the sheet's list (or its no-results column) puts the
421
+ // keyboard away, the sheet stays: it grows to the whole screen (position),
422
+ // and a tap on its input brings the keyboard back. Only a drag: a tap on a
423
+ // row, a tab or "Show all" keeps the keyboard and is not swallowed; the
424
+ // pill band's sideways drag is not the list. Focus goes to the sheet
425
+ // (tabindex -1), not <body>: Escape and Tab still work from a hardware
426
+ // keyboard. Bug (iOS 26 Safari): with the keyboard up, its bar and the
427
+ // address bar above it, the sheet kept half the screen.
428
+ const putKeyboardAway = () => {
429
+ swipe = null;
430
+ if (!mirror || focused() !== mirror) return;
431
+ const panel = mirror.closest<HTMLElement>('.sheet[tabindex]');
432
+ if (panel) panel.focus({ preventScroll: true });
433
+ else mirror.blur(); // an integration's own sheet template
434
+ };
435
+ const finger = (e: PointerEvent) => e.pointerType === 'touch' && e.isPrimary;
436
+ box.addEventListener(
437
+ 'pointerdown',
438
+ (ev) => {
439
+ const e = ev as PointerEvent;
440
+ swipe = null;
441
+ if (!sheet || !finger(e)) return;
442
+ const list = e.composedPath().find((x) => x instanceof Element && x.matches('.sh-list, .sheet .empty')) as Element | undefined;
443
+ if (list) swipe = { list, x: e.clientX, y: e.clientY, moved: false };
444
+ },
445
+ { capture: true, passive: true },
446
+ );
447
+ box.addEventListener(
448
+ 'pointermove',
449
+ (ev) => {
450
+ const e = ev as PointerEvent;
451
+ if (!swipe || !finger(e)) return;
452
+ const dy = Math.abs(e.clientY - swipe.y);
453
+ if (dy <= Math.abs(e.clientX - swipe.x)) return;
454
+ if (dy > SWIPE) putKeyboardAway();
455
+ else if (dy >= SWIPE_START) swipe.moved = true;
456
+ },
457
+ { capture: true, passive: true },
458
+ );
459
+ box.addEventListener('pointerup', (e) => finger(e as PointerEvent) && (swipe = null), { capture: true, passive: true });
460
+ // The browser took the gesture over to scroll: after a first move (iOS
461
+ // Safari: a pull at the list's top, 10 px, then pointercancel), a swipe;
462
+ // without one, a swipe if the list scrolls right after (a flick), not a
463
+ // long press (a link's preview: no scroll; a later scroll is ours, toTop).
464
+ box.addEventListener(
465
+ 'pointercancel',
466
+ (e) => {
467
+ if (!swipe || !finger(e as PointerEvent)) return;
468
+ if (swipe.moved) putKeyboardAway();
469
+ else swipe.cancelled = performance.now();
399
470
  },
400
471
  { capture: true, passive: true },
401
472
  );
@@ -461,24 +532,44 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
461
532
  * before this task's writes (an opening: read after showing the popover,
462
533
  * it forced a layout).
463
534
  */
535
+ /** What Safari's bars and the keyboard cover of the sheet (px, --lbx-cover), -1 while it is not open. */
536
+ let cover = -1;
537
+ /** A clamp of the scrolled lists is queued (one for a keyboard going down, not one per frame of it). */
538
+ let clamping = false;
464
539
  const position = (frame = false, m?: ReturnType<typeof measure>) => {
465
540
  if (!open || !input) return;
466
541
  const s = host!.style;
467
542
  if (sheet) {
468
- // Full screen: its content in what shows of the page (the visual
469
- // viewport, above the on-screen keyboard), so the sticky "Show all"
470
- // sits above the keys. The host reaches on down to the layout
471
- // viewport's bottom, painted (openSheet): iOS 26 Safari ends the visual
472
- // viewport above its floating address bar, and between the two the
473
- // page showed through, see-through bars over it.
543
+ // Full screen from the top of what shows of the page (the visual
544
+ // viewport) down to the layout viewport's bottom: iOS 26 Safari ends
545
+ // the visual viewport above its floating bars (the address pill, with
546
+ // the keyboard up its form bar too), and the list runs on behind them,
547
+ // seen through their glass, as the legacy autocomplete's does. What
548
+ // they and the keyboard cover (--lbx-cover) ends the list as space, so
549
+ // its last row and "Show all" scroll clear of them. Bug (iOS 26/27):
550
+ // the list stopped above the bars, a blank strip down to the keys.
474
551
  const vv = window.visualViewport;
475
552
  const top = Math.round(vv?.offsetTop ?? 0);
476
553
  const h = Math.round(vv?.height ?? innerHeight);
554
+ const full = Math.max(h, innerHeight - top);
477
555
  s.top = `${top}px`;
478
556
  s.left = `${Math.round(vv?.offsetLeft ?? 0)}px`;
479
557
  s.width = `${Math.round(vv?.width ?? innerWidth)}px`;
480
- s.height = `${Math.max(h, innerHeight - top)}px`;
481
- s.setProperty('--lbx-vh', `${h}px`);
558
+ s.height = `${full}px`;
559
+ if (full - h !== cover) {
560
+ // Less covered (the keyboard going down): the end space shrinks, and iOS WebKit kept a list
561
+ // scrolled into it there, its rows stuck above a blank strip, not scrollable back (iOS 27
562
+ // Simulator). Its offset back within the list, read a frame later.
563
+ if (full - h < cover && !clamping) {
564
+ clamping = true;
565
+ afterFrame(() => {
566
+ clamping = false;
567
+ scrolled.forEach((el) => el.scrollTop > el.scrollHeight - el.clientHeight && (el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight)));
568
+ });
569
+ }
570
+ cover = full - h;
571
+ s.setProperty('--lbx-cover', `${cover}px`);
572
+ }
482
573
  host!.toggleAttribute('data-lbx-short', h < SHEET_SHORT);
483
574
  raf = requestAnimationFrame(() => position(true));
484
575
  return;
@@ -591,12 +682,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
591
682
  host!.showPopover();
592
683
  open = true;
593
684
  aria?.set('aria-expanded', 'true');
594
- hook('onOpen', ac.onOpen);
685
+ if (AC_HOOKS) hook('onOpen', ac.onOpen);
595
686
  cancelAnimationFrame(raf);
596
687
  // A hook may move the field (a storefront's opens its search strip, the
597
688
  // input lower): placed from where it is now, not from `m`, read before
598
689
  // it (otherwise the dropdown opens at the old place, then jumps).
599
- position(false, ac.onOpen ? undefined : m);
690
+ position(false, AC_HOOKS && ac.onOpen ? undefined : m);
600
691
  }
601
692
  };
602
693
 
@@ -605,11 +696,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
605
696
  * the focus's frame has rendered (layout clean then), ahead of the first
606
697
  * opening: read at the opening, after the page and our render had changed,
607
698
  * they forced a style and layout into the task that shows the results.
699
+ * Not once focus has left the input: putting the dropdown in the page
700
+ * borrows its ARIA, which focus leaving had put back (Shift+Tab back and
701
+ * Tab on within a frame left role=combobox on their input: WebKit on a slow
702
+ * machine, 2026-10-15).
608
703
  */
609
704
  let prepared: HTMLInputElement | null = null;
610
705
  const prepare = (i: HTMLInputElement) =>
611
706
  afterFrame(() => {
612
- if (open || input !== i || !i.isConnected || sheet) return;
707
+ if (open || input !== i || !i.isConnected || sheet || focused() !== i) return;
613
708
  lineUp(i);
614
709
  if (!light) sampleTokens(i, field!);
615
710
  prepared = i;
@@ -741,7 +836,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
741
836
  host!.style.setProperty('display', 'block', 'important');
742
837
  host!.showPopover();
743
838
  open = true;
744
- hook('onOpen', ac.onOpen);
839
+ if (AC_HOOKS) hook('onOpen', ac.onOpen);
745
840
  setCss('ac:sheet', ':root { overflow: hidden !important; }');
746
841
  try {
747
842
  history.pushState(null, '', location.href);
@@ -834,7 +929,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
834
929
  setCss('ac:sheet', null);
835
930
  host!.style.height = '';
836
931
  host!.style.background = 'transparent';
837
- host!.style.removeProperty('--lbx-vh');
932
+ host!.style.removeProperty('--lbx-cover');
933
+ cover = -1;
838
934
  host!.removeAttribute('data-lbx-short');
839
935
  mirror?.blur();
840
936
  if (focused() === input) input?.blur();
@@ -847,7 +943,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
847
943
  aria?.set('aria-activedescendant', null);
848
944
  live.clear();
849
945
  said = '';
850
- hook('onClose', ac.onClose, reason);
946
+ if (AC_HOOKS) hook('onClose', ac.onClose, reason);
851
947
  };
852
948
 
853
949
  /** The sheet input's value the last render drew (its clear button). */
@@ -956,16 +1052,37 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
956
1052
  * target only: its click still hands the query to the host (submitViaHost).
957
1053
  * `value`: the phrase instead of the input's (the phone sheet's own input).
958
1054
  */
1055
+ // Where the site's server redirects the form to the phrase's page (search.queryUrl): that page,
1056
+ // same origin and http(s) as the form's (as every other use of the hook checks), else the form's.
1057
+ // Decided outside searchHref's try (a flag in a try block is never folded: build/vite.mjs).
1058
+ const pageOf = QUERY_URL
1059
+ ? (q: string, u: URL) => {
1060
+ if (!q || !ctx.queryUrl) return u.href;
1061
+ try {
1062
+ const to = ctx.queryUrl(q, new URL(u.href));
1063
+ if (to === '') return u.href;
1064
+ const r = new URL(to, location.href);
1065
+ return (r.protocol === 'https:' || r.protocol === 'http:') && r.origin === location.origin ? r.href : u.href;
1066
+ } catch (e) {
1067
+ reportError('search queryUrl', e);
1068
+ return u.href;
1069
+ }
1070
+ }
1071
+ : null;
959
1072
  const searchHref = (i: HTMLInputElement | null, value?: string) => {
960
1073
  const f = i?.form;
961
1074
  if (!f || (f.getAttribute('method') || 'get').trim().toLowerCase() !== 'get') return '';
962
1075
  try {
963
1076
  const u = new URL(f.getAttribute('action')?.trim() || location.href, document.baseURI);
964
1077
  const p = new URLSearchParams();
965
- for (const [k, v] of new FormData(f)) if (typeof v === 'string') p.append(k, v);
1078
+ // With the form's default button, as Enter submits (its name=value, or an
1079
+ // image button's x and y): the URL the result and the page were fetched
1080
+ // ahead for (widgets/search/form-url.ts reads the same, core/default-button.ts).
1081
+ for (const [k, v] of formDataOf(f, defaultSubmitter(f))) if (typeof v === 'string') p.append(k, v);
966
1082
  if (value !== undefined && i!.name) p.set(i!.name, value);
967
1083
  u.search = p.toString();
968
- return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : '';
1084
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return '';
1085
+ return pageOf ? pageOf((value ?? i!.value).trim(), u) : u.href;
969
1086
  } catch {
970
1087
  return '';
971
1088
  }
@@ -1065,11 +1182,17 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1065
1182
  };
1066
1183
  /** The "No results" layout's options: phrases to try, links to browse, fallback products. */
1067
1184
  const emptyOptions = (fallback: Result[]) => {
1068
- const tag = (r: Result, kind: string) => Object.assign(r, { __empty: kind });
1185
+ // Luma's own options, not catalog hits: their text and link are the
1186
+ // config's, whatever the integration computes for its catalog's.
1187
+ const own = (hit: { '@id': string; '@type': string; '@title': string; url?: string }, kind: string) => {
1188
+ const r = ctx.result(hit);
1189
+ Object.defineProperties(r.c, { title: { value: hit['@title'] }, url: { value: safeUrl(hit.url) } });
1190
+ return Object.assign(r, { __empty: kind });
1191
+ };
1069
1192
  return [
1070
- ...(empty.tryQueries ?? []).filter((q) => typeof q === 'string' && q.trim()).map((q) => tag(ctx.result({ '@id': `try/${q}`, '@type': 'query', '@title': q }), 'try')),
1071
- ...(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')),
1072
- ...fallback.map((r) => tag({ ...r }, 'fallback')),
1193
+ ...(empty.tryQueries ?? []).filter((q) => typeof q === 'string' && q.trim()).map((q) => own({ '@id': `try/${q}`, '@type': 'query', '@title': q }, 'try')),
1194
+ ...(empty.browse ?? []).filter((b) => b && safeUrl(b.url)).map((b) => own({ '@id': `browse/${b.url}`, '@type': 'browse', '@title': b.title ?? '', url: b.url }, 'browse')),
1195
+ ...fallback.map((r) => ({ ...r, __empty: 'fallback' })),
1073
1196
  ];
1074
1197
  };
1075
1198
 
@@ -1214,7 +1337,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1214
1337
  /** Hand a query to the host so its own router/form performs navigation. */
1215
1338
  const submitViaHost = (q: string) => {
1216
1339
  const i = input!;
1217
- hook('onSelect', ac.onSelect, null);
1340
+ if (AC_HOOKS) hook('onSelect', ac.onSelect, null);
1218
1341
  // Paint our close first; the host's router render then runs in its own task.
1219
1342
  if (sheet) return leave('select', () => handToHost(i, q));
1220
1343
  close('select');
@@ -1254,7 +1377,17 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1254
1377
  const opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true } as KeyboardEventInit;
1255
1378
  const notCancelled = i.dispatchEvent(new KeyboardEvent('keydown', opts));
1256
1379
  i.dispatchEvent(new KeyboardEvent('keyup', opts));
1257
- if (notCancelled && i.form) i.form.requestSubmit();
1380
+ // As Enter would: with the form's default button (its name=value, formaction), so
1381
+ // the page loads at the URL the result and the document were fetched ahead for
1382
+ // (instant-search.spec 'a search button with a name', on a phone: here, not an Enter).
1383
+ if (notCancelled && i.form) {
1384
+ const f = i.form;
1385
+ try {
1386
+ f.requestSubmit(defaultSubmitter(f) ?? undefined);
1387
+ } catch {
1388
+ f.requestSubmit(); // a button the engine refuses as a submitter (none of this form)
1389
+ }
1390
+ }
1258
1391
  };
1259
1392
 
1260
1393
  /**
@@ -1274,8 +1407,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1274
1407
  if (r.type === 'query') submitViaHost(plain(r.c.title));
1275
1408
  else if (safeUrl(r.c.url)) {
1276
1409
  const url = safeUrl(r.c.url);
1277
- track(idx);
1278
- hook('onSelect', ac.onSelect, (r as any).__empty ? null : r);
1410
+ if (ANALYTICS) track(idx);
1411
+ if (AC_HOOKS) hook('onSelect', ac.onSelect, (r as any).__empty ? null : r);
1279
1412
  leave('select', () => location.assign(url));
1280
1413
  }
1281
1414
  }
@@ -1534,7 +1667,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1534
1667
  close('navigate');
1535
1668
  });
1536
1669
 
1537
- hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && lineUp(input), position()) });
1670
+ if (AC_HOOKS) hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && lineUp(input), position()) });
1538
1671
 
1539
1672
  // The interaction that loaded us has already happened: pick it up.
1540
1673
  const now = focused();
@@ -36,9 +36,9 @@
36
36
  --lbx-radius-row: calc(var(--lbx-radius) * .6);
37
37
  --lbx-radius-tile: calc(var(--lbx-radius) * .5);
38
38
  }
39
- /* The render root: the sheet is the visual viewport's height (--lbx-vh, index.ts; the host reaches on
40
- below it, painted); the panel is as tall as it is. */
41
- .lbx-root { height: var(--lbx-vh, 100%); }
39
+ /* The render root: the sheet is the host's height (from the visual viewport's top to the layout
40
+ viewport's bottom, index.ts); the panel is as tall as it is. */
41
+ .lbx-root { height: 100%; }
42
42
  /* What the rules read: the tokens, or the high-contrast palette while it is on. */
43
43
  .box, .sheet {
44
44
  --c-fg: var(--lbx-c-fg, var(--lbx-fg));
@@ -155,12 +155,12 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
155
155
  .e-opts { margin-top: 10px; display: flex; flex-direction: column; gap: 12px; }
156
156
  .grp.inline { flex-direction: row; flex-wrap: wrap; align-items: center; gap: 8px; }
157
157
  .grp.inline h3 { padding: 0; margin-right: 4px; font-size: 14px; }
158
- .tries, .browse { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; }
158
+ .tries, .e-links { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; }
159
159
  .tries { gap: 8px; }
160
160
  .try { height: 34px; padding: 0 14px; gap: 6px; border-radius: 999px; box-shadow: inset 0 0 0 1.5px var(--c-line); font-size: 14px; font-weight: 700; }
161
161
  .try .glass { color: var(--c-accent); }
162
162
  .try[aria-selected="true"] { background: var(--lbx-c-sel-bg, var(--c-tint)); color: var(--lbx-c-sel-fg, inherit); box-shadow: inset 0 0 0 1.5px var(--c-fill); }
163
- .browse .chev { display: none; }
163
+ .opt.browse .chev { display: none; }
164
164
  .opt.browse { padding: 2px 6px; margin: 0 -6px; border-radius: var(--lbx-radius-row); color: var(--c-accent); font-weight: 700; font-size: 14px; }
165
165
  .opt.browse[aria-selected="true"] { background: var(--lbx-c-sel-bg, var(--c-tint)); text-decoration: underline; text-underline-offset: 3px; }
166
166
  .help { width: 300px; flex: none; padding: 18px 20px; border-radius: var(--lbx-radius-row); border: 1.5px dashed var(--lbx-tint-strong); display: flex; gap: 14px; align-items: center; }
@@ -188,6 +188,8 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
188
188
  /* @lbx-part autocomplete-sheet: left out of a build whose integration replaces this template and uses none of its classes (build/vite.mjs) */
189
189
  /* ---- the phone sheet --------------------------------------------------- */
190
190
  .sheet { height: 100%; display: flex; flex-direction: column; overflow: hidden; background: var(--c-bg); border-top: 3px solid var(--c-fill); }
191
+ /* Focused from code only (tabindex -1: a swipe puts the keyboard away, index.ts): the whole screen, no ring. */
192
+ .sheet:focus { outline: none; }
191
193
  .sh-head { height: 60px; flex: none; padding: 0 8px 0 4px; display: flex; align-items: center; gap: 4px; border-bottom: 1px solid var(--c-line); touch-action: none; }
192
194
  .icon { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; border-radius: var(--lbx-radius-row); }
193
195
  .x { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 999px; background: var(--lbx-soft); }
@@ -199,6 +201,12 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
199
201
  .tab { flex: none; padding: 0 14px; font-size: 15px; color: var(--c-muted); white-space: nowrap; }
200
202
  .tab[aria-selected="true"] { color: var(--c-fg); font-weight: 700; box-shadow: inset 0 -3px 0 var(--c-fill); }
201
203
  .sh-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; touch-action: pan-y; }
204
+ /* What Safari's floating bars and the keyboard cover (--lbx-cover, index.ts): the list (and the
205
+ no-results column) runs on behind them, its end scrolls clear of them (as space; a row brought into
206
+ view by the keys or by focus too). A tap on what shows through the bars is Safari's, as on the
207
+ legacy autocomplete. */
208
+ .sh-list, .sheet .empty { scroll-padding-bottom: var(--lbx-cover, 0px); }
209
+ .sh-list::after, .sheet .empty::after { content: ""; flex: none; height: var(--lbx-cover, 0px); }
202
210
  .sh-list .pby { margin: 0; padding: 20px 16px 24px; justify-content: center; font-size: 13px; }
203
211
  .m-hero { padding: 16px; gap: 12px; border-bottom: 1px solid var(--c-line); }
204
212
  .m-hero h3 { padding: 0; font-size: 13px; color: var(--c-muted); }
@@ -222,7 +230,7 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
222
230
  .mrow .t { flex: 1; min-width: 0; }
223
231
  .mrow .chev, .opt.browse .chev { flex: none; color: var(--c-muted); }
224
232
  .mrow[aria-selected="true"] { background: var(--lbx-c-sel-bg, var(--c-tint)); color: var(--lbx-c-sel-fg, inherit); box-shadow: inset 3px 0 0 var(--c-fill); }
225
- .sh-foot { flex: none; padding: 10px 16px 14px; border-top: 1px solid var(--c-line); background: var(--c-bg); touch-action: none; }
233
+ .sh-foot { flex: none; padding: 16px 16px 0; }
226
234
  .sheet .all { height: 48px; font-size: 16px; background: var(--c-fill); color: var(--c-on-fill); box-shadow: none; }
227
235
  /* No results on the phone: one column, its own scroll. */
228
236
  .sheet .empty { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; touch-action: pan-y; }
@@ -232,9 +240,9 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
232
240
  .sheet .e-title { font-size: 21px; }
233
241
  .sheet .grp.inline { flex-direction: column; align-items: stretch; }
234
242
  .sheet .try { height: 40px; font-size: 15px; }
235
- .sheet .browse { flex-direction: column; align-items: stretch; gap: 0; }
243
+ .sheet .e-links { flex-direction: column; align-items: stretch; gap: 0; }
236
244
  .sheet .opt.browse { min-height: 48px; margin: 0 -6px; padding: 0 6px; justify-content: space-between; font-size: 16px; }
237
- .sheet .browse .chev { display: block; }
245
+ .sheet .opt.browse .chev { display: block; }
238
246
  .sheet .help { width: auto; padding: 16px; }
239
247
  .sheet .help-text { flex: 1; }
240
248
  .sheet .help-title { font-size: 15px; }
@@ -34,7 +34,7 @@ export default (s: AutocompleteScope) => {
34
34
  ${tries.length || browse.length
35
35
  ? html`<div class="e-opts" role="listbox" id="lbx-ac-listbox" aria-label=${s.t('label')}>
36
36
  ${tries.length ? group('try', s.t('tryLabel'), html`<div class="tries">${tries.map((x) => opt(x, 'try'))}</div>`, 'inline') : nothing}
37
- ${browse.length ? group('browse', s.t('browseLabel'), html`<div class="browse">${browse.map((x) => opt(x, 'browse'))}</div>`, 'inline') : nothing}
37
+ ${browse.length ? group('browse', s.t('browseLabel'), html`<div class="e-links">${browse.map((x) => opt(x, 'browse'))}</div>`, 'inline') : nothing}
38
38
  </div>`
39
39
  : nothing}
40
40
  </div>
@@ -4,9 +4,11 @@ import { POWERED, group, showAll } from './template-autocomplete-box';
4
4
 
5
5
  // The house design on a phone: a full-screen sheet. Its header
6
6
  // (back, its own input, clear), the query pills on one sideways-scrolling
7
- // band, a tab per type with results (products first; no counts), the open
8
- // tab's options (the top product, then product rows; or 56 px rows), and
9
- // "Show all products" in a bar that stays above the on-screen keyboard.
7
+ // band, a tab per type with results (products first; no counts; no tab bar
8
+ // for a lone type: 48 px of a short sheet, and the options say what they
9
+ // are), the open tab's options (the top product, then product rows; or 56 px rows), and
10
+ // "Show all products" at the end of the list (below the fold with the keyboard
11
+ // up: the list gets the room; a swipe puts the keyboard away, index.ts).
10
12
  // Its input is the combobox (index.ts): it controls both listboxes and
11
13
  // points at the selected option (aria-activedescendant, set by index.ts).
12
14
 
@@ -16,6 +18,8 @@ export default (s: AutocompleteScope) => {
16
18
  const results = s.state === 'results';
17
19
  const onProducts = !!s.hero && s.tabs.find((x) => x.selected)?.kind === s.hero.r.type;
18
20
  const open = s.tabs.find((x) => x.selected);
21
+ // A lone type: no tab bar, no tabpanel; its listbox is named by the type ("Products").
22
+ const tabbed = s.tabs.length > 1;
19
23
  const rows = open && !onProducts ? (s.lists.find((g) => g.kind === open.kind)?.rows ?? []) : [];
20
24
  // What the input controls: the listboxes on screen.
21
25
  const e = s.empty;
@@ -29,7 +33,7 @@ export default (s: AutocompleteScope) => {
29
33
  .filter(Boolean)
30
34
  .join(' ');
31
35
  return html`
32
- <div class="sheet" data-lbx-part="ac-panel">
36
+ <div class="sheet" data-lbx-part="ac-panel" role="group" aria-label=${s.inputLabel} tabindex="-1">
33
37
  <div class="sh-head">
34
38
  <button type="button" class="icon back" aria-label=${s.t('closeSearch')} @click=${() => s.back()}>
35
39
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6"></path></svg>
@@ -48,7 +52,7 @@ export default (s: AutocompleteScope) => {
48
52
  ${s.phrases.length
49
53
  ? html`<div class="sh-phrases" role="listbox" id="lbx-ac-phrases" aria-label=${s.t('phrases')}>${repeat(s.phrases, key, (p) => opt(p, 'pill'))}</div>`
50
54
  : nothing}
51
- ${s.tabs.length
55
+ ${tabbed
52
56
  ? html`<div class="tabs" role="tablist" aria-label=${s.t('tabs')}>
53
57
  ${s.tabs.map(
54
58
  (x) =>
@@ -56,18 +60,18 @@ export default (s: AutocompleteScope) => {
56
60
  )}
57
61
  </div>`
58
62
  : nothing}
59
- <div class="sh-list" role="tabpanel" id="lbx-ac-panel" aria-labelledby=${open ? `lbx-ac-tab-${open.kind}` : nothing}>
63
+ <div class="sh-list" role=${tabbed ? 'tabpanel' : nothing} id="lbx-ac-panel" aria-labelledby=${tabbed ? `lbx-ac-tab-${open!.kind}` : nothing}>
60
64
  ${s.tabs.length
61
- ? html`<div role="listbox" id="lbx-ac-listbox" aria-label=${s.t('label')}>
65
+ ? html`<div role="listbox" id="lbx-ac-listbox" aria-label=${tabbed ? s.t('label') : open!.label}>
62
66
  ${onProducts
63
67
  ? html`${group('hero', s.t('topProduct'), opt(s.hero!, 'mhero'), 'm-hero')}
64
68
  ${s.products.length ? group('products', s.t('products'), repeat(s.products, key, (p) => opt(p, 'mproduct')), 'm-products') : nothing}`
65
69
  : repeat(rows, key, (row) => opt(row, 'mrow'))}
66
70
  </div>`
67
71
  : nothing}
72
+ ${s.hero ? html`<div class="sh-foot">${showAll(s)}</div>` : nothing}
68
73
  ${POWERED(s.t)}
69
- </div>
70
- ${s.hero ? html`<div class="sh-foot">${showAll(s)}</div>` : nothing}`
74
+ </div>`
71
75
  : nothing}
72
76
  </div>
73
77
  `;
@@ -0,0 +1,87 @@
1
+ // Apply in a staged drawer, marked (element.ts swapIn): a change the shopper
2
+ // confirmed with the drawer closing over the list must read as one. On an
3
+ // iPhone the quiet fade of a prefetched result read as a flicker: had the
4
+ // filters been taken? (2026-10-19) The list it replaces dims at the tap
5
+ // (`data-lbx-swap`, as busy's dim: `--lbx-c-dim` holds it in the contrast
6
+ // mode), the new tiles come in, and a status at the listing's top says what
7
+ // was applied. Its own chunk (lbx-applied), asked for when a drawer opens
8
+ // where it stages: a listing page that never applies one does not load it.
9
+ import { cspNonce, layered, setCss } from '../../core/styles';
10
+
11
+ const SWAP_MS = 300;
12
+ export const CSS =
13
+ ':is(lbx-search,lbx-plp)[data-lbx-swap] [data-lbx-list]{opacity:var(--lbx-c-dim,.15)}' +
14
+ '@media (forced-colors:active){:is(lbx-search,lbx-plp)[data-lbx-swap] [data-lbx-list]{opacity:.6}}' +
15
+ // The contrast mode (core/a11y.ts): the pill opaque, nothing of the page through it.
16
+ ':root[data-lbx-contrast="more"] [data-lbx-applied-cue]{--lbx-applied-opaque:1}' +
17
+ '@media (prefers-contrast:more){:root:not([data-lbx-contrast="off"]) [data-lbx-applied-cue]{--lbx-applied-opaque:1}}';
18
+ // The pill is the one element with Luma's own look in the site's light
19
+ // DOM: a site's reset (`div,span{margin:0;padding:0}`, unlayered, so
20
+ // above Luma's layered CSS) took its padding and centring (a production storefront,
21
+ // 2026-10-19). Its wrapper's layout is its inline style (`!important`, as the
22
+ // status region's); the pill is in the wrapper's shadow root, with its own
23
+ // sheet. A site restyles it through `::part(pill)` or the `--lbx-applied-*`
24
+ // properties, which inherit into it.
25
+ // Sticky, 0 px high, the pill out of flow: margins collapse through it, nothing moves (a flex wrapper moved the list 16 px).
26
+ // What else a site's rule on `div` could set and break: clipped (overflow), given height (min-height: the list moves), narrowed (float, width).
27
+ export const WRAP =
28
+ 'display:block!important;position:sticky!important;top:var(--lbx-applied-top,8px)!important;z-index:3!important;height:0!important;min-height:0!important;max-height:none!important;' +
29
+ 'width:auto!important;max-width:none!important;float:none!important;margin:0!important;padding:0!important;border:0!important;overflow:visible!important;transform:none!important;filter:none!important;' +
30
+ 'opacity:1!important;visibility:visible!important;contain:none!important;pointer-events:none!important';
31
+ export const PILL =
32
+ // Wraps (a long query, a long language) rather than cut: its words are shown nowhere else. A tap goes through it
33
+ // (all:initial put pointer-events back: it took taps on what it overlaps). Its font the site's; its size at least the reader's default's 15/16.
34
+ // `--lbx-applied-offset`: below the listing's top by this much (a template whose heading sits there).
35
+ '.p{all:initial;display:block;position:absolute;top:var(--lbx-applied-offset,0px);left:0;right:0;box-sizing:border-box;width:fit-content;max-width:calc(100% - 16px);margin:0 auto;padding:8px 16px;border-radius:18px;pointer-events:none;' +
36
+ 'background:var(--lbx-applied-bg,#1d2433);color:var(--lbx-applied-fg,#fff);font-family:inherit;font-size:max(15px,.9375rem);font-weight:500;line-height:1.3;text-align:center;overflow-wrap:anywhere;box-shadow:0 2px 12px rgb(0 0 0/.25)}' +
37
+ // A little of the page through it, blurred (white text at least 10:1 over any page behind); opaque in the contrast mode (--lbx-applied-opaque).
38
+ '@supports (backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px)){.p{background:var(--lbx-applied-bg,rgb(29 36 51/calc(.88 + .12 * var(--lbx-applied-opaque,0))));' +
39
+ '-webkit-backdrop-filter:blur(calc(8px - 8px * var(--lbx-applied-opaque,0)));backdrop-filter:blur(calc(8px - 8px * var(--lbx-applied-opaque,0)))}}' +
40
+ '@media (forced-colors:active){.p{border:1px solid CanvasText;background:Canvas;color:CanvasText}}';
41
+ let sheet: CSSStyleSheet | null = null;
42
+
43
+ /** Its styles, before the first Apply (the drawer's opening restyles the page anyway). */
44
+ export const prepare = () => setCss('listing-applied', layered(CSS));
45
+
46
+ /**
47
+ * The applied state's first tiles come in (rising row after row, only fading
48
+ * under reduced motion), and the status's pill goes after `after` (Luma's
49
+ * status region, which says the same words: the pill is hidden from
50
+ * assistive technology). Returns what takes the pill away (faded, or at once).
51
+ */
52
+ export const swapIn = (host: HTMLElement, tiles: Element[], say: string, after: Element | null, reduce: boolean) => {
53
+ tiles.forEach((el, i) =>
54
+ el.animate(reduce ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 0, transform: 'translateY(12px)' }, { opacity: 1, transform: 'none' }], {
55
+ duration: SWAP_MS,
56
+ delay: reduce ? 0 : Math.min(i, 7) * 35,
57
+ easing: 'cubic-bezier(.2,0,0,1)',
58
+ fill: 'backwards',
59
+ }),
60
+ );
61
+ const el = document.createElement('div');
62
+ el.setAttribute('data-lbx-applied-cue', '');
63
+ el.setAttribute('aria-hidden', 'true');
64
+ el.style.cssText = WRAP;
65
+ const root = el.attachShadow({ mode: 'open' });
66
+ // Safari before 16.4 has no constructed sheets (its constructor throws): a <style>, with the page's nonce.
67
+ if ('adoptedStyleSheets' in ShadowRoot.prototype) {
68
+ if (!sheet) (sheet = new CSSStyleSheet()), sheet.replaceSync(PILL);
69
+ root.adoptedStyleSheets = [sheet];
70
+ } else {
71
+ const st = root.appendChild(document.createElement('style'));
72
+ st.nonce = cspNonce();
73
+ st.textContent = PILL;
74
+ }
75
+ const pill = root.appendChild(document.createElement('span'));
76
+ pill.className = 'p';
77
+ pill.setAttribute('part', 'pill');
78
+ pill.textContent = `✓ ${say}`;
79
+ if (after) after.after(el);
80
+ else host.prepend(el);
81
+ pill.animate([{ opacity: 0 }, { opacity: 1 }], 200);
82
+ return (fade: boolean) => {
83
+ if (!fade) return el.remove();
84
+ // A cancelled animation rejects `finished`: removed either way.
85
+ pill.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 300, fill: 'forwards' }).finished.then(() => el.remove(), () => el.remove());
86
+ };
87
+ };