@luigisbox/luma 0.1.0 → 0.3.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 (41) hide show
  1. package/README.md +208 -76
  2. package/build/vite.mjs +9 -0
  3. package/chrome/block.mjs +8 -0
  4. package/chrome/run.mjs +10 -5
  5. package/docs/ACCESSIBILITY.md +9 -5
  6. package/docs/INTEGRATION-PERFORMANCE.md +5 -6
  7. package/package.json +1 -1
  8. package/parity/hold.mjs +1 -1
  9. package/src/analytics/chain.ts +137 -0
  10. package/src/analytics/collect.ts +64 -0
  11. package/src/analytics/datalayer.ts +67 -0
  12. package/src/analytics/from-ga4.ts +68 -0
  13. package/src/analytics/from-luma.ts +21 -0
  14. package/src/analytics/index.ts +24 -0
  15. package/src/analytics/outbox.ts +118 -0
  16. package/src/analytics/page.ts +46 -0
  17. package/src/analytics/pageview.ts +82 -0
  18. package/src/analytics/ref.ts +19 -0
  19. package/src/analytics/setup.ts +41 -0
  20. package/src/analytics/start.ts +53 -0
  21. package/src/analytics/triggers.ts +239 -0
  22. package/src/analytics/user.ts +12 -0
  23. package/src/analytics/wire.ts +67 -0
  24. package/src/api/token.ts +3 -2
  25. package/src/core/entries.ts +10 -6
  26. package/src/core/integration.ts +6 -0
  27. package/src/core/kept.ts +25 -0
  28. package/src/core/stash.ts +24 -4
  29. package/src/core/tiles.ts +137 -0
  30. package/src/main.ts +54 -4
  31. package/src/types.ts +29 -2
  32. package/src/widgets/autocomplete/index.ts +206 -31
  33. package/src/widgets/autocomplete/style-autocomplete.css +11 -5
  34. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
  35. package/src/widgets/listing/element.ts +212 -21
  36. package/src/widgets/listing/instant.ts +83 -23
  37. package/src/widgets/listing/stash.ts +31 -8
  38. package/src/widgets/listing/view.ts +12 -2
  39. package/src/widgets/recommender/index.ts +15 -4
  40. package/src/widgets/search/range.ts +40 -18
  41. package/src/widgets/search/style-search.css +2 -2
@@ -27,6 +27,7 @@ import { cspNonce, setCss } from '../../core/styles';
27
27
  import type { WidgetContext } from '../../main';
28
28
  import type { AutocompleteConfigType, AutocompleteScope, Result } from '../../types';
29
29
  import css from './style-autocomplete.css?raw';
30
+ import { picked } from '../../core/tiles';
30
31
 
31
32
  export const templates = byName(import.meta.glob('./template-*.ts', { eager: true, import: 'default' }));
32
33
 
@@ -46,10 +47,23 @@ const PANEL_MAX = 1200;
46
47
  const PANEL_LIST = 560;
47
48
  const PANEL_GUTTER = 24;
48
49
  const PANEL_WIDE = 1000;
50
+ /** A content column inset at least this far from the screen's edges is the page's (its edges hold, inside the gutter too); nearer, it is the screen's width. */
51
+ const COLUMN_INSET = 8;
52
+ /** `active` on "Show all products" (`data-lbx-all`): the arrow keys' last stop in the dropdown, after the options. */
53
+ const ALL = -2;
54
+ /** The id an option, or "Show all products", is referenced by (aria-activedescendant). */
55
+ const idOf = (i: number) => (i === ALL ? 'lbx-ac-all' : `lbx-ac-${i}`);
49
56
  /** Products beside the top product on the panel (2 x 3). */
50
57
  const GRID_MAX = 6;
51
- /** Phones (the viewport up to this width): the house design is a full-screen sheet. */
52
- const SHEET_MEDIA = '(max-width: 600px)';
58
+ /**
59
+ * Phones: the house design is a full-screen sheet. A narrow viewport, or a
60
+ * touch screen (no hover, as the entry tells one) that is short: a phone in
61
+ * landscape (844 x 390) got the desktop panel, most of it under the
62
+ * on-screen keyboard. A tablet (768 px and taller) keeps the panel.
63
+ */
64
+ export const SHEET_MEDIA = '(max-width: 600px), (hover: none) and (max-height: 500px)';
65
+ /** The sheet's visible height below which its top product is drawn smaller (the keyboard up): more of the list shows. */
66
+ const SHEET_SHORT = 480;
53
67
 
