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