@luigisbox/luma 0.5.0 → 0.7.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.
@@ -18,7 +18,7 @@ import { borrow, liveRegion, useContrast } from '../../core/a11y';
18
18
  import { luminanceOf, readable } from '../../core/color';
19
19
  import { defaultSubmitter, formDataOf } from '../../core/default-button';
20
20
  import { onOwnBack } from '../../core/entries';
21
- import { AC_HOOKS, ANALYTICS, QUERY_URL } from '../../core/features';
21
+ import { AC_HOOKS, ANALYTICS, LIGHT_FULL_SCREEN, QUERY_URL } from '../../core/features';
22
22
  import { mark, reportError } from '../../core/health';
23
23
  import { modalOf } from '../../core/own';
24
24
  import { byName, view } from '../../core/templates';
@@ -66,6 +66,12 @@ const GRID_MAX = 6;
66
66
  export const SHEET_MEDIA = '(max-width: 600px), (hover: none) and (max-height: 500px)';
67
67
  /** The sheet's visible height below which its top product is drawn smaller (the keyboard up): more of the list shows. */
68
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;
69
75
 
70
76
  /** A computed colour that paints nothing (`rgba(0, 0, 0, 0)`, `transparent`, `color(srgb 0 0 0 / 0)`). */
71
77
  const clear = (c: string) => !c || c === 'transparent' || /(?:,|\/)\s*0(?:\.0*)?\)$/.test(c);
@@ -257,7 +263,13 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
257
263
  let want = '';
258
264
  /** Scrolled lists in the dropdown (the panel's columns, the sheet's list): back to their top for a new result set. */
259
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;
260
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
+ // A light-DOM sheet's list is the host: whichever of its elements scrolls.
272
+ if (swipe && (LIGHT_FULL_SCREEN && light ? [...scrolled].some((el) => swipe!.list.contains(el)) : scrolled.has(swipe.list))) swipe.ours = true;
261
273
  for (const el of scrolled) if (el.isConnected) el.scrollTop = 0;
262
274
  scrolled.clear();
263
275
  };
@@ -292,8 +304,13 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
292
304
  }
293
305
  };
294
306
 
295
- /** The house design's phone sheet applies here (shadow DOM, a phone's viewport). */
296
- const phone = () => !light && typeof matchMedia === 'function' && matchMedia(SHEET_MEDIA).matches;
307
+ /**
308
+ * Where the phone sheet applies (a media query): the house design's on a
309
+ * phone's viewport; a light-DOM integration's only with `fullScreen` (its own
310
+ * markup: `true` on a phone's viewport, or its own query), never without.
311
+ */
312
+ const sheetMedia = !light ? SHEET_MEDIA : !LIGHT_FULL_SCREEN ? 'not all' : ac.fullScreen === true ? SHEET_MEDIA : ac.fullScreen || 'not all';
313
+ const phone = () => typeof matchMedia === 'function' && matchMedia(sheetMedia).matches;
297
314
 
298
315
  /** Our shadow root on `el` with our CSS; the box templates render into. */
