@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.
- package/CHANGELOG.md +67 -0
- package/README.md +60 -4
- package/build/vite.mjs +6 -1
- package/docs/ACCESSIBILITY.md +52 -6
- package/package.json +1 -1
- package/src/api/ai.ts +12 -3
- package/src/api/index.ts +11 -1
- package/src/core/features.ts +7 -0
- package/src/core/integration.ts +7 -2
- package/src/locales/cs.json +1 -0
- package/src/locales/de.json +1 -0
- package/src/locales/en.json +1 -0
- package/src/types.ts +76 -17
- package/src/widgets/autocomplete/index.ts +180 -33
- package/src/widgets/autocomplete/style-autocomplete.css +16 -8
- package/src/widgets/autocomplete/template-autocomplete-box.ts +1 -1
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +4 -4
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +14 -10
- package/src/widgets/listing/applied.ts +87 -0
- package/src/widgets/listing/element.ts +261 -26
- package/src/widgets/listing/intent.ts +15 -0
- package/src/widgets/listing/query.ts +7 -3
- package/src/widgets/listing/stash.ts +11 -2
- package/src/widgets/listing/variants.ts +111 -0
- package/src/widgets/listing/view.ts +34 -3
- package/src/widgets/search/first.ts +10 -2
|
@@ -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
|
-
/**
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
|
473
|
-
// viewport
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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 ? (
|
|
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
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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 =
|
|
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
|
-
|
|
845
|
-
|
|
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:
|
|
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
|
-
|
|
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) =>
|
|
1100
|
-
...(empty.browse ?? []).filter((b) => b && safeUrl(b.url)).map((b) =>
|
|
1101
|
-
...fallback.map((r) =>
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
.lbx-root { height:
|
|
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, .
|
|
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:
|
|
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 .
|
|
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.
|
|
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.
|
|
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="
|
|
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.
|
|
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.
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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-
|
|
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
|
-
${
|
|
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
|
|
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
|
`;
|