54
68
  /** A computed colour that paints nothing (`rgba(0, 0, 0, 0)`, `transparent`, `color(srgb 0 0 0 / 0)`). */
55
69
  const clear = (c: string) => !c || c === 'transparent' || /(?:,|\/)\s*0(?:\.0*)?\)$/.test(c);
@@ -128,14 +142,58 @@ export const dropdownBox = (r: { left: number; right: number; width: number }, v
128
142
  * for a batch without products), centred under the field, kept 24 px from
129
143
  * the screen's edges; its caret under the field's left edge + 30 px (inside
130
144
  * the panel's corners). `wide`: the top product has its own column.
145
+ * `col`: the page's content column (`columnOf`): the panel also kept within
146
+ * its left and right edges (no wider than it), as the page's own content is;
147
+ * an edge of it inset from the screen's holds inside the gutter too (a
148
+ * column 20 px from the edges: the panel at 20, not 24), one at the screen's
149
+ * edge (less than COLUMN_INSET) leaves the gutter.
131
150
  */
132
- export const panelBox = (r: { left: number; width: number }, vw: number, listOnly = false) => {
133
- const width = Math.max(0, Math.min(listOnly ? PANEL_LIST : PANEL_MAX, vw - 2 * PANEL_GUTTER));
134
- const left = Math.max(PANEL_GUTTER, Math.min(r.left + r.width / 2 - width / 2, vw - PANEL_GUTTER - width));
151
+ export const panelBox = (r: { left: number; width: number }, vw: number, listOnly = false, col?: { left: number; right: number } | null) => {
152
+ const lo = col && col.left >= COLUMN_INSET ? col.left : PANEL_GUTTER;
153
+ const hi = col && col.right <= vw - COLUMN_INSET ? col.right : vw - PANEL_GUTTER;
154
+ const width = Math.max(0, Math.min(listOnly ? PANEL_LIST : PANEL_MAX, hi - lo));
155
+ const left = Math.max(lo, Math.min(r.left + r.width / 2 - width / 2, hi - width));
135
156
  const caret = Math.max(16, Math.min(r.left + 30 - left, width - 30));
136
157
  return { left, width, caret, wide: width >= PANEL_WIDE };
137
158
  };
138
159
 
160
+ /** `alignTo`'s parts: the field (a string: that alone) and the content column. */
161
+ const alignOf = (a: AutocompleteConfigType['alignTo']) => (typeof a === 'object' && a ? a : { field: a, column: undefined });
162
+
163
+ /**
164
+ * The page's content column the house panel stays within (`alignTo.column`),
165
+ * read once per opening with the field:
166
+ * - `'viewport'`: none (the screen, less the gutter);
167
+ * - a selector: the field's nearest ancestor matching it, else the first
168
+ * element on the page matching it (a header and a content column side by
169
+ * side), else none;
170
+ * - by default, the outermost ancestor of the field that is inset from both
171
+ * of the screen's edges (COLUMN_INSET) and at least PANEL_WIDE wide (or the
172
+ * screen less the gutters, on a narrower one): a storefront's centred
173
+ * `max-width` container (the shop's logo and cart at its edges), also
174
+ * where it nearly fills a 1024 px screen. A narrower box is a part of the
175
+ * header, not the page's column.
176
+ */
177
+ export const columnOf = (field: Element, column?: string): Element | null => {
178
+ if (column === 'viewport') return null;
179
+ if (column) {
180
+ try {
181
+ for (let el = up(field); el; el = up(el)) if (el.matches(column)) return el;
182
+ return document.querySelector(column);
183
+ } catch {
184
+ return null; // not a selector
185
+ }
186
+ }
187
+ const vw = document.documentElement.clientWidth;
188
+ let found: Element | null = null;
189
+ const min = Math.min(PANEL_WIDE, vw - 2 * PANEL_GUTTER);
190
+ for (let el = up(field); el && el !== document.body && el !== document.documentElement; el = up(el)) {
191
+ const b = el.getBoundingClientRect();
192
+ if (b.width >= min && b.left >= COLUMN_INSET && b.right <= vw - COLUMN_INSET) found = el;
193
+ }
194
+ return found;
195
+ };
196
+
139
197
  /** The house tokens a config may set (`autocomplete.tokens`), as their custom properties. */
140
198
  const TOKEN_PROPS: Record<string, string> = {
141
199
  font: 'font',
@@ -186,6 +244,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
186
244
  /** The arrow keys' order over `items` (the house design's reading order: pills, lists, the top product, products). */
187
245
  let order: number[] = [];
188
246
  let lastQuery = '';
247
+ /** The arrow keys' selection: an option of `items`, -1 none (the input), ALL "Show all products". */
189
248
  let active = -1;
190
249
  let open = false;
191
250
  let raf = 0;
@@ -194,6 +253,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
194
253
  let quietFocus = false;
195
254
  /** The phrase the dropdown is for (normalised input), '' while closed by the visitor or below minChars. */
196
255
  let want = '';
256
+ /** Scrolled lists in the dropdown (the panel's columns, the sheet's list): back to their top for a new result set. */
257
+ const scrolled = new Set<Element>();
258
+ const toTop = () => {
259
+ for (const el of scrolled) if (el.isConnected) el.scrollTop = 0;
260
+ scrolled.clear();
261
+ };
197
262
  /** Per type, what the dropdown shows. */
198
263
  const shown = new Map<string, Result[]>();
199
264
  /** The house panel: wide enough for the top product's column; a batch without products (the list alone, narrower). */
@@ -205,6 +270,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
205
270
  let tab = '';
206
271
  let entry = false;
207
272
  let afterBack: (() => void) | null = null;
273
+ /** What the site's input held when the sheet opened: put back when the sheet is cancelled (its back button, Escape, Back). */
274
+ let before = '';
208
275
  // Accessibility (docs/ACCESSIBILITY.md, "Autocomplete"): the host's input is
209
276
  // the combobox. Its ARIA is borrowed while it has focus and put back when
210
277
  // focus leaves it; the result count is announced politely, once typing pauses.
@@ -274,6 +341,13 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
274
341
  const intoMirror = (e: Event) => sheet && e.composedPath()[0] === mirror;
275
342
  box.addEventListener('pointerdown', (e) => e.pointerType !== 'touch' && !intoMirror(e) && e.preventDefault());
276
343
  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
+ });
277
351
  // "Show all products" has focus (Tab from the input): Shift+Tab goes back
