@luigisbox/luma 0.2.0 → 0.4.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.
@@ -47,10 +47,23 @@ const PANEL_MAX = 1200;
47
47
  const PANEL_LIST = 560;
48
48
  const PANEL_GUTTER = 24;
49
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}`);
50
56
  /** Products beside the top product on the panel (2 x 3). */
51
57
  const GRID_MAX = 6;
52
- /** Phones (the viewport up to this width): the house design is a full-screen sheet. */
53
- const SHEET_MEDIA = '(max-width: 600px)';
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;
54
67
 
55
68
  /** A computed colour that paints nothing (`rgba(0, 0, 0, 0)`, `transparent`, `color(srgb 0 0 0 / 0)`). */
56
69
  const clear = (c: string) => !c || c === 'transparent' || /(?:,|\/)\s*0(?:\.0*)?\)$/.test(c);
@@ -129,14 +142,58 @@ export const dropdownBox = (r: { left: number; right: number; width: number }, v
129
142
  * for a batch without products), centred under the field, kept 24 px from
130
143
  * the screen's edges; its caret under the field's left edge + 30 px (inside
131
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.
132
150
  */
133
- export const panelBox = (r: { left: number; width: number }, vw: number, listOnly = false) => {
134
- const width = Math.max(0, Math.min(listOnly ? PANEL_LIST : PANEL_MAX, vw - 2 * PANEL_GUTTER));
135
- const left = Math.max(PANEL_GUTTER, Math.min(r.left + r.width / 2 - width / 2, vw - PANEL_GUTTER - width));
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));
136
156
  const caret = Math.max(16, Math.min(r.left + 30 - left, width - 30));
137
157
  return { left, width, caret, wide: width >= PANEL_WIDE };
138
158
  };
139
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
+
140
197
  /** The house tokens a config may set (`autocomplete.tokens`), as their custom properties. */
141
198
  const TOKEN_PROPS: Record<string, string> = {
142
199
  font: 'font',
@@ -187,6 +244,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
187
244
  /** The arrow keys' order over `items` (the house design's reading order: pills, lists, the top product, products). */
188
245
  let order: number[] = [];
189
246
  let lastQuery = '';
247
+ /** The arrow keys' selection: an option of `items`, -1 none (the input), ALL "Show all products". */
190
248
  let active = -1;
191
249
  let open = false;
192
250
  let raf = 0;
@@ -195,6 +253,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
195
253
  let quietFocus = false;
196
254
  /** The phrase the dropdown is for (normalised input), '' while closed by the visitor or below minChars. */
197
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
+ };
198
262
  /** Per type, what the dropdown shows. */
199
263
  const shown = new Map<string, Result[]>();
200
264
  /** The house panel: wide enough for the top product's column; a batch without products (the list alone, narrower). */
@@ -206,6 +270,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
206
270
  let tab = '';
207
271
  let entry = false;
208
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 = '';
209
275
  // Accessibility (docs/ACCESSIBILITY.md, "Autocomplete"): the host's input is
210
276
  // the combobox. Its ARIA is borrowed while it has focus and put back when
211
277
  // focus leaves it; the result count is announced politely, once typing pauses.
@@ -319,6 +385,20 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
319
385
  backToInput();
320
386
  }
321
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
+ );
322
402
  // Focus leaving the dropdown for anywhere but the input: closed, as when
323
403
  // it leaves the input. Not the sheet: it covers the page, and its input
324
404
  // lets go whenever the shopper puts the keyboard away to read.
@@ -357,6 +437,14 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
357
437
 
358
438
  /** What the dropdown lines up with for this opening (`alignTo`): the visible search field, else the input. */
359
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
+ };
360
448
 
361
449
  /** Where the field is (null: hidden), read before anything of ours is written in the task. */
362
450
  const measure = () => {
@@ -364,7 +452,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
364
452
  // checkVisibility: not in Safari before 17.4.
365
453
  if (!input.isConnected || input.getBoundingClientRect().width === 0 || (input.checkVisibility && !input.checkVisibility({ checkVisibilityCSS: true }))) return null;
366
454
  const at = (field?.isConnected ? field : input).getBoundingClientRect();
367
- 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 };
368
457
  };
369
458
  /**
370
459
  * Where the dropdown is, every frame while open; `frame`: from the frame
@@ -376,26 +465,34 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
376
465
  if (!open || !input) return;
377
466
  const s = host!.style;
378
467
  if (sheet) {
379
- // Full screen: what shows of the page (the visual viewport, above the
380
- // on-screen keyboard), so the sticky "Show all" sits above the keys.
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.
381
474
  const vv = window.visualViewport;
382
- 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`;
383
478
  s.left = `${Math.round(vv?.offsetLeft ?? 0)}px`;