299
316
  const shadowBox = (el: HTMLElement, ...before: Node[]) => {
@@ -323,7 +340,9 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
323
340
  // The UA popover's height: fit-content makes a fixed panel the site pins
324
341
  // with top + bottom (a full-height sheet on phones) grow past the screen
325
342
  // instead of scrolling. Zero specificity: any site rule still wins.
326
- setCss('ac:light', ':where([data-lbx="ac-overlay"]) { height: auto; }');
343
+ // Its width and margin too: a phone sheet the site pins with `inset: 0`
344
+ // fills the screen, not fit-content and centred (the dropdown's width is inline).
345
+ setCss('ac:light', ':where([data-lbx="ac-overlay"]) { height: auto; width: auto; margin: 0; }');
327
346
  ctx.styles.forEach((c, i) => setCss(`itg:autocomplete:${i}`, c));
328
347
  box = host;
329
348
  host.append(live.el); // the templates render after it
@@ -363,8 +382,9 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
363
382
  if (!inOverlay(el) || e.ctrlKey || e.metaKey) return;
364
383
  if (sheet) {
365
384
  if (e.key === 'Escape') {
366
- e.preventDefault();
367
- e.stopPropagation();
385
+ // A whole gesture, as from the input: focus is on the sheet after a swipe (or on its buttons),
386
+ // and its keyup reached the host's handlers. Review (PR 26).
387
+ consume(e);
368
388
  leave('back');
369
389
  } else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && el.getAttribute?.('role') === 'tab') {
370
390
  e.preventDefault();
@@ -400,6 +420,66 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
400
420
  if (!(el instanceof Element)) return;
401
421
  if (el.scrollTop > 0) scrolled.add(el);
402
422
  else scrolled.delete(el);
423
+ if (!swipe || !(LIGHT_FULL_SCREEN && light ? swipe.list.contains(el) : swipe.list === el)) return;
424
+ if (swipe.ours) swipe.ours = false;
425
+ // A light-DOM sheet's sideways band (its tabs) scrolls in it too: only a list that went down.
426
+ else if ((!LIGHT_FULL_SCREEN || !light || el.scrollTop > 0) && (!swipe.cancelled || performance.now() - swipe.cancelled < SWIPE_SCROLL_MS)) putKeyboardAway();
427
+ },
428
+ { capture: true, passive: true },
429
+ );
430
+ // A swipe through the sheet's list (or its no-results column) puts the
431
+ // keyboard away, the sheet stays: it grows to the whole screen (position),
432
+ // and a tap on its input brings the keyboard back. Only a drag: a tap on a
433
+ // row, a tab or "Show all" keeps the keyboard and is not swallowed; the
434
+ // pill band's sideways drag is not the list. Focus goes to the sheet
435
+ // (tabindex -1), not <body>: Escape and Tab still work from a hardware
436
+ // keyboard. Bug (iOS 26 Safari): with the keyboard up, its bar and the
437
+ // address bar above it, the sheet kept half the screen. A light-DOM
438
+ // sheet: its list is the host (whichever of its elements scrolls), and
439
+ // focus goes to the host.
440
+ const putKeyboardAway = () => {
441
+ swipe = null;
442
+ if (!mirror || focused() !== mirror) return;
443
+ const panel = mirror.closest<HTMLElement>('.sheet[tabindex]') ?? (LIGHT_FULL_SCREEN && light ? host : null);
444
+ if (panel) panel.focus({ preventScroll: true });
445
+ else mirror.blur(); // an integration's own sheet template
446
+ };
447
+ const finger = (e: PointerEvent) => e.pointerType === 'touch' && e.isPrimary;
448
+ box.addEventListener(
449
+ 'pointerdown',
450
+ (ev) => {
451
+ const e = ev as PointerEvent;
452
+ swipe = null;
453
+ if (!sheet || !finger(e)) return;
454
+ const path = e.composedPath();
455
+ const list = LIGHT_FULL_SCREEN && light ? (mirror && path.includes(mirror) ? undefined : host!) : (path.find((x) => x instanceof Element && x.matches('.sh-list, .sheet .empty')) as Element | undefined);
456
+ if (list) swipe = { list, x: e.clientX, y: e.clientY, moved: false };
457
+ },
458
+ { capture: true, passive: true },
459
+ );
460
+ box.addEventListener(
461
+ 'pointermove',
462
+ (ev) => {
463
+ const e = ev as PointerEvent;
464
+ if (!swipe || !finger(e)) return;
465
+ const dy = Math.abs(e.clientY - swipe.y);
466
+ if (dy <= Math.abs(e.clientX - swipe.x)) return;
467
+ if (dy > SWIPE) putKeyboardAway();
468
+ else if (dy >= SWIPE_START) swipe.moved = true;
469
+ },
470
+ { capture: true, passive: true },
471
+ );
472
+ box.addEventListener('pointerup', (e) => finger(e as PointerEvent) && (swipe = null), { capture: true, passive: true });
473
+ // The browser took the gesture over to scroll: after a first move (iOS
474
+ // Safari: a pull at the list's top, 10 px, then pointercancel), a swipe;
475
+ // without one, a swipe if the list scrolls right after (a flick), not a
476
+ // long press (a link's preview: no scroll; a later scroll is ours, toTop).
477
+ box.addEventListener(
478
+ 'pointercancel',
479
+ (e) => {
480
+ if (!swipe || !finger(e as PointerEvent)) return;
481
+ if (swipe.moved) putKeyboardAway();
482
+ else swipe.cancelled = performance.now();
403
483
  },
404
484
  { capture: true, passive: true },
405
485
  );
@@ -465,24 +545,51 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
465
545
  * before this task's writes (an opening: read after showing the popover,
466
546
  * it forced a layout).
467
547
  */