278
352
  // to the input, Escape closes and goes back, Tab moves on as from the
279
353
  // input (focus put back on it, the browser's own Tab then leaves it: the
@@ -311,6 +385,20 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
311
385
  backToInput();
312
386
  }
313
387
  });
388
+ // Which of its lists the shopper scrolled (read in their own scroll
389
+ // event, layout clean): a new result set starts at their top (toTop).
390
+ // Bug: a list scrolled down for "fiets" drew "fiets slot" scrolled down,
391
+ // the top product out of sight.
392
+ box.addEventListener(
393
+ 'scroll',
394
+ (e) => {
395
+ const el = e.composedPath()[0];
396
+ if (!(el instanceof Element)) return;
397
+ if (el.scrollTop > 0) scrolled.add(el);
398
+ else scrolled.delete(el);
399
+ },
400
+ { capture: true, passive: true },
401
+ );
314
402
  // Focus leaving the dropdown for anywhere but the input: closed, as when
315
403
  // it leaves the input. Not the sheet: it covers the page, and its input
316
404
  // lets go whenever the shopper puts the keyboard away to read.
@@ -349,6 +437,14 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
349
437
 
350
438
  /** What the dropdown lines up with for this opening (`alignTo`): the visible search field, else the input. */
351
439
  let field: Element | null = null;
440
+ /** The page's content column the house panel stays within, for this opening (`alignTo.column`; none: the screen). */
441
+ let column: Element | null = null;
442
+ const align = alignOf(ac.alignTo);
443
+ /** The field and the content column, read once per opening (styles and boxes, before our writes). */
444
+ const lineUp = (i: HTMLInputElement) => {
445
+ field = fieldOf(i, align.field);
446
+ column = light ? null : columnOf(field, align.column);
447
+ };
352
448
 
353
449
  /** Where the field is (null: hidden), read before anything of ours is written in the task. */
