@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
|
@@ -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
|
+
};
|
|
@@ -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';
|
|
@@ -37,16 +37,22 @@ import { facetFields, handsBack, nextState, queryKey, stateOf, type Query, type
|
|
|
37
37
|
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
|
-
import { firstPages, 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';
|
|
40
|
+
import { firstPages, SETTLE_MS, targetOf, thrifty, watchIntent } from './intent';
|
|
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;
|
|
49
50
|
const FADE_MS = 150;
|
|
51
|
+
/** An Apply's status (./applied.ts) shows this long; its new tiles come in up to SWAP_TILES (those in view). */
|
|
52
|
+
const APPLIED_MS = 2500;
|
|
53
|
+
const SWAP_TILES = 16;
|
|
54
|
+
/** Reduced motion asked for (read when needed: the setting may change). */
|
|
55
|
+
const reduced = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
50
56
|
/** A hidden first render in frames: the first STEP tiles, then about BUDGET ms of rendering per frame. */
|
|
51
57
|
const STEP = 4;
|
|
52
58
|
/** Tiles drawn (with the facets) before ours shows, per 900 px of viewport height: two rows of four on a wide screen, four of two on a phone. */
|
|
@@ -103,7 +109,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
103
109
|
useContrast();
|
|
104
110
|
const query = (q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void) => {
|
|
105
111
|
const t0 = performance.now();
|
|
106
|
-
|
|
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);
|
|
107
114
|
// A whole listing (not a first page fetched already): how long a fresh one takes here.
|
|
108
115
|
if (!pre) p.then((r) => !r.partial && observe(performance.now() - t0), () => {});
|
|
109
116
|
return p;
|
|
@@ -167,6 +174,17 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
167
174
|
// it does not download it with every listing.
|
|
168
175
|
const toggle = ctx.global.contrastToggle ? import('../../core/contrast-toggle') : null;
|
|
169
176
|
const visitOf = (q: Query) => JSON.stringify([q.q, q.scope]);
|
|
177
|
+
const prefetchFailed = (e: unknown) => reportError(`${route.id} filter prefetch`, e);
|
|
178
|
+
/** What a render says it shows: the count found, or nothing found. */
|
|
179
|
+
const found = (q: Query, res: ListingResult) => (res.total ? t('results', { count: res.total, query: q.title }) : t('empty', { query: q.title }));
|
|
180
|
+
// An Apply's cue (./applied.ts, its own chunk): asked for when a drawer opens where it stages.
|
|
181
|
+
let applied: typeof import('./applied') | null = null;
|
|
182
|
+
let appliedAsked = false;
|
|
183
|
+
const askApplied = () => {
|
|
184
|
+
if (appliedAsked) return;
|
|
185
|
+
appliedAsked = true;
|
|
186
|
+
import('./applied').then((m) => (m.prepare(), (applied = m)), (e) => reportError(`${route.id} applied cue`, e));
|
|
187
|
+
};
|
|
170
188
|
const earlyKey = (q: Query) => {
|
|
171
189
|
const r = firstRequest(ctx.global, cfg, route, q);
|
|
172
190
|
return r && JSON.stringify(r);
|
|
@@ -217,6 +235,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
217
235
|
else if (stashed) {
|
|
218
236
|
seeded.set(pageKey(location.href), stashed.query);
|
|
219
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);
|
|
220
240
|
mark(`${route.id} from stash (${stashed.kind}, ${stashed.result.hits.length} hits)`);
|
|
221
241
|
// A prerendered page reads (and drops) it in a copy of sessionStorage; at
|
|
222
242
|
// its activation the opener's storage, still holding it, comes back: used once.
|
|
@@ -334,6 +354,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
334
354
|
*/
|
|
335
355
|
private staged: UiState | null = null;
|
|
336
356
|
private stagedMode = false;
|
|
357
|
+
/**
|
|
358
|
+
* The held state fetched in the background (prefetchHeld()): the timer
|
|
359
|
+
* waiting for the changes to pause, the held state's URL (a query
|
|
360
|
+
* resolved late is for it only), and the request it started (its key in
|
|
361
|
+
* `early`), aborted by a newer held state while it is out.
|
|
362
|
+
*/
|
|
363
|
+
private heldTimer = 0;
|
|
364
|
+
private heldKey: string | null = null;
|
|
337
365
|
/** Stops watching the `facetsApply` breakpoints of the open drawer (watchApply()). */
|
|
338
366
|
private unwatchApply: (() => void) | null = null;
|
|
339
367
|
/** Renders started: a render in chunks stops when a newer one has started. */
|
|
@@ -490,6 +518,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
490
518
|
this.ahead?.();
|
|
491
519
|
this.intent?.stop();
|
|
492
520
|
this.intent = null;
|
|
521
|
+
clearTimeout(this.heldTimer);
|
|
522
|
+
this.heldTimer = 0;
|
|
523
|
+
this.endCue();
|
|
524
|
+
this.removeAttribute('data-lbx-swap');
|
|
493
525
|
this.sized?.ro.disconnect();
|
|
494
526
|
this.sized = null;
|
|
495
527
|
this.unwatchApply?.();
|
|
@@ -581,7 +613,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
581
613
|
* Where the shopper was in each state of the visit left by a change of
|
|
582
614
|
* ours or a traversal (canonical href → scrollY), and the one a traversal
|
|
583
615
|
* goes back to: drawn whole (not in steps) and placed there. Bug (a
|
|
584
|
-
*
|
|
616
|
+
* production storefront, 2026-10-15): Back after a drawer filter
|
|
585
617
|
* drew the list in steps, the browser's restored scroll was clamped to
|
|
586
618
|
* the first step's short page, and Chromium's scroll anchoring kept the
|
|
587
619
|
* footer in view as the tiles went in above it.
|
|
@@ -607,22 +639,49 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
607
639
|
if (!DRAWER) return;
|
|
608
640
|
const next = this.staged;
|
|
609
641
|
this.staged = null;
|
|
610
|
-
|
|
642
|
+
// Changes not fetched yet (Apply within SETTLE_MS of the last): their
|
|
643
|
+
// request starts in the next task (the drawer closes first), while the
|
|
644
|
+
// drawer's entry is taken back, and the navigation joins it.
|
|
645
|
+
if (next && this.heldTimer) setTimeout(() => this.prefetchHeld(next), 0);
|
|
646
|
+
// Another state to show: the list it replaces dims now, as the drawer closes (./applied.ts).
|
|
647
|
+
const cue = (this.applyCue = next && canon(route.write(new URL(location.href), next).href) !== this.href ? { href: '' } : null);
|
|
648
|
+
this.toggleAttribute('data-lbx-swap', !!cue);
|
|
649
|
+
this.setDrawer(false, from, next ? () => {
|
|
650
|
+
this.navigate(next);
|
|
651
|
+
this.topFor = this.wantedHref;
|
|
652
|
+
if (cue) cue.href = this.wantedHref ?? '';
|
|
653
|
+
if (cue && !cue.href) this.removeAttribute('data-lbx-swap'), (this.applyCue = null); // nothing to fetch after all
|
|
654
|
+
this.toListTop();
|
|
655
|
+
} : undefined);
|
|
611
656
|
}
|
|
612
657
|
|
|
613
658
|
/** The state an Apply asked for: its render brings the listing's top into view again (late layout, a smooth scroll cut short). */
|
|
614
659
|
private topFor: string | null = null;
|
|
660
|
+
/**
|
|
661
|
+
* An Apply's swap (./applied.ts): the state it asked for ('' until its
|
|
662
|
+
* navigation; the old list dimmed meanwhile, `data-lbx-swap`); its words
|
|
663
|
+
* and state while its render puts the new tiles in (swapIn(), from the
|
|
664
|
+
* step that draws them); then the status shown for the state applied,
|
|
665
|
+
* what takes it away, and when.
|
|
666
|
+
*/
|
|
667
|
+
private applyCue: { href: string } | null = null;
|
|
668
|
+
private swapping: { say: string; href: string } | null = null;
|
|
669
|
+
private cue: { end: (fade: boolean) => void; href: string; timer: number } | null = null;
|
|
670
|
+
/** What the applied state's render announces (the status's words). */
|
|
671
|
+
private appliedSay: string | null = null;
|
|
615
672
|
/** The top of the listing in view when it is above the viewport (smooth, unless reduced motion is asked for). */
|
|
616
673
|
private toListTop() {
|
|
617
674
|
if (!this.isConnected || this.getBoundingClientRect().top >= 0) return;
|
|
618
|
-
|
|
619
|
-
this.scrollIntoView({ block: 'start', behavior: (reduce ? 'instant' : 'smooth') as ScrollBehavior });
|
|
675
|
+
this.scrollIntoView({ block: 'start', behavior: (reduced() ? 'instant' : 'smooth') as ScrollBehavior });
|
|
620
676
|
}
|
|
621
677
|
|
|
622
678
|
/** The drawer's held changes dropped: it shows the applied state again. */
|
|
623
679
|
private discardStaged() {
|
|
624
680
|
if (!this.staged) return;
|
|
625
681
|
this.staged = null;
|
|
682
|
+
// A fetch of what it held, started already, is left to expire (./intent.ts); one not started yet is not.
|
|
683
|
+
clearTimeout(this.heldTimer);
|
|
684
|
+
this.heldTimer = 0;
|
|
626
685
|
this.redraw();
|
|
627
686
|
}
|
|
628
687
|
|
|
@@ -647,6 +706,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
647
706
|
if (!open) this.discardStaged();
|
|
648
707
|
if (open && !was) {
|
|
649
708
|
this.stagedMode = atWidth(cfg.facetsApply, 'instant', viewport) === 'staged';
|
|
709
|
+
if (this.stagedMode) askApplied();
|
|
650
710
|
if (this.stagedMode && !this.drawerEntry)
|
|
651
711
|
try {
|
|
652
712
|
history.pushState(null, '', location.href);
|
|
@@ -696,8 +756,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
696
756
|
/**
|
|
697
757
|
* The open drawer closes when the viewport crosses into another
|
|
698
758
|
* `facetsApply` mode (a phone rotated, a window widened): the drawer was a
|
|
699
|
-
* phone's, and what it held was never confirmed. Regression (a
|
|
700
|
-
*
|
|
759
|
+
* phone's, and what it held was never confirmed. Regression (a production
|
|
760
|
+
* storefront, `{ 0: 'staged', 992: 'instant' }`, 2026-10-14): a value ticked in
|
|
701
761
|
* the drawer at 820 px stayed ticked in the 992 px sidebar with the
|
|
702
762
|
* results unfiltered and no apply button there, and rotating back showed
|
|
703
763
|
* the full-screen drawer again (`data-drawer-open` had stayed). Dropping
|
|
@@ -839,12 +899,18 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
839
899
|
return w;
|
|
840
900
|
}
|
|
841
901
|
|
|
842
|
-
/**
|
|
902
|
+
/**
|
|
903
|
+
* UI -> URL. The resulting URL change drives sync(). In a drawer that
|
|
904
|
+
* stages: held there, its state fetched in the background once the
|
|
905
|
+
* changes pause (nothing drawn from it until Apply).
|
|
906
|
+
*/
|
|
843
907
|
go(patch: Partial<UiState>) {
|
|
844
908
|
if (!this.current) return;
|
|
845
909
|
this.intent?.cancel(); // a dwell still pending: this click's own state is what is fetched now
|
|
846
910
|
if (this.staging()) {
|
|
847
911
|
this.staged = nextState(this.staged ?? this.wanted ?? stateOf(this.current), this.shown ?? stateOf(this.current), patch);
|
|
912
|
+
clearTimeout(this.heldTimer);
|
|
913
|
+
if (DRAWER && prefetchFilters) this.heldTimer = window.setTimeout(() => this.staging() && this.staged && this.prefetchHeld(this.staged), SETTLE_MS);
|
|
848
914
|
return this.redraw();
|
|
849
915
|
}
|
|
850
916
|
this.navigate(nextState(this.wanted ?? stateOf(this.current), this.shown ?? stateOf(this.current), patch));
|
|
@@ -1027,7 +1093,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1027
1093
|
this.message = null;
|
|
1028
1094
|
// Another state of the visit shown (a filter, a sort): its sidebar
|
|
1029
1095
|
// stays until the new facets come, as from the intent prefetch.
|
|
1030
|
-
// Bug (a
|
|
1096
|
+
// Bug (a production storefront, 2026-10-15): drawn with no
|
|
1031
1097
|
// facets, the sidebar collapsed to one group for ~250 ms and
|
|
1032
1098
|
// Chromium's scroll anchoring threw the page to its top.
|
|
1033
1099
|
const last = this.last;
|
|
@@ -1099,6 +1165,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1099
1165
|
}
|
|
1100
1166
|
this.images();
|
|
1101
1167
|
if (tk.state === 'ours') this.watch();
|
|
1168
|
+
if (DRAWER) this.swapIn(); // an Apply's new tiles, if these are they
|
|
1102
1169
|
}
|
|
1103
1170
|
|
|
1104
1171
|
/** Images a render put in that are there already (cached, persisted tiles) lose their placeholder now: no flash, no fade. */
|
|
@@ -1124,7 +1191,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1124
1191
|
this.leftAt = Math.round(scrollY);
|
|
1125
1192
|
// The browser's own restoration off for this entry: Luma places the page after its render (holdScroll).
|
|
1126
1193
|
const sr = holdScroll();
|
|
1127
|
-
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`);
|
|
1194
|
+
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`);
|
|
1128
1195
|
else releaseScroll();
|
|
1129
1196
|
}
|
|
1130
1197
|
|
|
@@ -1260,25 +1327,89 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1260
1327
|
private onIntent(el: Element) {
|
|
1261
1328
|
const cur = this.current;
|
|
1262
1329
|
// In a drawer that stages, a control leads nowhere until Apply: nothing to fetch ahead.
|
|
1263
|
-
if (this.staging()) return;
|
|
1264
|
-
if (!cur || !this.last || this.lastQ !== cur || this.inFlight || this.wanted || tk.state !== 'ours' || thrifty()) return;
|
|
1330
|
+
if (this.staging() || !this.settled(cur)) return;
|
|
1265
1331
|
// "Show more" (`data-lbx-more`): its next page, from the cursor, as nearing the end does.
|
|
1266
|
-
if (el.matches('[data-lbx-more]')) return this.prefetchNext(cur, this.last);
|
|
1332
|
+
if (el.matches('[data-lbx-more]')) return this.prefetchNext(cur, this.last!);
|
|
1267
1333
|
const raw = targetOf(el);
|
|
1268
1334
|
if (!raw) return;
|
|
1269
1335
|
const url = new URL(raw, location.href);
|
|
1270
1336
|
if (url.origin !== location.origin || url.pathname !== location.pathname || !route.accepts(url)) return;
|
|
1337
|
+
this.prefetchState(url, 'on intent');
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* What the open staged drawer holds, fetched in the background (its
|
|
1342
|
+
* changes paused for SETTLE_MS, or Apply came first): its first page into
|
|
1343
|
+
* `early`, which Apply's sync() takes, done or in flight. Nothing is drawn
|
|
1344
|
+
* from it, no hook runs, no URL is written: the results change on Apply
|
|
1345
|
+
* only. A newer held state aborts the older one still out; back at the
|
|
1346
|
+
* state shown, nothing is fetched. Limits as on intent (thrifty(), the
|
|
1347
|
+
* visit's cap; none with `prefetchFilters` off: no timer is set).
|
|
1348
|
+
*/
|
|
1349
|
+
private prefetchHeld(st: UiState) {
|
|
1350
|
+
if (!DRAWER) return;
|
|
1351
|
+
clearTimeout(this.heldTimer);
|
|
1352
|
+
this.heldTimer = 0;
|
|
1353
|
+
const cur = this.current;
|
|
1354
|
+
if (!this.settled(cur)) return;
|
|
1355
|
+
const why = "of the drawer's held state";
|
|
1356
|
+
// The request this held state starts (or finds cached, or none): the older one still out goes.
|
|
1357
|
+
const keyed = (k: string | null) => {
|
|
1358
|
+
const was = this.heldKey;
|
|
1359
|
+
if (was && was !== k) early.drop(was);
|
|
1360
|
+
this.heldKey = k;
|
|
1361
|
+
};
|
|
1362
|
+
// From a timer: a route hook that throws (fromUrl) must not reach the
|
|
1363
|
+
// site. Nothing is fetched; the drawer keeps what it holds, and Apply
|
|
1364
|
+
// reports the hook's failure as any navigation does.
|
|
1365
|
+
try {
|
|
1366
|
+
const url = route.write(new URL(location.href), st);
|
|
1367
|
+
if (canon(url.href) === this.href) return keyed(null);
|
|
1368
|
+
// The query Apply's sync() will resolve from that URL: the same
|
|
1369
|
+
// request, to the order of the values (a value map puts some in the
|
|
1370
|
+
// site's param, the others in ours, read back first). When the route
|
|
1371
|
+
// has it at hand (memoised), that one. Else the state held, at once
|
|
1372
|
+
// (resolving the URL's slugs may ask the category's values first:
|
|
1373
|
+
// two requests in a row), and once resolved, the URL's own query
|
|
1374
|
+
// supersedes it if its request differs (the same: nothing more).
|
|
1375
|
+
const r = route.resolve(url);
|
|
1376
|
+
if (!isPromise(r)) return this.prefetchState(r, why, keyed);
|
|
1377
|
+
this.prefetchState({ ...cur, ...st }, why, keyed);
|
|
1378
|
+
r.then((q) => void (this.staged === st && this.prefetchState(q, why, keyed))).catch(prefetchFailed);
|
|
1379
|
+
} catch (e) {
|
|
1380
|
+
prefetchFailed(e);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/** A listing of ours to fetch ahead from: the current state shown, nothing pending for it, a connection that allows it. */
|
|
1385
|
+
private settled(cur: Query | null): cur is Query {
|
|
1386
|
+
return !!cur && !!this.last && this.lastQ === cur && !this.inFlight && !this.wanted && tk.state === 'ours' && !thrifty();
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Another state of this visit (a control's URL, or a held state's
|
|
1391
|
+
* query), not cached: its first page, the request sync() starts with
|
|
1392
|
+
* (`firstRequest`), fetched ahead into `early` and never rendered from
|
|
1393
|
+
* here. `keyed`: told the request's key (null: none) before it starts,
|
|
1394
|
+
* also when there is nothing to start (cached).
|
|
1395
|
+
*/
|
|
1396
|
+
private prefetchState(to: URL | Query | null, why: string, keyed?: (k: string | null) => void) {
|
|
1397
|
+
const cur = this.current!;
|
|
1271
1398
|
const from = this.href;
|
|
1272
|
-
if (canon(url.href) === from) return;
|
|
1273
1399
|
const then = (q: Query | null) => {
|
|
1274
|
-
if (!q || this.href !== from || this.inFlight || this.wanted || visitOf(q) !== visitOf(cur)
|
|
1400
|
+
if (!q || this.href !== from || this.inFlight || this.wanted || visitOf(q) !== visitOf(cur)) return;
|
|
1275
1401
|
const req = firstRequest(ctx.global, cfg, route, q);
|
|
1402
|
+
const k = req && JSON.stringify(req);
|
|
1403
|
+
keyed?.(k);
|
|
1404
|
+
if (!k || cache.get(keyOf(q))) return;
|
|
1276
1405
|
// Its first images too (`_global.images.warm`, low priority): the click then shows them at once.
|
|
1277
1406
|
const fetch = (signal: AbortSignal) => ctx.api.search(req!, signal).then((p) => (warm > 0 && warmImages(firstRowImages(imgs, p.hits.slice(0, warm).map(ctx.result), true), warm), p));
|
|
1278
|
-
if (
|
|
1407
|
+
if (early.start(k, visitOf(q), fetch)) mark(`${route.id} prefetch ${why}`);
|
|
1279
1408
|
};
|
|
1280
|
-
|
|
1281
|
-
if (
|
|
1409
|
+
if (!(to instanceof URL)) return then(to);
|
|
1410
|
+
if (canon(to.href) === from) return;
|
|
1411
|
+
const r = route.resolve(to);
|
|
1412
|
+
if (isPromise(r)) r.then(then).catch(prefetchFailed);
|
|
1282
1413
|
else then(r);
|
|
1283
1414
|
}
|
|
1284
1415
|
|
|
@@ -1287,6 +1418,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1287
1418
|
if (res !== this.last || q !== this.lastQ || !hasMore(res) || nextOf.has(res)) return;
|
|
1288
1419
|
mark(`${route.id} prefetch page ${res.page + 1}`);
|
|
1289
1420
|
more(q, res).catch(() => {}); // a failed one: the click asks again
|
|
1421
|
+
// `variants`: its products' groups too (more() asks once), so "show more" draws them with its tiles.
|
|
1422
|
+
if (VARIANTS && cfg.variants) more(q, res).then((r) => askVariants(ctx.api, cfg, r, this.ctrl?.signal), () => {});
|
|
1290
1423
|
}
|
|
1291
1424
|
|
|
1292
1425
|
private render(q: Query, res: ListingResult, url: URL) {
|
|
@@ -1299,14 +1432,63 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1299
1432
|
this.waited = null;
|
|
1300
1433
|
const was = this.last;
|
|
1301
1434
|
const quiet = this.quiet;
|
|
1435
|
+
// An Apply's swap (./applied.ts): any render after its navigation ends
|
|
1436
|
+
// it; the applied state's own render, not an error's, says what was
|
|
1437
|
+
// applied, and its first step brings the new tiles in (swapIn()).
|
|
1438
|
+
const href = DRAWER ? canon(url.href) : '';
|
|
1439
|
+
const cue = DRAWER && this.applyCue?.href ? this.applyCue : null;
|
|
1440
|
+
const say = cue && href === cue.href && !this.message ? t('applied', { results: found(q, res) }) : null;
|
|
1441
|
+
if (cue) this.applyCue = null;
|
|
1442
|
+
if (DRAWER && this.cue && this.cue.href !== href) this.endCue();
|
|
1443
|
+
// A render of the same state before the swap's step drew its tiles (the
|
|
1444
|
+
// facets, before a first step a frame late on a phone): it carries the
|
|
1445
|
+
// swap on; it dropped it, and the list stayed dimmed for the visit.
|
|
1446
|
+
if (DRAWER) (this.appliedSay = say), (this.swapping = say && tk.state === 'ours' ? { say, href } : this.swapping?.href === href ? this.swapping : null);
|
|
1302
1447
|
this.guarded(() => this.draw(q, res, url));
|
|
1303
|
-
if (
|
|
1448
|
+
if (DRAWER) (this.appliedSay = null), !this.swapping && this.removeAttribute('data-lbx-swap');
|
|
1449
|
+
if (!say && w && !w.dimmed && was && !quiet && tk.state === 'ours' && !sameHits(res, was)) this.fadeIn();
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* The applied state's tiles are in (the render's first step): the old
|
|
1454
|
+
* list's dim ends, those in view come in, and the status says what was
|
|
1455
|
+
* applied for APPLIED_MS (`data-lbx-applied` on the element meanwhile,
|
|
1456
|
+
* for the site's own cue). Without the cue's chunk (not loaded yet, or
|
|
1457
|
+
* failed), only the dim ends: the status region still says it.
|
|
1458
|
+
*/
|
|
1459
|
+
private swapIn() {
|
|
1460
|
+
const s = DRAWER ? this.swapping : null;
|
|
1461
|
+
if (!s) return;
|
|
1462
|
+
this.swapping = null;
|
|
1463
|
+
this.removeAttribute('data-lbx-swap');
|
|
1464
|
+
if (!applied) return;
|
|
1465
|
+
this.endCue();
|
|
1466
|
+
const tiles = [...(this.querySelector('[data-lbx-list]')?.children ?? [])].slice(0, SWAP_TILES);
|
|
1467
|
+
let end: (fade: boolean) => void;
|
|
1468
|
+
try {
|
|
1469
|
+
end = applied.swapIn(this, tiles, s.say, this.a11y?.root ?? null, reduced());
|
|
1470
|
+
} catch (e) {
|
|
1471
|
+
// In the render's step: a failure of the cue costs the cue, never the listing (guarded() would hand it back).
|
|
1472
|
+
return reportError(`${route.id} applied cue`, e);
|
|
1473
|
+
}
|
|
1474
|
+
this.setAttribute('data-lbx-applied', '');
|
|
1475
|
+
this.cue = { end, href: s.href, timer: window.setTimeout(() => this.endCue(true), APPLIED_MS) };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/** The status of an Apply taken away: faded out once its time is up, at once when another state renders. */
|
|
1479
|
+
private endCue(fade = false) {
|
|
1480
|
+
const c = DRAWER ? this.cue : null;
|
|
1481
|
+
if (!c) return;
|
|
1482
|
+
this.cue = null;
|
|
1483
|
+
clearTimeout(c.timer);
|
|
1484
|
+
this.removeAttribute('data-lbx-applied');
|
|
1485
|
+
c.end(fade);
|
|
1304
1486
|
}
|
|
1305
1487
|
|
|
1306
1488
|
/** The list fading in from FADE_FROM (Web Animations: no class to restart, no layout read); not under reduced motion. */
|
|
1307
1489
|
private fadeIn() {
|
|
1308
1490
|
const list = this.querySelector('[data-lbx-list]');
|
|
1309
|
-
if (!list || typeof list.animate !== 'function' ||
|
|
1491
|
+
if (!list || typeof list.animate !== 'function' || reduced()) return;
|
|
1310
1492
|
this.fade?.cancel();
|
|
1311
1493
|
this.fade = list.animate([{ opacity: FADE_FROM }, { opacity: 1 }], { duration: FADE_MS, easing: 'cubic-bezier(.2,0,0,1)' });
|
|
1312
1494
|
}
|
|
@@ -1318,6 +1500,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1318
1500
|
} catch (e) {
|
|
1319
1501
|
reportError(`${route.id} render`, e);
|
|
1320
1502
|
this.setBusy(false);
|
|
1503
|
+
// An Apply's swap under way: the list handed back is not left dimmed.
|
|
1504
|
+
if (DRAWER) (this.swapping = null), this.removeAttribute('data-lbx-swap');
|
|
1321
1505
|
// The server's markup is still the page's: back it goes (the takeover keeps a listing that is there).
|
|
1322
1506
|
if (this.server) {
|
|
1323
1507
|
delete (this as any)._$litPart$;
|
|
@@ -1401,7 +1585,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1401
1585
|
const self = this;
|
|
1402
1586
|
return {
|
|
1403
1587
|
...ctx.helpers,
|
|
1404
|
-
...listingData(ctx, route, url, q, res, this.message, this.view()),
|
|
1588
|
+
...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()),
|
|
1405
1589
|
facetsOpen: matchMedia('(min-width: 768px)').matches,
|
|
1406
1590
|
// actions
|
|
1407
1591
|
go: (link: Link | null) => (e: Event) => {
|
|
@@ -1450,6 +1634,9 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1450
1634
|
}
|
|
1451
1635
|
|
|
1452
1636
|
private draw(q: Query, res: ListingResult, url: URL) {
|
|
1637
|
+
// `variants`: the groups of these products, asked for now (those not
|
|
1638
|
+
// asked yet), never waited for: finish() draws them when they land.
|
|
1639
|
+
if (VARIANTS && cfg.variants) askVariants(ctx.api, cfg, res, this.ctrl?.signal);
|
|
1453
1640
|
// A first render in steps of these same products (shown before their
|
|
1454
1641
|
// facets): it goes on with the facets, not a render of them all at once.
|
|
1455
1642
|
if (this.stepping?.hits === res.hits) return this.stepping.take(q, res, url);
|
|
@@ -1461,7 +1648,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1461
1648
|
const before = this.lastQ;
|
|
1462
1649
|
const grew = !!before && !!this.last && keyOf(q) === keyOf(before) && res.page > this.last.page && res.guid === this.last.guid;
|
|
1463
1650
|
this.shownBefore = grew ? (this.last?.hits.length ?? 0) : 0;
|
|
1464
|
-
const said = this.message ?? (grew ? t('shown', { shown: res.hits.length, total: res.total, count: res.total }) :
|
|
1651
|
+
const said = this.message ?? this.appliedSay ?? (grew ? t('shown', { shown: res.hits.length, total: res.total, count: res.total }) : found(q, res));
|
|
1465
1652
|
// A quiet render keeps what is still to be said: the facets' render (said
|
|
1466
1653
|
// with the products) cut short their redraw in steps, before it spoke,
|
|
1467
1654
|
// and a filter was never announced (found by Luma's accessibility tests on a phone, 2026-10-11).
|
|
@@ -1783,6 +1970,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1783
1970
|
}
|
|
1784
1971
|
/** Tiles in the viewport (an IntersectionObserver), whether the list's last one is above it, the grid's columns (read with layout clean). */
|
|
1785
1972
|
private seen = new Set<Element>();
|
|
1973
|
+
/** `variants`: the groups the last scope of a result was built with (its map grows in steps as they land: finish()). */
|
|
1974
|
+
declare private groups$: { res: ListingResult; map?: ReadonlyMap<string, VariantGroup> } | undefined;
|
|
1786
1975
|
private past = false;
|
|
1787
1976
|
private cols = 1;
|
|
1788
1977
|
private io: IntersectionObserver | null = null;
|
|
@@ -1814,6 +2003,52 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1814
2003
|
this.drawn$ = { hits: res.hits, n: res.hits.length };
|
|
1815
2004
|
this.lastQ = q;
|
|
1816
2005
|
this.afterA11y();
|
|
2006
|
+
// `variants`: drawn again once the groups of these products asked for
|
|
2007
|
+
// have landed (now: during a render in steps), if they are still shown
|
|
2008
|
+
// (a later render of other products draws its own).
|
|
2009
|
+
if (VARIANTS && cfg.variants)
|
|
2010
|
+
askVariants(ctx.api, cfg, res, this.ctrl?.signal).then(() => {
|
|
2011
|
+
const got = groupsOf(res);
|
|
2012
|
+
const had = this.groups$?.map;
|
|
2013
|
+
if (!got || had === got) return;
|
|
2014
|
+
// In steps, as a shown listing's re-render (each tile whose group
|
|
2015
|
+
// landed runs its template again): the tiles in view first, then
|
|
2016
|
+
// as many a frame as BUDGET holds by what one cost.
|
|
2017
|
+
const tiles = this.querySelector('[data-lbx-list]')?.children;
|
|
2018
|
+
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);
|
|
2019
|
+
const N = res.hits.length;
|
|
2020
|
+
let n = 0;
|
|
2021
|
+
let per = this.tileMs || 3;
|
|
2022
|
+
let frames = 0;
|
|
2023
|
+
const step = () => {
|
|
2024
|
+
if (this.last !== res || this.drawn$?.hits !== res.hits || this.groups$?.res !== res || !this.drawn) return;
|
|
2025
|
+
// STEP tiles first, to measure one (a tile drawn anew with its group
|
|
2026
|
+
// costs what a new tile does: the tiles' cost learnt from a render
|
|
2027
|
+
// in steps was 0.3 ms for 2 ms tiles, one step of all of them); then
|
|
2028
|
+
// as a re-render in steps: as many as BUDGET holds with the tiles
|
|
2029
|
+
// there run again, not even one, a third of the rest.
|
|
2030
|
+
const room = Math.floor((BUDGET - RERUN * per * N) / per);
|
|
2031
|
+
const k = Math.min(ids.length - n, !frames ? STEP : room >= 1 ? room : Math.ceil((ids.length - n) / 3));
|
|
2032
|
+
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)]);
|
|
2033
|
+
n += k;
|
|
2034
|
+
this.groups$ = { res, map };
|
|
2035
|
+
// A tile's template may draw other markup with its group: focus in one replaced goes to the region, not the page's start.
|
|
2036
|
+
const at = document.activeElement;
|
|
2037
|
+
this.focusWas = at && at !== this && this.contains(at) ? at : null;
|
|
2038
|
+
const t0 = performance.now();
|
|
2039
|
+
// Only the results change: the scope is not built again (as setUi). A
|
|
2040
|
+
// template throwing on the groups hands the page back, as a render does, and no step follows.
|
|
2041
|
+
this.guarded(() => {
|
|
2042
|
+
this.renderOrRebuild((this.drawn = { ...this.drawn!, results: res.hits.map(withGroups(ctx.result, map)) }));
|
|
2043
|
+
this.afterA11y();
|
|
2044
|
+
per = Math.max(0.5, (performance.now() - t0) / (k + RERUN * (N - k)));
|
|
2045
|
+
frames++;
|
|
2046
|
+
if (n < ids.length) afterFrame(step);
|
|
2047
|
+
else mark(`${route.id} variants in ${frames} frames`);
|
|
2048
|
+
});
|
|
2049
|
+
};
|
|
2050
|
+
step();
|
|
2051
|
+
});
|
|
1817
2052
|
const info = { route: route.id, query: q.title, total: res.total, shown: res.hits.length, page: res.page, url };
|
|
1818
2053
|
// A prerendered page (a search typed on the page before) is not seen
|
|
1819
2054
|
// yet: the hook (titles, and often the site's analytics) runs when it
|
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
// Results are never rendered by the prefetch itself: only a click whose
|
|
22
22
|
// request is the same takes them.
|
|
23
23
|
//
|
|
24
|
+
// A drawer that stages (`facetsApply: 'staged'`) has no intent to watch: its
|
|
25
|
+
// controls lead nowhere until Apply. There the held state itself is fetched
|
|
26
|
+
// once the changes pause for SETTLE_MS (three quick ticks: one request), a
|
|
27
|
+
// newer held state aborting the older one still out; Apply takes it as a
|
|
28
|
+
// click does. Closing the drawer otherwise leaves it to expire.
|
|
29
|
+
//
|
|
24
30
|
// Limits: two in flight (a newer intent aborts the oldest), the state
|
|
25
31
|
// changing aborts those still out (their controls now lead elsewhere), kept
|
|
26
32
|
// TTL_MS, PER_VISIT per category or query, none with Save-Data, offline or on
|
|
@@ -36,6 +42,8 @@ import { reportError } from '../../core/health';
|
|
|
36
42
|
|
|
37
43
|
/** Hover or focus this long counts as intent (Chrome's speculation rules wait ~200 ms on hover for a whole page; this is one request). */
|
|
38
44
|
export const DWELL_MS = 100;
|
|
45
|
+
/** A staged drawer's changes pause this long before its held state is fetched (element.ts `prefetchHeld`). */
|
|
46
|
+
export const SETTLE_MS = 250;
|
|
39
47
|
const TTL_MS = 30_000;
|
|
40
48
|
/** First pages kept (done or in flight). */
|
|
41
49
|
const KEEP = 6;
|
|
@@ -181,6 +189,13 @@ export const firstPages = () => {
|
|
|
181
189
|
while (m.size > KEEP) m.delete(m.keys().next().value!);
|
|
182
190
|
return true;
|
|
183
191
|
},
|
|
192
|
+
/** One still out, no longer wanted (a staged drawer's older held state): aborted. A done one stays until it expires. */
|
|
193
|
+
drop(k: string) {
|
|
194
|
+
const e = m.get(k);
|
|
195
|
+
if (!e || e.settled) return;
|
|
196
|
+
e.ctrl.abort();
|
|
197
|
+
m.delete(k);
|
|
198
|
+
},
|
|
184
199
|
/** The state changed: requests still out were for controls that now lead elsewhere. */
|
|
185
200
|
abortInFlight() {
|
|
186
201
|
for (const [k, e] of m)
|