548
+ /** What Safari's bars and the keyboard cover of the sheet (px, --lbx-cover), -1 while it is not open. */
549
+ let cover = -1;
550
+ /** A light-DOM sheet's top (px, --lbx-top: the visual viewport's), -1 while it is not open. */
551
+ let sheetTop = -1;
552
+ /** A clamp of the scrolled lists is queued (one for a keyboard going down, not one per frame of it). */
553
+ let clamping = false;
468
554
  const position = (frame = false, m?: ReturnType<typeof measure>) => {
469
555
  if (!open || !input) return;
470
556
  const s = host!.style;
471
557
  if (sheet) {
472
- // Full screen: its content in what shows of the page (the visual
473
- // viewport, above the on-screen keyboard), so the sticky "Show all"
474
- // sits above the keys. The host reaches on down to the layout
475
- // viewport's bottom, painted (openSheet): iOS 26 Safari ends the visual
476
- // viewport above its floating address bar, and between the two the
477
- // page showed through, see-through bars over it.
558
+ // Full screen from the top of what shows of the page (the visual
559
+ // viewport) down to the layout viewport's bottom: iOS 26 Safari ends
560
+ // the visual viewport above its floating bars (the address pill, with
561
+ // the keyboard up its form bar too), and the list runs on behind them,
562
+ // seen through their glass, as the legacy autocomplete's does. What
563
+ // they and the keyboard cover (--lbx-cover) ends the list as space, so
564
+ // its last row and "Show all" scroll clear of them. Bug (iOS 26/27):
565
+ // the list stopped above the bars, a blank strip down to the keys.
566
+ // A light-DOM sheet is placed by the integration's CSS (`position:
567
+ // fixed; inset: 0; top: var(--lbx-top)`): only that top and what is
568
+ // covered (--lbx-cover) are ours.
478
569
  const vv = window.visualViewport;
479
570
  const top = Math.round(vv?.offsetTop ?? 0);
480
571
  const h = Math.round(vv?.height ?? innerHeight);
481
- s.top = `${top}px`;
482
- s.left = `${Math.round(vv?.offsetLeft ?? 0)}px`;
483
- s.width = `${Math.round(vv?.width ?? innerWidth)}px`;
484
- s.height = `${Math.max(h, innerHeight - top)}px`;
485
- s.setProperty('--lbx-vh', `${h}px`);
572
+ const full = Math.max(h, innerHeight - top);
573
+ if (!LIGHT_FULL_SCREEN || !light) {
574
+ s.top = `${top}px`;
575
+ s.left = `${Math.round(vv?.offsetLeft ?? 0)}px`;
576
+ s.width = `${Math.round(vv?.width ?? innerWidth)}px`;
577
+ s.height = `${full}px`;
578
+ } else if (top !== sheetTop) s.setProperty('--lbx-top', `${(sheetTop = top)}px`);
579
+ if (full - h !== cover) {
580
+ // Less covered (the keyboard going down): the end space shrinks, and iOS WebKit kept a list
581
+ // scrolled into it there, its rows stuck above a blank strip, not scrollable back (iOS 27
582
+ // Simulator). Its offset back within the list, read a frame later.
583
+ if (full - h < cover && !clamping) {
584
+ clamping = true;
585
+ afterFrame(() => {
586
+ clamping = false;
587
+ scrolled.forEach((el) => el.scrollTop > el.scrollHeight - el.clientHeight && (el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight)));
588
+ });
589
+ }
590
+ cover = full - h;
591
+ s.setProperty('--lbx-cover', `${cover}px`);
592
+ }
486
593
  host!.toggleAttribute('data-lbx-short', h < SHEET_SHORT);
487
594
  raf = requestAnimationFrame(() => position(true));
488
595
  return;
@@ -543,6 +650,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
543
650
  aria?.restore();
544
651
  aria = null;
545
652
  };