354
450
  const measure = () => {
@@ -356,7 +452,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
356
452
  // checkVisibility: not in Safari before 17.4.
357
453
  if (!input.isConnected || input.getBoundingClientRect().width === 0 || (input.checkVisibility && !input.checkVisibility({ checkVisibilityCSS: true }))) return null;
358
454
  const at = (field?.isConnected ? field : input).getBoundingClientRect();
359
- return { r: at.width ? at : input.getBoundingClientRect(), vw: document.documentElement.clientWidth };
455
+ const col = column?.isConnected ? column.getBoundingClientRect() : null;
456
+ return { r: at.width ? at : input.getBoundingClientRect(), vw: document.documentElement.clientWidth, col: col?.width ? col : null };
360
457
  };
361
458
  /**
362
459
  * Where the dropdown is, every frame while open; `frame`: from the frame
@@ -368,26 +465,34 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
368
465
  if (!open || !input) return;
369
466
  const s = host!.style;
370
467
  if (sheet) {
371
- // Full screen: what shows of the page (the visual viewport, above the
372
- // on-screen keyboard), so the sticky "Show all" sits above the keys.
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.
373
474
  const vv = window.visualViewport;
374
- s.top = `${Math.round(vv?.offsetTop ?? 0)}px`;
475
+ const top = Math.round(vv?.offsetTop ?? 0);
476
+ const h = Math.round(vv?.height ?? innerHeight);
477
+ s.top = `${top}px`;
375
478
  s.left = `${Math.round(vv?.offsetLeft ?? 0)}px`;
376
479
  s.width = `${Math.round(vv?.width ?? innerWidth)}px`;
377
- s.height = `${Math.round(vv?.height ?? innerHeight)}px`;
480
+ s.height = `${Math.max(h, innerHeight - top)}px`;
481
+ s.setProperty('--lbx-vh', `${h}px`);
482
+ host!.toggleAttribute('data-lbx-short', h < SHEET_SHORT);
378
483
  raf = requestAnimationFrame(() => position(true));
379
484
  return;
380
485
  }
381
486
  const got = m === undefined ? measure() : m;
382
487
  if (!got) return close('hidden');
383
- const { r, vw } = got;
488
+ const { r, vw, col } = got;
384
489
  s.top = `${Math.round(r.bottom + 4)}px`;
385
490
  if (light) {
386
491
  const { left, width } = dropdownBox(r, vw);
387
492
  s.left = `${Math.round(left)}px`;
388
493
  s.width = `${Math.round(width)}px`;
389
494
  } else {
390
- const p = panelBox(r, vw, listOnly);
495
+ const p = panelBox(r, vw, listOnly, col);
391
496
  s.left = `${Math.round(p.left)}px`;
392
497
  s.width = `${Math.round(p.width)}px`;
393
498
  s.setProperty('--lbx-caret', `${Math.round(p.caret)}px`);
@@ -450,7 +555,9 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
450
555
  * (not for a row the pointer is over: `say` false).
451
556
  */
