@luigisbox/luma 0.6.0 → 0.7.1
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 +40 -0
- package/README.md +60 -4
- package/build/vite.mjs +4 -1
- package/docs/ACCESSIBILITY.md +30 -3
- 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/types.ts +57 -12
- package/src/widgets/autocomplete/index.ts +78 -25
- package/src/widgets/autocomplete/template-autocomplete-box.ts +1 -1
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +3 -3
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
- package/src/widgets/listing/element.ts +70 -12
- package/src/widgets/listing/instant.ts +14 -4
- 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';
|
|
@@ -268,7 +268,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
268
268
|
const toTop = () => {
|
|
269
269
|
// Our scroll, not the finger's: a finger at rest on the list is no swipe, the same finger's drag
|
|
270
270
|
// after it still is. Review (PR 26): a new result set's reset put the keyboard away.
|
|
271
|
-
|
|
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;
|
|
272
273
|
for (const el of scrolled) if (el.isConnected) el.scrollTop = 0;
|
|
273
274
|
scrolled.clear();
|
|
274
275
|
};
|
|
@@ -303,8 +304,13 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
303
304
|
}
|
|
304
305
|
};
|
|
305
306
|
|
|
306
|
-
/**
|
|
307
|
-
|
|
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;
|
|
308
314
|
|
|
309
315
|
/** Our shadow root on `el` with our CSS; the box templates render into. */
|
|
310
316
|
const shadowBox = (el: HTMLElement, ...before: Node[]) => {
|
|
@@ -334,7 +340,9 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
334
340
|
// The UA popover's height: fit-content makes a fixed panel the site pins
|
|
335
341
|
// with top + bottom (a full-height sheet on phones) grow past the screen
|
|
336
342
|
// instead of scrolling. Zero specificity: any site rule still wins.
|
|
337
|
-
|
|
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; }');
|
|
338
346
|
ctx.styles.forEach((c, i) => setCss(`itg:autocomplete:${i}`, c));
|
|
339
347
|
box = host;
|
|
340
348
|
host.append(live.el); // the templates render after it
|
|
@@ -412,8 +420,10 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
412
420
|
if (!(el instanceof Element)) return;
|
|
413
421
|
if (el.scrollTop > 0) scrolled.add(el);
|
|
414
422
|
else scrolled.delete(el);
|
|
415
|
-
if (swipe
|
|
416
|
-
|
|
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();
|
|
417
427
|
},
|
|
418
428
|
{ capture: true, passive: true },
|
|
419
429
|
);
|
|
@@ -424,11 +434,13 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
424
434
|
// pill band's sideways drag is not the list. Focus goes to the sheet
|
|
425
435
|
// (tabindex -1), not <body>: Escape and Tab still work from a hardware
|
|
426
436
|
// keyboard. Bug (iOS 26 Safari): with the keyboard up, its bar and the
|
|
427
|
-
// address bar above it, the sheet kept half the screen.
|
|
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.
|
|
428
440
|
const putKeyboardAway = () => {
|
|
429
441
|
swipe = null;
|
|
430
442
|
if (!mirror || focused() !== mirror) return;
|
|
431
|
-
const panel = mirror.closest<HTMLElement>('.sheet[tabindex]');
|
|
443
|
+
const panel = mirror.closest<HTMLElement>('.sheet[tabindex]') ?? (LIGHT_FULL_SCREEN && light ? host : null);
|
|
432
444
|
if (panel) panel.focus({ preventScroll: true });
|
|
433
445
|
else mirror.blur(); // an integration's own sheet template
|
|
434
446
|
};
|
|
@@ -439,7 +451,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
439
451
|
const e = ev as PointerEvent;
|
|
440
452
|
swipe = null;
|
|
441
453
|
if (!sheet || !finger(e)) return;
|
|
442
|
-
const
|
|
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);
|
|
443
456
|
if (list) swipe = { list, x: e.clientX, y: e.clientY, moved: false };
|
|
444
457
|
},
|
|
445
458
|
{ capture: true, passive: true },
|
|
@@ -534,6 +547,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
534
547
|
*/
|
|
535
548
|
/** What Safari's bars and the keyboard cover of the sheet (px, --lbx-cover), -1 while it is not open. */
|
|
536
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;
|
|
537
552
|
/** A clamp of the scrolled lists is queued (one for a keyboard going down, not one per frame of it). */
|
|
538
553
|
let clamping = false;
|
|
539
554
|
const position = (frame = false, m?: ReturnType<typeof measure>) => {
|
|
@@ -548,14 +563,19 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
548
563
|
// they and the keyboard cover (--lbx-cover) ends the list as space, so
|
|
549
564
|
// its last row and "Show all" scroll clear of them. Bug (iOS 26/27):
|
|
550
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.
|
|
551
569
|
const vv = window.visualViewport;
|
|
552
570
|
const top = Math.round(vv?.offsetTop ?? 0);
|
|
553
571
|
const h = Math.round(vv?.height ?? innerHeight);
|
|
554
572
|
const full = Math.max(h, innerHeight - top);
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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`);
|
|
559
579
|
if (full - h !== cover) {
|
|
560
580
|
// Less covered (the keyboard going down): the end space shrinks, and iOS WebKit kept a list
|
|
561
581
|
// scrolled into it there, its rows stuck above a blank strip, not scrollable back (iOS 27
|
|
@@ -630,6 +650,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
630
650
|
aria?.restore();
|
|
631
651
|
aria = null;
|
|
632
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));
|
|
633
655
|
/**
|
|
634
656
|
* The selected row (the screen reader's focus stays on the input). An id
|
|
635
657
|
* reference resolves only in the input's own tree, never across a shadow
|
|
@@ -648,7 +670,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
648
670
|
const markActive = (say = true) => {
|
|
649
671
|
const all = active === ALL ? allLink() : null;
|
|
650
672
|
if (all && !all.id) all.id = idOf(ALL);
|
|
651
|
-
const row = all ?? (active >= 0 ? (
|
|
673
|
+
const row = all ?? (active >= 0 ? rowOf(active) : null);
|
|
652
674
|
if (sheet && mirror) {
|
|
653
675
|
if (row) mirror.setAttribute('aria-activedescendant', row.id);
|
|
654
676
|
else mirror.removeAttribute('aria-activedescendant');
|
|
@@ -811,7 +833,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
811
833
|
const idle = (f: () => void) => afterFrame(() => (typeof requestIdleCallback === 'function' ? requestIdleCallback(f, { timeout: 300 }) : setTimeout(f, 0)));
|
|
812
834
|
|
|
813
835
|
/**
|
|
814
|
-
* 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,
|
|
815
838
|
* opened when the site's input gets focus. Its own input takes the focus
|
|
816
839
|
* at once (in the tap's task: iOS shows its keyboard only for a focus in
|
|
817
840
|
* the gesture) with what the site's input holds; it is the combobox there.
|
|
@@ -826,9 +849,20 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
826
849
|
tab = '';
|
|
827
850
|
before = i.value;
|
|
828
851
|
place();
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
+
}
|
|
832
866
|
// What the site's input holds now, not what the sheet's held when it last
|
|
833
867
|
// closed (letters typed into the site's input before the hand-off, a
|
|
834
868
|
// value the site's page set since).
|
|
@@ -845,7 +879,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
845
879
|
cancelAnimationFrame(raf);
|
|
846
880
|
position();
|
|
847
881
|
render(); // with what the site's input holds
|
|
848
|
-
mirror =
|
|
882
|
+
mirror = box.querySelector<HTMLInputElement>('[data-lbx-full-screen-input]');
|
|
849
883
|
}
|
|
850
884
|
if (mirror) {
|
|
851
885
|
mirror.focus();
|
|
@@ -890,6 +924,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
890
924
|
then?.();
|
|
891
925
|
return;
|
|
892
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;
|
|
893
929
|
const done = then ?? (() => {});
|
|
894
930
|
afterBack = done;
|
|
895
931
|
pendingReason = reason;
|
|
@@ -928,7 +964,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
928
964
|
entry = false; // left in history (the same URL) unless Back took it
|
|
929
965
|
setCss('ac:sheet', null);
|
|
930
966
|
host!.style.height = '';
|
|
931
|
-
|
|
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';
|
|
932
973
|
host!.style.removeProperty('--lbx-cover');
|
|
933
974
|
cover = -1;
|
|
934
975
|
host!.removeAttribute('data-lbx-short');
|
|
@@ -946,6 +987,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
946
987
|
if (AC_HOOKS) hook('onClose', ac.onClose, reason);
|
|
947
988
|
};
|
|
948
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');
|
|
949
992
|
/** The sheet input's value the last render drew (its clear button). */
|
|
950
993
|
let drawnValue = '';
|
|
951
994
|
/** Rows as templates get them: the result and its index in `items`. */
|
|
@@ -972,7 +1015,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
972
1015
|
// The arrow keys' order: pills, the lists in config order, the
|
|
973
1016
|
// top product, the products in reading order; in the sheet, the pills and
|
|
974
1017
|
// the open tab's options; with no results, phrases to try, links, picks.
|
|
975
|
-
// 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).
|
|
976
1020
|
const phrases = byType('query');
|
|
977
1021
|
order = light
|
|
978
1022
|
? rows.map((x) => x.i)
|
|
@@ -1000,7 +1044,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1000
1044
|
submitAll: () => submitAll(),
|
|
1001
1045
|
allHref: searchHref(input, sheet ? (mirror?.value ?? '') : undefined),
|
|
1002
1046
|
state,
|
|
1003
|
-
sheet,
|
|
1047
|
+
fullScreen: sheet,
|
|
1004
1048
|
wide,
|
|
1005
1049
|
phrases,
|
|
1006
1050
|
lists,
|
|
@@ -1030,7 +1074,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1030
1074
|
.slice(0, 4),
|
|
1031
1075
|
value,
|
|
1032
1076
|
placeholder: input?.placeholder ?? '',
|
|
1033
|
-
inputLabel:
|
|
1077
|
+
inputLabel: labelOf(input),
|
|
1034
1078
|
clear: () => {
|
|
1035
1079
|
if (!mirror) return;
|
|
1036
1080
|
mirror.value = '';
|
|
@@ -1419,7 +1463,11 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1419
1463
|
* other way round.
|
|
1420
1464
|
*/
|
|
1421
1465
|
const step = (by: 1 | -1) => {
|
|
1422
|
-
|
|
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);
|
|
1423
1471
|
const n = stops.length + 1; // positions: -1 (back in input) .. stops.length-1
|
|
1424
1472
|
const at = stops.indexOf(active);
|
|
1425
1473
|
const next = ((at + by + 1 + n) % n) - 1;
|
|
@@ -1539,6 +1587,11 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1539
1587
|
};
|
|
1540
1588
|
|
|
1541
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');
|
|
1542
1595
|
if (!matches(target) || quietFocus) return;
|
|
1543
1596
|
engage(target);
|
|
1544
1597
|
});
|
|
@@ -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>
|
|
@@ -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>
|
|
@@ -38,7 +38,7 @@ export default (s: AutocompleteScope) => {
|
|
|
38
38
|
<button type="button" class="icon back" aria-label=${s.t('closeSearch')} @click=${() => s.back()}>
|
|
39
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>
|
|
40
40
|
</button>
|
|
41
|
-
<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"
|
|
42
42
|
role="combobox" aria-autocomplete="list" aria-expanded=${controls ? 'true' : 'false'} aria-controls=${controls || nothing}
|
|
43
43
|
aria-label=${s.inputLabel} placeholder=${s.placeholder} .value=${s.value} />
|
|
44
44
|
${s.value
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { borrow, liveRegion, useContrast } from '../../core/a11y';
|
|
17
17
|
import { activateByKey, byKeyboard, focusables, trapTab } from '../../core/a11y-keys';
|
|
18
|
-
import { CONTENT_VISIBILITY, DRAWER, DROPDOWN, MENU, NAMED_FADE, NAMED_PRECONNECT_IMAGES, NAMED_PREFETCH_FILTERS, NAMED_PREFETCH_NEXT_PAGE, NAMED_PRELOAD, NAMED_RESTORE_ON_BACK, ON_RENDER, SEARCH, SSR } from '../../core/features';
|
|
18
|
+
import { CONTENT_VISIBILITY, DRAWER, DROPDOWN, MENU, NAMED_FADE, NAMED_PRECONNECT_IMAGES, NAMED_PREFETCH_FILTERS, NAMED_PREFETCH_NEXT_PAGE, NAMED_PRELOAD, NAMED_RESTORE_ON_BACK, ON_RENDER, SEARCH, SSR, VARIANTS } from '../../core/features';
|
|
19
19
|
import { mark, reportError } from '../../core/health';
|
|
20
20
|
import { claim } from '../../core/reconciler';
|
|
21
21
|
import { afterLayout, rememberHeight } from '../../core/hold';
|
|
@@ -38,11 +38,12 @@ import { specKey } from '../../core/stash';
|
|
|
38
38
|
import { firstRequest, hasMore, listingMore, listingQuery, type ListingConfig, type ListingResult } from './query';
|
|
39
39
|
import { flowKey, notAbove, onQuickLanded, quickChannel } from './flow';
|
|
40
40
|
import { firstPages, SETTLE_MS, targetOf, thrifty, watchIntent } from './intent';
|
|
41
|
-
import type { Page } from '../../api';
|
|
42
|
-
import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, viewport, type ListingView, type SsrState } from './view';
|
|
41
|
+
import type { Page, VariantGroup } from '../../api';
|
|
42
|
+
import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, withGroups, viewport, type ListingView, type SsrState } from './view';
|
|
43
43
|
import { dropStash, peekStash, readStash, removeStash, writeStash, type Stash } from './stash';
|
|
44
44
|
import { adoptScroll, holdScroll, keepViewport, leavingLink, nearEnd, preconnectImages, releaseScroll, restoreScroll, scrollSpot } from './instant';
|
|
45
45
|
import { watch } from '../../core/tiles';
|
|
46
|
+
import { askVariants, groupsOf, seedGroups, stashGroups } from './variants';
|
|
46
47
|
|
|
47
48
|
/** A re-render answered within DIM_MS (no dim shown) fades its list in from this opacity, over FADE_MS. */
|
|
48
49
|
const FADE_FROM = 0.6;
|
|
@@ -108,7 +109,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
108
109
|
useContrast();
|
|
109
110
|
const query = (q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void) => {
|
|
110
111
|
const t0 = performance.now();
|
|
111
|
-
|
|
112
|
+
// `variants`: the first page's groups asked for as soon as it is here, with its facets' request.
|
|
113
|
+
const p = listingQuery(ctx.api, ctx.global, cfg, route, q, signal, pre, onProducts, SEARCH ? quickChannel(route.id) : undefined, VARIANTS && cfg.variants ? (f) => void askVariants(ctx.api, cfg, f, signal) : undefined);
|
|
112
114
|
// A whole listing (not a first page fetched already): how long a fresh one takes here.
|
|
113
115
|
if (!pre) p.then((r) => !r.partial && observe(performance.now() - t0), () => {});
|
|
114
116
|
return p;
|
|
@@ -233,6 +235,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
233
235
|
else if (stashed) {
|
|
234
236
|
seeded.set(pageKey(location.href), stashed.query);
|
|
235
237
|
cache.set(keyOf(stashed.query), stashed.result);
|
|
238
|
+
// `variants`: the groups it was left with, drawn with it (nothing asked).
|
|
239
|
+
if (VARIANTS) seedGroups(stashed.result, stashed.groups);
|
|
236
240
|
mark(`${route.id} from stash (${stashed.kind}, ${stashed.result.hits.length} hits)`);
|
|
237
241
|
// A prerendered page reads (and drops) it in a copy of sessionStorage; at
|
|
238
242
|
// its activation the opener's storage, still holding it, comes back: used once.
|
|
@@ -399,7 +403,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
399
403
|
private hiddenAt = 0;
|
|
400
404
|
/** scrollY when a Back stash was last written (leave()): the page hidden from there is stashed already. */
|
|
401
405
|
private leftAt = -1;
|
|
402
|
-
private onPageHide = () => {
|
|
406
|
+
private onPageHide = (e: PageTransitionEvent) => {
|
|
403
407
|
this.hiddenAt = Date.now();
|
|
404
408
|
// Left with more than the first page shown, not by a link of ours (a
|
|
405
409
|
// reload, their menu, the address bar): stashed too, so a reload within
|
|
@@ -408,6 +412,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
408
412
|
// shorter than where the shopper was). Into the back/forward cache
|
|
409
413
|
// too: it may be evicted there (then Back is a page load).
|
|
410
414
|
if (restoreOn && (this.last?.page ?? 0) > 1 && this.leftAt !== Math.round(scrollY)) this.leave();
|
|
415
|
+
// Into the back/forward cache: the browser's restoration on again (instant.ts `holdScroll`).
|
|
416
|
+
if (e.persisted) releaseScroll();
|
|
411
417
|
};
|
|
412
418
|
/**
|
|
413
419
|
* Restored from the back/forward cache (pageshow, persisted): the page is as
|
|
@@ -420,14 +426,13 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
420
426
|
*/
|
|
421
427
|
private onPageShow = (e: PageTransitionEvent) => {
|
|
422
428
|
if (!e.persisted) return;
|
|
423
|
-
// Where the shopper was:
|
|
424
|
-
//
|
|
425
|
-
//
|
|
429
|
+
// Where the shopper was: iOS Safari reads scrollY 0 at this event even
|
|
430
|
+
// when it restores it. Placed again (the browser's restoration is on
|
|
431
|
+
// again since pagehide, a host's own setting kept).
|
|
426
432
|
const st = peekStash(location.href);
|
|
427
433
|
dropStash(location.href);
|
|
428
434
|
mark(`${route.id} restored from the back/forward cache`);
|
|
429
|
-
if (st?.kind === 'back' && st.scroll && tk.state === 'ours') restoreScroll(this, st.scroll
|
|
430
|
-
else releaseScroll();
|
|
435
|
+
if (st?.kind === 'back' && st.scroll && tk.state === 'ours') restoreScroll(this, st.scroll);
|
|
431
436
|
const away = this.hiddenAt ? Date.now() - this.hiddenAt : 0;
|
|
432
437
|
if (away > BFCACHE_STALE_MS && this.last && this.lastQ && this.lastQ === this.current) this.revalidate(this.lastQ, this.last, new URL(location.href), this.href);
|
|
433
438
|
};
|
|
@@ -1187,7 +1192,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1187
1192
|
this.leftAt = Math.round(scrollY);
|
|
1188
1193
|
// The browser's own restoration off for this entry: Luma places the page after its render (holdScroll).
|
|
1189
1194
|
const sr = holdScroll();
|
|
1190
|
-
if (writeStash(location.href, { kind: 'back', route: route.id, query: this.lastQ, result: { ...this.last, quickPending: undefined }, scroll: scrollSpot(this, a), sr })) mark(`${route.id} stashed for back`);
|
|
1195
|
+
if (writeStash(location.href, { kind: 'back', route: route.id, query: this.lastQ, result: { ...this.last, quickPending: undefined }, scroll: scrollSpot(this, a), sr, groups: VARIANTS && cfg.variants ? stashGroups(this.last) : undefined })) mark(`${route.id} stashed for back`);
|
|
1191
1196
|
else releaseScroll();
|
|
1192
1197
|
}
|
|
1193
1198
|
|
|
@@ -1414,6 +1419,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1414
1419
|
if (res !== this.last || q !== this.lastQ || !hasMore(res) || nextOf.has(res)) return;
|
|
1415
1420
|
mark(`${route.id} prefetch page ${res.page + 1}`);
|
|
1416
1421
|
more(q, res).catch(() => {}); // a failed one: the click asks again
|
|
1422
|
+
// `variants`: its products' groups too (more() asks once), so "show more" draws them with its tiles.
|
|
1423
|
+
if (VARIANTS && cfg.variants) more(q, res).then((r) => askVariants(ctx.api, cfg, r, this.ctrl?.signal), () => {});
|
|
1417
1424
|
}
|
|
1418
1425
|
|
|
1419
1426
|
private render(q: Query, res: ListingResult, url: URL) {
|
|
@@ -1579,7 +1586,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1579
1586
|
const self = this;
|
|
1580
1587
|
return {
|
|
1581
1588
|
...ctx.helpers,
|
|
1582
|
-
...listingData(ctx, route, url, q, res, this.message, this.view()),
|
|
1589
|
+
...listingData(ctx, route, url, q, res, this.message, VARIANTS && cfg.variants ? { ...this.view(), variants: (this.groups$?.res === res ? this.groups$ : (this.groups$ = { res, map: groupsOf(res) })).map } : this.view()),
|
|
1583
1590
|
facetsOpen: matchMedia('(min-width: 768px)').matches,
|
|
1584
1591
|
// actions
|
|
1585
1592
|
go: (link: Link | null) => (e: Event) => {
|
|
@@ -1628,6 +1635,9 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1628
1635
|
}
|
|
1629
1636
|
|
|
1630
1637
|
private draw(q: Query, res: ListingResult, url: URL) {
|
|
1638
|
+
// `variants`: the groups of these products, asked for now (those not
|
|
1639
|
+
// asked yet), never waited for: finish() draws them when they land.
|
|
1640
|
+
if (VARIANTS && cfg.variants) askVariants(ctx.api, cfg, res, this.ctrl?.signal);
|
|
1631
1641
|
// A first render in steps of these same products (shown before their
|
|
1632
1642
|
// facets): it goes on with the facets, not a render of them all at once.
|
|
1633
1643
|
if (this.stepping?.hits === res.hits) return this.stepping.take(q, res, url);
|
|
@@ -1961,6 +1971,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1961
1971
|
}
|
|
1962
1972
|
/** Tiles in the viewport (an IntersectionObserver), whether the list's last one is above it, the grid's columns (read with layout clean). */
|
|
1963
1973
|
private seen = new Set<Element>();
|
|
1974
|
+
/** `variants`: the groups the last scope of a result was built with (its map grows in steps as they land: finish()). */
|
|
1975
|
+
declare private groups$: { res: ListingResult; map?: ReadonlyMap<string, VariantGroup> } | undefined;
|
|
1964
1976
|
private past = false;
|
|
1965
1977
|
private cols = 1;
|
|
1966
1978
|
private io: IntersectionObserver | null = null;
|
|
@@ -1992,6 +2004,52 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1992
2004
|
this.drawn$ = { hits: res.hits, n: res.hits.length };
|
|
1993
2005
|
this.lastQ = q;
|
|
1994
2006
|
this.afterA11y();
|
|
2007
|
+
// `variants`: drawn again once the groups of these products asked for
|
|
2008
|
+
// have landed (now: during a render in steps), if they are still shown
|
|
2009
|
+
// (a later render of other products draws its own).
|
|
2010
|
+
if (VARIANTS && cfg.variants)
|
|
2011
|
+
askVariants(ctx.api, cfg, res, this.ctrl?.signal).then(() => {
|
|
2012
|
+
const got = groupsOf(res);
|
|
2013
|
+
const had = this.groups$?.map;
|
|
2014
|
+
if (!got || had === got) return;
|
|
2015
|
+
// In steps, as a shown listing's re-render (each tile whose group
|
|
2016
|
+
// landed runs its template again): the tiles in view first, then
|
|
2017
|
+
// as many a frame as BUDGET holds by what one cost.
|
|
2018
|
+
const tiles = this.querySelector('[data-lbx-list]')?.children;
|
|
2019
|
+
const ids = res.hits.map((h, i) => [tiles && this.seen.has(tiles[i]) ? 0 : 1, h['@id']] as const).filter(([, id]) => got.has(id) && had?.get(id) !== got.get(id)).sort((a, b) => a[0] - b[0]).map(([, id]) => id);
|
|
2020
|
+
const N = res.hits.length;
|
|
2021
|
+
let n = 0;
|
|
2022
|
+
let per = this.tileMs || 3;
|
|
2023
|
+
let frames = 0;
|
|
2024
|
+
const step = () => {
|
|
2025
|
+
if (this.last !== res || this.drawn$?.hits !== res.hits || this.groups$?.res !== res || !this.drawn) return;
|
|
2026
|
+
// STEP tiles first, to measure one (a tile drawn anew with its group
|
|
2027
|
+
// costs what a new tile does: the tiles' cost learnt from a render
|
|
2028
|
+
// in steps was 0.3 ms for 2 ms tiles, one step of all of them); then
|
|
2029
|
+
// as a re-render in steps: as many as BUDGET holds with the tiles
|
|
2030
|
+
// there run again, not even one, a third of the rest.
|
|
2031
|
+
const room = Math.floor((BUDGET - RERUN * per * N) / per);
|
|
2032
|
+
const k = Math.min(ids.length - n, !frames ? STEP : room >= 1 ? room : Math.ceil((ids.length - n) / 3));
|
|
2033
|
+
const map = n + k >= ids.length ? got : new Map([...(this.groups$.map ?? []), ...ids.slice(n, n + k).map((id) => [id, got.get(id)!] as const)]);
|
|
2034
|
+
n += k;
|
|
2035
|
+
this.groups$ = { res, map };
|
|
2036
|
+
// A tile's template may draw other markup with its group: focus in one replaced goes to the region, not the page's start.
|
|
2037
|
+
const at = document.activeElement;
|
|
2038
|
+
this.focusWas = at && at !== this && this.contains(at) ? at : null;
|
|
2039
|
+
const t0 = performance.now();
|
|
2040
|
+
// Only the results change: the scope is not built again (as setUi). A
|
|
2041
|
+
// template throwing on the groups hands the page back, as a render does, and no step follows.
|
|
2042
|
+
this.guarded(() => {
|
|
2043
|
+
this.renderOrRebuild((this.drawn = { ...this.drawn!, results: res.hits.map(withGroups(ctx.result, map)) }));
|
|
2044
|
+
this.afterA11y();
|
|
2045
|
+
per = Math.max(0.5, (performance.now() - t0) / (k + RERUN * (N - k)));
|
|
2046
|
+
frames++;
|
|
2047
|
+
if (n < ids.length) afterFrame(step);
|
|
2048
|
+
else mark(`${route.id} variants in ${frames} frames`);
|
|
2049
|
+
});
|
|
2050
|
+
};
|
|
2051
|
+
step();
|
|
2052
|
+
});
|
|
1995
2053
|
const info = { route: route.id, query: q.title, total: res.total, shown: res.hits.length, page: res.page, url };
|
|
1996
2054
|
// A prerendered page (a search typed on the page before) is not seen
|
|
1997
2055
|
// yet: the hook (titles, and often the site's analytics) runs when it
|
|
@@ -106,8 +106,10 @@ export const restoreScroll = (root: Element, s: Spot, after?: () => void) => {
|
|
|
106
106
|
* after its render. The browser's restoration ran before it, on a page not
|
|
107
107
|
* grown to the stashed pages yet, and could land after Luma's place too.
|
|
108
108
|
* Only from `auto`: a host that set it (Turbo, a router) keeps its own.
|
|
109
|
-
* Back on (`releaseScroll`) once Luma has placed the page,
|
|
110
|
-
* document moves on within itself (an SPA's navigation: theirs to restore)
|
|
109
|
+
* Back on (`releaseScroll`) once Luma has placed the page, when the
|
|
110
|
+
* document moves on within itself (an SPA's navigation: theirs to restore),
|
|
111
|
+
* or as the page goes into the back/forward cache (restored from memory:
|
|
112
|
+
* iOS Safari's swipe Back shows the page behind only with it on).
|
|
111
113
|
*/
|
|
112
114
|
let ownScroll = false;
|
|
113
115
|
/** Off for this entry, when it is the browser's (or already ours): 1, for the stash's `sr`. */
|
|
@@ -121,8 +123,16 @@ export const holdScroll = (): 1 | undefined => {
|
|
|
121
123
|
return undefined;
|
|
122
124
|
}
|
|
123
125
|
};
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
+
/**
|
|
127
|
+
* A page loaded on an entry Luma turned it off for (the stash's `sr`): Luma's
|
|
128
|
+
* to turn back on. Off again where it was turned on as the page went into
|
|
129
|
+
* the back/forward cache (the page evicted from it since): the browser's
|
|
130
|
+
* restoration stops here, Luma places the page.
|
|
131
|
+
*/
|
|
132
|
+
export const adoptScroll = () => {
|
|
133
|
+
ownScroll = true;
|
|
134
|
+
holdScroll();
|
|
135
|
+
};
|
|
126
136
|
/** Back to the browser's, if Luma turned it off. */
|
|
127
137
|
export const releaseScroll = () => {
|
|
128
138
|
if (!ownScroll) return;
|
|
@@ -10,7 +10,7 @@ import { facetFields, type Query, type Route } from './state';
|
|
|
10
10
|
import { lru } from '../../core/storage';
|
|
11
11
|
import { reportError } from '../../core/health';
|
|
12
12
|
import { MAX_PAGE } from '../../core/routes';
|
|
13
|
-
import { EMPTY_VALUES } from '../../core/features';
|
|
13
|
+
import { EMPTY_VALUES, VARIANTS } from '../../core/features';
|
|
14
14
|
|
|
15
15
|
const num = (s: string | undefined) => (s === undefined || s === '' ? null : Number(s));
|
|
16
16
|
/** A range filter value `min|max` (either end may be empty). */
|
|
@@ -156,9 +156,11 @@ export const firstRequest = (g: GlobalConfigType, cfg: ListingConfig, route: Rou
|
|
|
156
156
|
* need their result set's guid and so a request after them. `onQuick`: the
|
|
157
157
|
* quick types never wait for (where they do not sit above the products):
|
|
158
158
|
* the result comes without them if they are not here yet (`quickPending`),
|
|
159
|
-
* and they land in it later, `onQuick` called with it then.
|
|
159
|
+
* and they land in it later, `onQuick` called with it then. `onFirst`: the
|
|
160
|
+
* first page as soon as it is here, with the facets' request (`variants`:
|
|
161
|
+
* its products' groups are asked for then, not after the facets).
|
|
160
162
|
*/
|
|
161
|
-
export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingConfig, route: Route, q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void, onQuick?: (r: ListingResult) => void): Promise<ListingResult> => {
|
|
163
|
+
export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingConfig, route: Route, q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void, onQuick?: (r: ListingResult) => void, onFirst?: (p: Page) => void): Promise<ListingResult> => {
|
|
162
164
|
// A range selection over a field that may not be a numeric one (a page's
|
|
163
165
|
// own facet, e.g. a parameter holding its numbers as text): if the API facets
|
|
164
166
|
// it as terms, the range is the values within it, from the values of the
|
|
@@ -259,6 +261,8 @@ export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingCo
|
|
|
259
261
|
: {};
|
|
260
262
|
// Optional too: without them no value shows as empty.
|
|
261
263
|
const all = EMPTY_VALUES && cfg.emptyValues && narrowed ? allOf().catch(() => undefined) : undefined;
|
|
264
|
+
// Extras after what the listing waits for (its facets' requests are out).
|
|
265
|
+
if (VARIANTS) onFirst?.(first);
|
|
262
266
|
// The products now, the facets when they come: a request (theirs needs
|
|
263
267
|
// this result set's guid) the shopper would otherwise wait for.
|
|
264
268
|
const early: ListingResult | null =
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { navigation, specKey, STASH, stashFits, stashKey } from '../../core/stash';
|
|
22
22
|
import { pageKey } from '../../core/page';
|
|
23
|
+
import { VARIANTS } from '../../core/features';
|
|
23
24
|
import type { ListingResult } from './query';
|
|
24
25
|
import { queryKey, type Query } from './state';
|
|
25
26
|
import type { Spot } from './instant';
|
|
@@ -39,6 +40,8 @@ export interface Stash {
|
|
|
39
40
|
scroll?: Spot;
|
|
40
41
|
/** back: Luma turned the browser's own scroll restoration off for the entry left (`history.scrollRestoration`): back on once restored. */
|
|
41
42
|
sr?: 1;
|
|
43
|
+
/** back, `variants`: the groups of the products shown (./variants.ts), drawn with them. Storage: checked when read. */
|
|
44
|
+
groups?: unknown;
|
|
42
45
|
/** Read only: too old to render from, where the shopper was is all that is used. */
|
|
43
46
|
stale?: true;
|
|
44
47
|
}
|
|
@@ -114,9 +117,15 @@ export const removeStash = (key: string) => {
|
|
|
114
117
|
};
|
|
115
118
|
|
|
116
119
|
/** Under the page's URL (`back`) or the query (`spec`). false: too big, or storage refused it. */
|
|
117
|
-
export const writeStash = (href: string, s: Omit<Stash, 'v' | 'at'>) =>
|
|
120
|
+
export const writeStash = (href: string, s: Omit<Stash, 'v' | 'at'>) =>
|
|
121
|
+
// Too big with the products' variant groups (extras): stashed without them rather than not at all.
|
|
122
|
+
put(href, s) || (VARIANTS ? !!s.groups && put(href, { ...s, groups: undefined }) : false);
|
|
123
|
+
|
|
124
|
+
const put = (href: string, s: Omit<Stash, 'v' | 'at'>) => {
|
|
118
125
|
try {
|
|
119
|
-
|
|
126
|
+
// `at` and `kind` first: pruning reads them without parsing the rest.
|
|
127
|
+
const { kind, ...rest } = s;
|
|
128
|
+
const json = JSON.stringify({ at: Date.now(), v: 1, kind, ...rest });
|
|
120
129
|
if (json.length > MAX_CHARS) return false;
|
|
121
130
|
const key = s.kind === 'spec' ? specKey(s.route, s.query.q ?? '') : stashKey(href);
|
|
122
131
|
const others = entries(key);
|