653
+ /** Option `i`'s element, in the root the options render into. */
654
+ const rowOf = (i: number) => (box.getRootNode() as Document | ShadowRoot).getElementById(idOf(i));
546
655
  /**
547
656
  * The selected row (the screen reader's focus stays on the input). An id
548
657
  * reference resolves only in the input's own tree, never across a shadow
@@ -561,7 +670,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
561
670
  const markActive = (say = true) => {
562
671
  const all = active === ALL ? allLink() : null;
563
672
  if (all && !all.id) all.id = idOf(ALL);
564
- const row = all ?? (active >= 0 ? (box.getRootNode() as Document | ShadowRoot).getElementById(idOf(active)) : null);
673
+ const row = all ?? (active >= 0 ? rowOf(active) : null);
565
674
  if (sheet && mirror) {
566
675
  if (row) mirror.setAttribute('aria-activedescendant', row.id);
567
676
  else mirror.removeAttribute('aria-activedescendant');
@@ -724,7 +833,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
724
833
  const idle = (f: () => void) => afterFrame(() => (typeof requestIdleCallback === 'function' ? requestIdleCallback(f, { timeout: 300 }) : setTimeout(f, 0)));
725
834
 
726
835
  /**
727
- * The phone sheet (house design): full screen over the page,
836
+ * The phone sheet (the house design's, a light-DOM integration's with
837
+ * `fullScreen`: its markup, placed by its CSS): full screen over the page,
728
838
  * opened when the site's input gets focus. Its own input takes the focus
729
839
  * at once (in the tap's task: iOS shows its keyboard only for a focus in
730
840
  * the gesture) with what the site's input holds; it is the combobox there.
@@ -739,9 +849,20 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
739
849
  tab = '';
740
850
  before = i.value;
741
851
  place();
742
- sampleTokens(i, i);
743
- // Below the visible part, its background (position).
744
- host!.style.background = 'var(--lbx-c-bg, var(--lbx-bg))';
852
+ if (LIGHT_FULL_SCREEN && light) {
853
+ // The dropdown's inline box (inset: auto, its top, left and width) would beat the integration's CSS.
854
+ // Focusable: a swipe's keyboard put away leaves the focus here (putKeyboardAway), named as the house sheet's panel.
855
+ host!.style.inset = host!.style.width = '';
856
+ host!.tabIndex = -1;
857
+ host!.setAttribute('role', 'group');
858
+ host!.setAttribute('aria-label', labelOf(i));
859
+ // What its CSS keys on (`[data-lbx-full-screen]`): where the sheet opens is Luma's media query, a phone in landscape too.
860
+ host!.setAttribute('data-lbx-full-screen', '');
861
+ } else {
862
+ sampleTokens(i, i);
863
+ // Below the visible part, its background (position).
864
+ host!.style.background = 'var(--lbx-c-bg, var(--lbx-bg))';
865
+ }
745
866
  // What the site's input holds now, not what the sheet's held when it last
746
867
  // closed (letters typed into the site's input before the hand-off, a
747
868
  // value the site's page set since).
@@ -758,7 +879,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
758
879
  cancelAnimationFrame(raf);
759
880
  position();
760
881
  render(); // with what the site's input holds
761
- mirror = (box as ShadowRoot | HTMLElement).querySelector<HTMLInputElement>('[data-lbx-sheet-input]');
882
+ mirror = box.querySelector<HTMLInputElement>('[data-lbx-full-screen-input]');
762
883
  }
763
884
  if (mirror) {
764
885
  mirror.focus();
@@ -803,6 +924,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
803
924
  then?.();
804
925
  return;
805
926
  }
927
+ // Our Back already on its way (a second focus outside, Escape twice): one Back. Review: two left the page in Chromium.
928
+ if (afterBack) return;
806
929
  const done = then ?? (() => {});
807
930
  afterBack = done;
808
931
  pendingReason = reason;
@@ -841,8 +964,14 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
841
964
  entry = false; // left in history (the same URL) unless Back took it
842
965
  setCss('ac:sheet', null);
843
966
  host!.style.height = '';
844
- host!.style.background = 'transparent';
845
- host!.style.removeProperty('--lbx-vh');
967
+ if (LIGHT_FULL_SCREEN && light) {
968
+ host!.style.inset = 'auto'; // the dropdown's again (position sets its top and left)
969
+ host!.style.removeProperty('--lbx-top');
970
+ sheetTop = -1;
971
+ for (const a of ['role', 'aria-label', 'data-lbx-full-screen']) host!.removeAttribute(a);
972
+ } else host!.style.background = 'transparent';
973
+ host!.style.removeProperty('--lbx-cover');
974
+ cover = -1;
846
975
  host!.removeAttribute('data-lbx-short');
847
976
  mirror?.blur();
848
977
  if (focused() === input) input?.blur();
@@ -858,6 +987,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
858
987
  if (AC_HOOKS) hook('onClose', ac.onClose, reason);
859
988
  };
860
989
 
990
+ /** The site's input's name: the sheet's input's (and a light-DOM sheet's own, its panel's). */
991
+ const labelOf = (i: HTMLInputElement | null) => i?.getAttribute('aria-label') || i?.placeholder || t('label');
861
992
  /** The sheet input's value the last render drew (its clear button). */