452
557
  const markActive = (say = true) => {
453
- const row = active >= 0 ? (box.getRootNode() as Document | ShadowRoot).getElementById(`lbx-ac-${active}`) : null;
558
+ const all = active === ALL ? allLink() : null;
559
+ if (all && !all.id) all.id = idOf(ALL);
560
+ const row = all ?? (active >= 0 ? (box.getRootNode() as Document | ShadowRoot).getElementById(idOf(active)) : null);
454
561
  if (sheet && mirror) {
455
562
  if (row) mirror.setAttribute('aria-activedescendant', row.id);
456
563
  else mirror.removeAttribute('aria-activedescendant');
@@ -472,7 +579,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
472
579
  // The field and the site's colours: read after the focus's frame (prepare), else now.
473
580
  // Reads first (the field, where it is, the colours), then our writes.
474
581
  const fresh = prepared !== input;
475
- if (fresh) field = fieldOf(input!, ac.alignTo);
582
+ if (fresh) lineUp(input!);
476
583
  const m = measure();
477
584
  if (fresh && !light) sampleTokens(input!, field!);
478
585
  prepared = null;
@@ -503,8 +610,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
503
610
  const prepare = (i: HTMLInputElement) =>
504
611
  afterFrame(() => {
505
612
  if (open || input !== i || !i.isConnected || sheet) return;
506
- field = fieldOf(i, ac.alignTo);
507
- if (!light) sampleTokens(i, field);
613
+ lineUp(i);
614
+ if (!light) sampleTokens(i, field!);
508
615
  prepared = i;
509
616
  place(); // after the reads: it dirties the page's style
510
617
  rehearse();
@@ -585,7 +692,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
585
692
  st.setProperty('visibility', 'hidden');
586
693
  st.top = '0px';
587
694
  st.left = '0px';
588
- st.width = `${Math.round(panelBox(m.r, m.vw).width)}px`;
695
+ st.width = `${Math.round(panelBox(m.r, m.vw, false, m.col).width)}px`;
589
696
  st.setProperty('display', 'block', 'important');
590
697
  }
591
698
  }
@@ -622,8 +729,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
622
729
  if (!open) {
623
730
  sheet = true;
624
731
  tab = '';
732
+ before = i.value;
625
733
  place();
626
734
  sampleTokens(i, i);
735
+ // Below the visible part, its background (position).
736
+ host!.style.background = 'var(--lbx-c-bg, var(--lbx-bg))';
737
+ // What the site's input holds now, not what the sheet's held when it last
738
+ // closed (letters typed into the site's input before the hand-off, a
739
+ // value the site's page set since).
740
+ if (mirror) mirror.value = i.value;
627
741
  host!.style.setProperty('display', 'block', 'important');
628
742
  host!.showPopover();
629
743
  open = true;
@@ -640,11 +754,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
640
754
  }
641
755
  if (mirror) {
642
756
  mirror.focus();
643
- // The caret or selection the site's input has (where a tap put it, all
644
- // of it after a Tab): typing goes on as it would have there.
757
+ // The caret or selection the site's input has (all of it after a Tab):
758
+ // typing goes on as it would have there. A caret at its start is a tap's
759
+ // focus not yet placed (Chromium places it after the focus): the end.
760
+ // Bug: a tap on "helm" then "fietstas" typed "fietstashelm".
645
761
  const end = mirror.value.length;
762
+ let [from, to] = [Math.min(i.selectionStart ?? end, end), Math.min(i.selectionEnd ?? end, end)];
763
+ if (!from && !to) from = to = end;
646
764
  try {
647
- mirror.setSelectionRange(Math.min(i.selectionStart ?? end, end), Math.min(i.selectionEnd ?? end, end));
765
+ mirror.setSelectionRange(from, to);
648
766
  } catch {}
649
767
  }
650
768
  query(mirror?.value ?? i.value);
@@ -715,8 +833,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
715
833
  entry = false; // left in history (the same URL) unless Back took it
716
834
  setCss('ac:sheet', null);
717
835
  host!.style.height = '';
836
+ host!.style.background = 'transparent';
837
+ host!.style.removeProperty('--lbx-vh');
838
+ host!.removeAttribute('data-lbx-short');
718
839
  mirror?.blur();
719
840
  if (focused() === input) input?.blur();
841
+ // Cancelled: the site's input as it was (what the page still shows
842
+ // results for). Bug: the sheet's back arrow left "helmfietstas" in the
843
+ // site's input over a page of results for "helm".
844
+ if (reason === 'back' && input && input.value !== before) setValue(input, before);
720
845
  }
721
846
  aria?.set('aria-expanded', 'false');
722
847
  aria?.set('aria-activedescendant', null);
@@ -760,7 +885,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
760
885
  : sheet
761
886
  ? [...phrases, ...(tab === PRODUCT ? products : byType(tab))].map((x) => x.i)
762
887
  : [...phrases, ...lists.flatMap((g) => g.rows), ...(wide && hero ? [hero] : []), ...grid].map((x) => x.i);
763
- if (active >= 0 && !order.includes(active)) active = -1;
888
+ if ((active >= 0 && !order.includes(active)) || (active === ALL && sheet)) active = -1;
764
889
  const phoneNumber = t('help.phone');
765
890
  const help = phoneNumber && phoneNumber !== 'help.phone' ? { title: t('help.title'), phone: phoneNumber, tel: phoneNumber.replace(/[^\d+]/g, ''), hours: t('help.hours').replace(/^help\.hours$/, '') } : null;
766
891
  const value = (drawnValue = mirror?.value ?? input?.value ?? '');
@@ -785,7 +910,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
785
910
  lists,
786
911
  hero: sheet || wide ? hero : null,
787
912
  products: grid,
788
- tabs: kinds.map((kind) => ({ kind, label: t(`types.${kind}`), selected: kind === tab, select: () => ((tab = kind), active >= 0 && (active = -1), render(), markActive()) })),
913
+ tabs: kinds.map((kind) => ({ kind, label: t(`types.${kind}`), selected: kind === tab, select: () => ((tab = kind), active >= 0 && (active = -1), toTop(), render(), markActive()) })),
789
914
  tab,
790
915
  empty: { tries, browse, fallback, help },
791
916
  hover,
@@ -819,6 +944,10 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
819
944
  },
