@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.
@@ -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
- const p = listingQuery(ctx.api, ctx.global, cfg, route, q, signal, pre, onProducts, SEARCH ? quickChannel(route.id) : undefined);
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
- * bicycle shop's QA round 4, 2026-10-15): Back after a drawer filter
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
- this.setDrawer(false, from, next ? () => (this.navigate(next), (this.topFor = this.wantedHref), this.toListTop()) : undefined);
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
- const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
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 bicycle
700
- * shop, `{ 0: 'staged', 992: 'instant' }`, 2026-10-14): a value ticked in
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
- /** UI -> URL. The resulting URL change drives sync(). In a drawer that stages: held there, nothing fetched. */
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 bicycle shop's QA round 3, 2026-10-15): drawn with no
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) || cache.get(keyOf(q))) return;
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 (req && early.start(JSON.stringify(req), visitOf(q), fetch)) mark(`${route.id} prefetch on intent`);
1407
+ if (early.start(k, visitOf(q), fetch)) mark(`${route.id} prefetch ${why}`);
1279
1408
  };
1280
- const r = route.resolve(url);
1281
- if (isPromise(r)) r.then(then).catch((e) => reportError(`${route.id} filter prefetch`, e));
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 (w && !w.dimmed && was && !quiet && tk.state === 'ours' && !sameHits(res, was)) this.fadeIn();
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' || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
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 }) : res.total ? t('results', { count: res.total, query: q.title }) : t('empty', { query: q.title }));
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)