862
993
  let drawnValue = '';
863
994
  /** Rows as templates get them: the result and its index in `items`. */
@@ -884,7 +1015,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
884
1015
  // The arrow keys' order: pills, the lists in config order, the
885
1016
  // top product, the products in reading order; in the sheet, the pills and
886
1017
  // the open tab's options; with no results, phrases to try, links, picks.
887
- // A light-DOM integration's own markup keeps the config order.
1018
+ // A light-DOM integration's own markup keeps the config order (its sheet:
1019
+ // of the options its markup shows, read at the key: step).
888
1020
  const phrases = byType('query');
889
1021
  order = light
890
1022
  ? rows.map((x) => x.i)
@@ -912,7 +1044,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
912
1044
  submitAll: () => submitAll(),
913
1045
  allHref: searchHref(input, sheet ? (mirror?.value ?? '') : undefined),
914
1046
  state,
915
- sheet,
1047
+ fullScreen: sheet,
916
1048
  wide,
917
1049
  phrases,
918
1050
  lists,
@@ -942,7 +1074,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
942
1074
  .slice(0, 4),
943
1075
  value,
944
1076
  placeholder: input?.placeholder ?? '',
945
- inputLabel: input?.getAttribute('aria-label') || input?.placeholder || t('label'),
1077
+ inputLabel: labelOf(input),
946
1078
  clear: () => {
947
1079
  if (!mirror) return;
948
1080
  mirror.value = '';
@@ -1094,11 +1226,17 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1094
1226
  };
1095
1227
  /** The "No results" layout's options: phrases to try, links to browse, fallback products. */
1096
1228
  const emptyOptions = (fallback: Result[]) => {
1097
- const tag = (r: Result, kind: string) => Object.assign(r, { __empty: kind });
1229
+ // Luma's own options, not catalog hits: their text and link are the
1230
+ // config's, whatever the integration computes for its catalog's.
1231
+ const own = (hit: { '@id': string; '@type': string; '@title': string; url?: string }, kind: string) => {
1232
+ const r = ctx.result(hit);
1233
+ Object.defineProperties(r.c, { title: { value: hit['@title'] }, url: { value: safeUrl(hit.url) } });
1234
+ return Object.assign(r, { __empty: kind });
1235
+ };
1098
1236
  return [
1099
- ...(empty.tryQueries ?? []).filter((q) => typeof q === 'string' && q.trim()).map((q) => tag(ctx.result({ '@id': `try/${q}`, '@type': 'query', '@title': q }), 'try')),
1100
- ...(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')),
1101
- ...fallback.map((r) => tag({ ...r }, 'fallback')),
1237
+ ...(empty.tryQueries ?? []).filter((q) => typeof q === 'string' && q.trim()).map((q) => own({ '@id': `try/${q}`, '@type': 'query', '@title': q }, 'try')),
1238
+ ...(empty.browse ?? []).filter((b) => b && safeUrl(b.url)).map((b) => own({ '@id': `browse/${b.url}`, '@type': 'browse', '@title': b.title ?? '', url: b.url }, 'browse')),
1239
+ ...fallback.map((r) => ({ ...r, __empty: 'fallback' })),
1102
1240
  ];
1103
1241
  };
1104
1242
 
@@ -1325,7 +1463,11 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1325
1463
  * other way round.
1326
1464
  */
1327
1465
  const step = (by: 1 | -1) => {
1328
- const stops = !sheet && allLink() ? [...order, ALL] : order;
1466
+ let stops = !sheet && allLink() ? [...order, ALL] : order;
1467
+ // A light-DOM sheet: the options its markup shows (the open tab's, drawn alone or the others hidden by
1468
+ // its CSS; all of them without tabs), read in response to the key. Review: a template without tabs had
1469
+ // its other types' options out of the arrows' reach (the house sheet's open-tab order).
1470
+ if (LIGHT_FULL_SCREEN && light && sheet) stops = stops.filter((i) => rowOf(i)?.getClientRects().length);
1329
1471
  const n = stops.length + 1; // positions: -1 (back in input) .. stops.length-1
1330
1472
  const at = stops.indexOf(active);
1331
1473
  const next = ((at + by + 1 + n) % n) - 1;
@@ -1445,6 +1587,11 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1445
1587
  };
1446
1588
 
1447
1589
  const onFocusIn = once<'focusin'>((_, target) => {
1590
+ // Focus gone from the open sheet to the page it covers (Tab past its last
1591
+ // control, Shift+Tab from its first, iOS's next-field arrow): closed, the
1592
+ // focus seen. Review: Tab left it on a hidden control, Escape not heard.
1593
+ // Not the site's input (the tap's hand-off), nor <body> (the keyboard put away).
1594
+ if (sheet && open && target instanceof Element && target !== input && !inOverlay(target)) return leave('blur');
1448
1595
  if (!matches(target) || quietFocus) return;
1449
1596
  engage(target);
1450
1597
  });
@@ -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; }
@@ -27,7 +27,7 @@ export const showAll = (s: AutocompleteScope) =>
27
27
  : html`<button type="button" class="all" data-lbx-part="ac-all" data-lbx-all @click=${() => s.submitAll()}>${s.t('showAllProducts')}${ARROW}</button>`;