820
945
  back: () => leave('back'),
821
946
  });
947
+ // "Show all products" as the arrow keys' selection: lit as when it has the focus (its CSS: [data-lbx-active]).
948
+ const all = box.querySelector('[data-lbx-all]');
949
+ if (all && all.hasAttribute('data-lbx-active') !== (active === ALL)) all.toggleAttribute('data-lbx-active', active === ALL);
950
+ if (active === ALL && !all) active = -1;
822
951
  };
823
952
 
824
953
  /**
@@ -1061,6 +1190,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1061
1190
  active = sel ? items.findIndex((r) => r.type === sel.type && r.id === sel.id) : -1;
1062
1191
  lastQuery = q;
1063
1192
  listOnly = results.length > 0 && !results.some((r) => r.type === PRODUCT);
1193
+ toTop();
1064
1194
  // The sheet takes what arrives while its input has let go (the keyboard put away to read).
1065
1195
  if (!sheet && focused() !== input) return;
1066
1196
  // The first opening with an onOpen (the site's own code: one storefront opens its
@@ -1100,14 +1230,18 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1100
1230
  const typed = () => {
1101
1231
  const i = input;
1102
1232
  if (!i || !mirror || i.value === mirror.value) return;
1233
+ setValue(i, mirror.value);
1234
+ };
1235
+ /** A value into the site's input as typing would put it (an `input` event), which ours ignores. */
1236
+ function setValue(i: HTMLInputElement, value: string) {
1103
1237
  suppressInput = true;
1104
1238
  try {
1105
- Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(i, mirror.value);
1239
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(i, value);
1106
1240
  i.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
1107
1241
  } finally {
1108
1242
  suppressInput = false;
1109
1243
  }
1110
- };
1244
+ }
1111
1245
  const handToHost = (from: HTMLInputElement, q: string) => {
1112
1246
  // A host that re-rendered its header meanwhile: its input as it is now.
1113
1247
  const i = from.isConnected ? from : (document.querySelector(selector) as HTMLInputElement | null) ?? from;
@@ -1123,30 +1257,47 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1123
1257
  if (notCancelled && i.form) i.form.requestSubmit();
1124
1258
  };
1125
1259
 
1260
+ /**
1261
+ * A suggestion followed (a click on it, core/tiles.ts). Not a
1262
+ * query suggestion (the search it starts is the API's), nor a "No results"
1263
+ * link of the configuration: the fallback strip's products are clicks.
1264
+ */
1265
+ const track = (i: number) => {
1266
+ const r = items[i];
1267
+ const kind = (r as any)?.__empty;
1268
+ if (r && r.type !== 'query' && (!kind || kind === 'fallback')) picked(r, i, 'lbx_autocomplete', ctx.global);
1269
+ };
1270
+
1126
1271
  function choose(idx: number) {
1127
1272
  const r = items[idx];
1128
1273
  if (!r) return;
1129
1274
  if (r.type === 'query') submitViaHost(plain(r.c.title));
1130
1275
  else if (safeUrl(r.c.url)) {
1131
1276
  const url = safeUrl(r.c.url);
1277
+ track(idx);
1132
1278
  hook('onSelect', ac.onSelect, (r as any).__empty ? null : r);
1133
1279
  leave('select', () => location.assign(url));
1134
1280
  }
1135
1281
  }
1136
1282
 