384
479
  s.width = `${Math.round(vv?.width ?? innerWidth)}px`;
385
- 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);
386
483
  raf = requestAnimationFrame(() => position(true));
387
484
  return;
388
485
  }
389
486
  const got = m === undefined ? measure() : m;
390
487
  if (!got) return close('hidden');
391
- const { r, vw } = got;
488
+ const { r, vw, col } = got;
392
489
  s.top = `${Math.round(r.bottom + 4)}px`;
393
490
  if (light) {
394
491
  const { left, width } = dropdownBox(r, vw);
395
492
  s.left = `${Math.round(left)}px`;
396
493
  s.width = `${Math.round(width)}px`;
397
494
  } else {
398
- const p = panelBox(r, vw, listOnly);
495
+ const p = panelBox(r, vw, listOnly, col);
399
496
  s.left = `${Math.round(p.left)}px`;
400
497
  s.width = `${Math.round(p.width)}px`;
401
498
  s.setProperty('--lbx-caret', `${Math.round(p.caret)}px`);
@@ -458,7 +555,9 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
458
555
  * (not for a row the pointer is over: `say` false).
459
556
  */
460
557
  const markActive = (say = true) => {
461
- 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);
462
561
  if (sheet && mirror) {
463
562
  if (row) mirror.setAttribute('aria-activedescendant', row.id);
464
563
  else mirror.removeAttribute('aria-activedescendant');
@@ -480,7 +579,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
480
579
  // The field and the site's colours: read after the focus's frame (prepare), else now.
481
580
  // Reads first (the field, where it is, the colours), then our writes.
482
581
  const fresh = prepared !== input;
483
- if (fresh) field = fieldOf(input!, ac.alignTo);
582
+ if (fresh) lineUp(input!);
484
583
  const m = measure();
485
584
  if (fresh && !light) sampleTokens(input!, field!);
486
585
  prepared = null;
@@ -511,8 +610,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
511
610
  const prepare = (i: HTMLInputElement) =>
512
611
  afterFrame(() => {
513
612
  if (open || input !== i || !i.isConnected || sheet) return;
514
- field = fieldOf(i, ac.alignTo);
515
- if (!light) sampleTokens(i, field);
613
+ lineUp(i);
614
+ if (!light) sampleTokens(i, field!);
516
615
  prepared = i;
517
616
  place(); // after the reads: it dirties the page's style
518
617
  rehearse();
@@ -593,7 +692,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
593
692
  st.setProperty('visibility', 'hidden');
594
693
  st.top = '0px';
595
694
  st.left = '0px';
596
- 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`;
597
696
  st.setProperty('display', 'block', 'important');
598
697
  }
599
698
  }
@@ -630,8 +729,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
630
729
  if (!open) {
631
730
  sheet = true;
632
731
  tab = '';
732
+ before = i.value;
633
733
  place();
634
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;
635
741
  host!.style.setProperty('display', 'block', 'important');
636
742
  host!.showPopover();
637
743
  open = true;
@@ -648,11 +754,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
648
754
  }
649
755
  if (mirror) {
650
756
  mirror.focus();
651
- // The caret or selection the site's input has (where a tap put it, all
652
- // of it after a Tab): typing goes on as it would have there.
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".
653
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;
654
764
  try {
655
- mirror.setSelectionRange(Math.min(i.selectionStart ?? end, end), Math.min(i.selectionEnd ?? end, end));
765
+ mirror.setSelectionRange(from, to);
656
766
  } catch {}
657
767
  }
658
768
  query(mirror?.value ?? i.value);
@@ -723,8 +833,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
723
833
  entry = false; // left in history (the same URL) unless Back took it
724
834
  setCss('ac:sheet', null);
725
835
  host!.style.height = '';
836
+ host!.style.background = 'transparent';
837
+ host!.style.removeProperty('--lbx-vh');
838
+ host!.removeAttribute('data-lbx-short');
726
839
  mirror?.blur();
727
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);
728
845
  }
729
846
  aria?.set('aria-expanded', 'false');
730
847
  aria?.set('aria-activedescendant', null);
@@ -768,7 +885,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
768
885
  : sheet
769
886
  ? [...phrases, ...(tab === PRODUCT ? products : byType(tab))].map((x) => x.i)
770
887
  : [...phrases, ...lists.flatMap((g) => g.rows), ...(wide && hero ? [hero] : []), ...grid].map((x) => x.i);
771
- if (active >= 0 && !order.includes(active)) active = -1;
888
+ if ((active >= 0 && !order.includes(active)) || (active === ALL && sheet)) active = -1;
772
889
  const phoneNumber = t('help.phone');
773
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;
774
891
  const value = (drawnValue = mirror?.value ?? input?.value ?? '');
@@ -793,7 +910,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
793
910
  lists,
794
911
  hero: sheet || wide ? hero : null,
795
912
  products: grid,
796
- 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()) })),
797
914
  tab,
798
915
  empty: { tries, browse, fallback, help },
799
916
  hover,
@@ -827,6 +944,10 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
827
944
  },
828
945
  back: () => leave('back'),
829
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;
830
951
  };
831
952
 
832
953
  /**
@@ -1069,6 +1190,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1069
1190
  active = sel ? items.findIndex((r) => r.type === sel.type && r.id === sel.id) : -1;
1070
1191
  lastQuery = q;
1071
1192
  listOnly = results.length > 0 && !results.some((r) => r.type === PRODUCT);
1193
+ toTop();
1072
1194
  // The sheet takes what arrives while its input has let go (the keyboard put away to read).
1073
1195
  if (!sheet && focused() !== input) return;
1074
1196
  // The first opening with an onOpen (the site's own code: one storefront opens its
@@ -1108,14 +1230,18 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1108
1230
  const typed = () => {
1109
1231
  const i = input;
1110
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) {
1111
1237
  suppressInput = true;
1112
1238
  try {
1113
- Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(i, mirror.value);
1239
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(i, value);
1114
1240
  i.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
1115
1241
  } finally {
1116
1242
  suppressInput = false;
1117
1243
  }
1118
- };
1244
+ }
1119
1245
  const handToHost = (from: HTMLInputElement, q: string) => {
1120
1246
  // A host that re-rendered its header meanwhile: its input as it is now.
1121
1247
  const i = from.isConnected ? from : (document.querySelector(selector) as HTMLInputElement | null) ?? from;
@@ -1154,19 +1280,24 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1154
1280
  }
1155
1281
  }
1156
1282
 
1157
- /** 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
+ */
1158
1288
  const step = (by: 1 | -1) => {
1159
- const n = order.length + 1; // positions: -1 (back in input) .. order.length-1
1160
- const at = order.indexOf(active);
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);
1161
1292
  const next = ((at + by + 1 + n) % n) - 1;
1162
- active = next < 0 ? -1 : order[next];
1293
+ active = next < 0 ? -1 : stops[next];
1163
1294
  byPointer = false;
1164
1295
  render();
1165
1296
  // Into view once the frame with it has rendered (layout clean then): in
1166
1297
  // the key's task, right after our render, it forced a style and layout of
1167
1298
  // the page (most of typing's forced layout; the key's response waited).
1168
1299
  const row = markActive();
1169
- 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' }));
1170
1301
  };
1171
1302
  /** The pointer over an option: the same selection the arrow keys move. A
1172
1303
  * touch is not a hover (a tap's pointerenter would leave the row lit). */
@@ -1214,6 +1345,21 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1214
1345
  if (!mirror.value !== !drawnValue) render();
1215
1346
  return;
1216
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
+ }
1217
1363
  // Not one being typed into: a value set for the visitor (search/in-place.ts
1218
1364
  // on back/forward) asks for nothing; a focus later asks for what it holds.
1219
1365
  if (suppressInput || !matches(target) || target !== focused()) return;
@@ -1325,7 +1471,10 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1325
1471
  consume(e); // also keeps the host's hidden suggestion list from moving
1326
1472
  step(e.key === 'ArrowDown' ? 1 : -1);
1327
1473
  } else if (e.key === 'Enter') {
1328
- if (active >= 0) {
1474
+ if (active === ALL) {
1475
+ consume(e);
1476
+ submitAll();
1477
+ } else if (active >= 0) {
1329
1478
  consume(e); // our selection wins over the host's Enter handler
1330
1479
  choose(active);
1331
1480
  } else if (sheet) {
@@ -1344,6 +1493,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1344
1493
  const all = allLink();
1345
1494
  if (!all) return;
1346
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
+ }
1347
1502
  all.focus();
1348
1503
  }
1349
1504
  });
@@ -1379,7 +1534,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1379
1534
  close('navigate');
1380
1535
  });
1381
1536
 
1382
- 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()) });
1383
1538
 
1384
1539
  // The interaction that loaded us has already happened: pick it up.
1385
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