28
28
 
29
29
  export default (s: AutocompleteScope) => {
30
- if (s.sheet) return s.include('autocomplete-sheet');
30
+ if (s.fullScreen) return s.include('autocomplete-sheet');
31
31
  const opt = (row: { r: any; i: number }, as: string) => s.include('autocomplete-result-default', { r: row.r, i: row.i, as });
32
32
  const key = (row: { r: any; i: number }) => `${row.i}:${row.r.id}`;
33
33
  const list = s.phrases.length > 0 || s.lists.length > 0;
@@ -16,7 +16,7 @@ export default (s: AutocompleteScope) => {
16
16
  const opt = (row: { r: any; i: number }, as: string) => s.include('autocomplete-result-default', { r: row.r, i: row.i, as });
17
17
  const helpBox = help
18
18
  ? html`<div class="help">
19
- ${s.sheet
19
+ ${s.fullScreen
20
20
  ? html`<span class="help-text"><span class="help-title">${help.title}</span>${help.hours ? html`<span class="help-hours">${help.hours}</span>` : nothing}</span>
21
21
  <a class="call" href=${`tel:${help.tel}`}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="2.5"></rect><path d="M11 18h2"></path></svg>${s.t('call')}<span class="sr"> ${help.phone}</span></a>`
22
22
  : html`<span class="phone-ico" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2" width="12" height="20" rx="2.5"></rect><path d="M11 18h2"></path></svg></span>
@@ -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>
@@ -43,9 +43,9 @@ export default (s: AutocompleteScope) => {
43
43
  </div>
44
44
  ${fallback.length
45
45
  ? html`<div class="strip">
46
- <div class="strip-head"><span class="note-wrap"><span class="f-title" id="lbx-ac-g-fallback">${s.t('fallbackTitle')}</span><span class="f-note" id="lbx-ac-g-fallback-note">${s.t('fallbackNote')}</span></span>${s.sheet ? nothing : POWERED(s.t)}</div>
46
+ <div class="strip-head"><span class="note-wrap"><span class="f-title" id="lbx-ac-g-fallback">${s.t('fallbackTitle')}</span><span class="f-note" id="lbx-ac-g-fallback-note">${s.t('fallbackNote')}</span></span>${s.fullScreen ? nothing : POWERED(s.t)}</div>
47
47
  <div class="fb" role="listbox" id="lbx-ac-fallback" aria-labelledby="lbx-ac-g-fallback lbx-ac-g-fallback-note">${fallback.map((x) => opt(x, 'compact'))}</div>
48
- ${s.sheet ? POWERED(s.t) : nothing}
48
+ ${s.fullScreen ? POWERED(s.t) : nothing}
49
49
  </div>`
50
50
  : html`<div class="e-foot">${POWERED(s.t)}</div>`}
51
51
  </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,12 +33,12 @@ 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>
36
40
  </button>
37
- <input class="sh-input" data-lbx-sheet-input type="text" enterkeyhint="search" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
41
+ <input class="sh-input" data-lbx-full-screen-input type="text" enterkeyhint="search" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
38
42
  role="combobox" aria-autocomplete="list" aria-expanded=${controls ? 'true' : 'false'} aria-controls=${controls || nothing}
39
43
  aria-label=${s.inputLabel} placeholder=${s.placeholder} .value=${s.value} />
40
44
  ${s.value
@@ -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
  `;