1137
- /** The arrow keys: the next option in `order`, or back to the input (-1) past either end. */
1283
+ /**
1284
+ * The arrow keys: the next option in `order`, then "Show all products" (the
1285
+ * dropdown's, not the sheet's), then back to the input (-1); Arrow Up the
1286
+ * other way round.
1287
+ */
1138
1288
  const step = (by: 1 | -1) => {
1139
- const n = order.length + 1; // positions: -1 (back in input) .. order.length-1
1140
- const at = order.indexOf(active);
1289
+ const stops = !sheet && allLink() ? [...order, ALL] : order;
1290
+ const n = stops.length + 1; // positions: -1 (back in input) .. stops.length-1
1291
+ const at = stops.indexOf(active);
1141
1292
  const next = ((at + by + 1 + n) % n) - 1;
1142
- active = next < 0 ? -1 : order[next];
1293
+ active = next < 0 ? -1 : stops[next];
1143
1294
  byPointer = false;
1144
1295
  render();
1145
1296
  // Into view once the frame with it has rendered (layout clean then): in
1146
1297
  // the key's task, right after our render, it forced a style and layout of
1147
1298
  // the page (most of typing's forced layout; the key's response waited).
1148
1299
  const row = markActive();
1149
- if (row) afterFrame(() => row.isConnected && row.id === `lbx-ac-${active}` && row.scrollIntoView({ block: 'nearest' }));
1300
+ if (row) afterFrame(() => row.isConnected && row.id === idOf(active) && row.scrollIntoView({ block: 'nearest' }));
1150
1301
  };
1151
1302
  /** The pointer over an option: the same selection the arrow keys move. A
1152
1303
  * touch is not a hover (a tap's pointerenter would leave the row lit). */
@@ -1194,6 +1345,21 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1194
1345
  if (!mirror.value !== !drawnValue) render();
1195
1346
  return;
1196
1347
  }
1348
+ // Typed into the site's input while the sheet has the focus: keys the
1349
+ // phone sent during the hand-off (the tap focused the site's input, the
1350
+ // sheet's took over in that task). Carried into the sheet's input. Bug:
1351
+ // letters typed within a few ms of the tap were lost (the next one typed
1352
+ // into the sheet wrote its value over theirs).
1353
+ if (sheet && mirror && input && target === input && !suppressInput) {
1354
+ if (input.value === mirror.value) return;
1355
+ mirror.value = input.value;
1356
+ try {
1357
+ mirror.setSelectionRange(mirror.value.length, mirror.value.length);
1358
+ } catch {}
1359
+ query(mirror.value);
1360
+ if (!mirror.value !== !drawnValue) render();
1361
+ return;
1362
+ }
1197
1363
  // Not one being typed into: a value set for the visitor (search/in-place.ts
1198
1364
  // on back/forward) asks for nothing; a focus later asks for what it holds.
1199
1365
  if (suppressInput || !matches(target) || target !== focused()) return;
@@ -1305,7 +1471,10 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1305
1471
  consume(e); // also keeps the host's hidden suggestion list from moving
1306
1472
  step(e.key === 'ArrowDown' ? 1 : -1);
1307
1473
  } else if (e.key === 'Enter') {
1308
- if (active >= 0) {
1474
+ if (active === ALL) {
1475
+ consume(e);
1476
+ submitAll();
1477
+ } else if (active >= 0) {
1309
1478
  consume(e); // our selection wins over the host's Enter handler
1310
1479
  choose(active);
1311
1480
  } else if (sheet) {
@@ -1324,6 +1493,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1324
1493
  const all = allLink();
1325
1494
  if (!all) return;
1326
1495
  consume(e);
1496
+ // The one thing lit: the options' selection goes (its focus ring shows where Tab went).
1497
+ if (active !== -1) {
1498
+ active = -1;
1499
+ render();
1500
+ markActive(false);
1501
+ }
1327
1502
  all.focus();
1328
1503
  }
1329
1504
  });
@@ -1359,7 +1534,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1359
1534
  close('navigate');
1360
1535
  });
1361
1536
 
1362
- hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && (field = fieldOf(input, ac.alignTo)), position()) });
1537
+ hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && lineUp(input), position()) });
1363
1538
 
1364
1539
  // The interaction that loaded us has already happened: pick it up.
1365
1540
  const now = focused();
@@ -36,8 +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 fills the host (sized to the visual viewport); the panel is as tall as it is. */
40
- .lbx-root { height: 100%; }
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%); }
41
42
  /* What the rules read: the tokens, or the high-contrast palette while it is on. */
42
43
  .box, .sheet {
43
44
  --c-fg: var(--lbx-c-fg, var(--lbx-fg));
@@ -105,14 +106,15 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
105
106
  .clamp, .card .t, .mproduct .t, .compact .t { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
106
107
 
107
108
  /* The top product: the whole card is the option. */
108
- .hero { flex: 1; flex-direction: column; align-items: stretch; gap: 12px; padding: 10px; margin: -10px; border-radius: var(--lbx-radius-row); }
109
+ .hero { flex: 1; flex-direction: column; align-items: stretch; justify-content: flex-start; gap: 8px; padding: 10px; margin: -10px; border-radius: var(--lbx-radius-row); }
109
110
  .pic { position: relative; height: 230px; display: flex; align-items: center; justify-content: center; border-radius: var(--lbx-radius-tile); background: var(--lbx-soft); overflow: hidden; }
110
111
  .v-spec .pic { height: 120px; }
111
112
  .badge { position: absolute; top: 10px; left: 10px; height: 24px; padding: 0 10px; display: flex; align-items: center; border-radius: 999px; background: var(--c-fill); color: var(--c-on-fill); font-size: 12px; font-weight: 700; }
112
113
  .hero .t { font-size: 17px; font-weight: 700; }
113
114
  .hero .stock { font-size: 13px; gap: 8px; }
114
115
  .hero .dot { width: 8px; height: 8px; }
115
- .hero .prices { margin-top: auto; flex-direction: column-reverse; align-items: flex-start; gap: 0; }
116
+ /* Under the title, not at the column's foot; the old price first, as on the cards. */
117
+ .hero .prices { margin-top: 4px; align-items: baseline; gap: 8px; }
116
118
  .hero .now { font-size: 26px; letter-spacing: -.01em; }
117
119
  .hero .old { font-size: 13px; }
118
120
  .specs { display: flex; flex-direction: column; border-top: 1px solid var(--c-line); }
@@ -136,7 +138,7 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
136
138
  /* "Show all products": the PRODUCTS column's foot, outlined; filled when focused or hovered. Outside the listbox. */
137
139
  .all { display: flex; align-items: center; justify-content: center; gap: 8px; height: 46px; border-radius: var(--lbx-radius-row); box-shadow: inset 0 0 0 1.5px var(--c-fill);
138
140
  color: var(--c-accent); font-size: 15px; font-weight: 700; background: var(--c-bg); cursor: pointer; }
139
- .all:hover, .all:focus-visible { background: var(--c-fill); color: var(--c-on-fill); }
141
+ .all:hover, .all:focus-visible, .all[data-lbx-active] { background: var(--c-fill); color: var(--c-on-fill); }
140
142
  .box .all { position: absolute; right: 24px; bottom: 20px; left: 24px; }
141
143
  .box.has-list .all { left: 304px; }
142
144
  .box.has-hero .all { left: 334px; }
@@ -208,6 +210,10 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
208
210
  .mhero .t { font-size: 16px; line-height: 1.3; font-weight: 700; }
209
211
  .mhero .stock, .mproduct .stock { font-size: 13px; }
210
212
  .mhero .now { font-size: 22px; }
213
+ /* The keyboard up (a short sheet): a smaller top product, more of the list in view. */
214
+ :host([data-lbx-short]) .mhero .tile { width: 72px; height: 72px; }
215
+ :host([data-lbx-short]) .m-hero { padding: 12px 16px; gap: 8px; }
216
+ :host([data-lbx-short]) .mhero .now { font-size: 18px; }
211
217
  .mproduct { padding: 12px 16px; gap: 14px; border-bottom: 1px solid var(--c-line); }
212
218
  .mproduct .tile { width: 72px; height: 72px; }
213
219
  .mproduct .t { font-size: 15px; font-weight: 700; }
@@ -34,7 +34,7 @@ export default (s: AutocompleteScope) => {
34
34
  <button type="button" class="icon back" aria-label=${s.t('closeSearch')} @click=${() => s.back()}>
35
35
  <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>
36
36
  </button>
37
- <input class="sh-input" data-lbx-sheet-input type="text" enterkeyhint="search" autocomplete="off" autocapitalize="off" spellcheck="false"
37
+ <input class="sh-input" data-lbx-sheet-input type="text" enterkeyhint="search" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
38
38
  role="combobox" aria-autocomplete="list" aria-expanded=${controls ? 'true' : 'false'} aria-controls=${controls || nothing}
39
39
  aria-label=${s.inputLabel} placeholder=${s.placeholder} .value=${s.value} />
40
40
  ${s.value