@luigisbox/luma 0.4.0 → 0.5.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +75 -8
  3. package/build/baseline.mjs +2 -2
  4. package/build/vite.mjs +253 -20
  5. package/docs/ACCESSIBILITY.md +9 -2
  6. package/package.json +3 -2
  7. package/src/analytics/chain.ts +1 -1
  8. package/src/analytics/pageview.ts +1 -1
  9. package/src/analytics/start.ts +3 -4
  10. package/src/analytics/wire.ts +1 -1
  11. package/src/api/ai.ts +32 -20
  12. package/src/api/token.ts +7 -7
  13. package/src/core/a11y.ts +6 -35
  14. package/src/core/context.ts +2 -0
  15. package/src/core/contrast-toggle.ts +32 -0
  16. package/src/core/default-button.ts +66 -0
  17. package/src/core/early-rules.ts +3 -2
  18. package/src/core/features.ts +109 -0
  19. package/src/core/frameworks-check.ts +32 -0
  20. package/src/core/frameworks.ts +35 -0
  21. package/src/core/image-load.ts +0 -26
  22. package/src/core/image-preload.ts +29 -0
  23. package/src/core/image-tags.ts +7 -1
  24. package/src/core/images.ts +3 -2
  25. package/src/core/integration.ts +42 -26
  26. package/src/core/listings.ts +9 -5
  27. package/src/core/locales.ts +2 -1
  28. package/src/core/own.ts +7 -3
  29. package/src/core/recommenders.ts +2 -2
  30. package/src/core/reconciler.ts +32 -23
  31. package/src/core/routes.ts +7 -6
  32. package/src/core/scheduler.ts +39 -5
  33. package/src/core/speculation.ts +2 -0
  34. package/src/core/stash.ts +15 -6
  35. package/src/core/styles.ts +6 -5
  36. package/src/core/support.ts +1 -1
  37. package/src/core/takeover.ts +6 -5
  38. package/src/core/templates.ts +5 -2
  39. package/src/core/tiles.ts +33 -10
  40. package/src/core/url.ts +6 -3
  41. package/src/core/widget-inits.ts +4 -1
  42. package/src/lit.ts +2 -1
  43. package/src/locales/cs.json +0 -1
  44. package/src/locales/de.json +0 -1
  45. package/src/locales/en.json +0 -1
  46. package/src/main.ts +70 -53
  47. package/src/types.ts +29 -9
  48. package/src/widgets/autocomplete/index.ts +58 -19
  49. package/src/widgets/listing/element.ts +81 -58
  50. package/src/widgets/listing/query.ts +6 -3
  51. package/src/widgets/listing/stash.ts +33 -16
  52. package/src/widgets/listing/state.ts +12 -0
  53. package/src/widgets/listing/view.ts +2 -1
  54. package/src/widgets/plp/route.ts +3 -2
  55. package/src/widgets/recommender/index.ts +3 -2
  56. package/src/widgets/search/direct.ts +39 -0
  57. package/src/widgets/search/form-url.ts +38 -34
  58. package/src/widgets/search/in-place.ts +43 -108
  59. package/src/widgets/search/index.ts +7 -2
  60. package/src/widgets/search/speculate.ts +48 -27
  61. package/src/widgets/search/submit.ts +136 -0
@@ -13,14 +13,16 @@
13
13
  // (lit SSR) instead of rebuilding it, so nothing is fetched or repainted on
14
14
  // load. From then on the listing is client-side as usual.
15
15
 
16
- import { borrow, contrastToggle, liveRegion, useContrast } from '../../core/a11y';
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
19
  import { mark, reportError } from '../../core/health';
19
20
  import { claim } from '../../core/reconciler';
20
21
  import { afterLayout, rememberHeight } from '../../core/hold';
21
22
  import { hasPrefetch, observe, peekPrefetch, peekProducts, productsChannel, putPrefetch, takePrefetch, typicalMs, type Prefetch } from '../../core/prefetch';
22
23
  // Through the support chunk, which every widget loads with its own (src/main.ts load()).
23
- import { firstRowCount, firstRowImages, imageHelpers, imageOf, preloadImages, settleImages, warmImages } from '../../core/support';
24
+ import { preloadImages } from '../../core/image-preload';
25
+ import { firstRowCount, firstRowImages, imageHelpers, imageOf, settleImages, warmImages } from '../../core/support';
24
26
  import { view } from '../../core/templates';
25
27
  import { afterFrame, isPromise } from '../../core/scheduler';
26
28
  import { onUrlChange } from '../../core/sense';
@@ -31,13 +33,14 @@ import { canon, pageKey, writeUrl } from '../../core/url';
31
33
  import { lru } from '../../core/storage';
32
34
  import type { WidgetContext } from '../../main';
33
35
  import type { Link } from '../../types';
34
- import { facetFields, handsBack, nextState, stateOf, type Query, type Route, type UiState } from './state';
36
+ import { facetFields, handsBack, nextState, queryKey, stateOf, type Query, type Route, type UiState } from './state';
37
+ import { specKey } from '../../core/stash';
35
38
  import { firstRequest, hasMore, listingMore, listingQuery, type ListingConfig, type ListingResult } from './query';
36
39
  import { flowKey, notAbove, onQuickLanded, quickChannel } from './flow';
37
40
  import { firstPages, targetOf, thrifty, watchIntent } from './intent';
38
41
  import type { Page } from '../../api';
39
42
  import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, viewport, type ListingView, type SsrState } from './view';
40
- import { dropStash, peekStash, readStash, writeStash, type Stash } from './stash';
43
+ import { dropStash, peekStash, readStash, removeStash, writeStash, type Stash } from './stash';
41
44
  import { adoptScroll, holdScroll, keepViewport, leavingLink, nearEnd, preconnectImages, releaseScroll, restoreScroll, scrollSpot } from './instant';
42
45
  import { watch } from '../../core/tiles';
43
46
 
@@ -88,7 +91,7 @@ const sameUrl = (u: unknown, href: string) => {
88
91
  return false;
89
92
  }
90
93
  };
91
- const keyOf = (q: Query) => JSON.stringify([q.q, q.scope, q.filters, q.flags, q.sort]);
94
+ const keyOf = queryKey;
92
95
 
93
96
  type Hydrate = (v: unknown, c: HTMLElement | ShadowRoot) => void;
94
97
 
@@ -100,7 +103,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
100
103
  useContrast();
101
104
  const query = (q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void) => {
102
105
  const t0 = performance.now();
103
- const p = listingQuery(ctx.api, ctx.global, cfg, route, q, signal, pre, onProducts, quickChannel(route.id));
106
+ const p = listingQuery(ctx.api, ctx.global, cfg, route, q, signal, pre, onProducts, SEARCH ? quickChannel(route.id) : undefined);
104
107
  // A whole listing (not a first page fetched already): how long a fresh one takes here.
105
108
  if (!pre) p.then((r) => !r.partial && observe(performance.now() - t0), () => {});
106
109
  return p;
@@ -158,7 +161,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
158
161
  };
159
162
  // Filter clicks that render at once (./intent.ts): first pages fetched on intent, by their request.
160
163
  const early = firstPages();
161
- const prefetchFilters = ctx.global.prefetchFilters !== false;
164
+ const prefetchFilters = (NAMED_PREFETCH_FILTERS ? ctx.global.prefetchFilters !== false : true);
165
+ // The high-contrast toggle (`_global.contrastToggle`, off by default): its
166
+ // own chunk (lbx-contrast), asked for now where it is on, so a site without
167
+ // it does not download it with every listing.
168
+ const toggle = ctx.global.contrastToggle ? import('../../core/contrast-toggle') : null;
162
169
  const visitOf = (q: Query) => JSON.stringify([q.q, q.scope]);
163
170
  const earlyKey = (q: Query) => {
164
171
  const r = firstRequest(ctx.global, cfg, route, q);
@@ -190,7 +197,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
190
197
  return false;
191
198
  }
192
199
  };
193
- if (document.querySelector(`${route.tag}[data-lbx-ssr]`)) adopt();
200
+ if (SSR && document.querySelector(`${route.tag}[data-lbx-ssr]`)) adopt();
194
201
 
195
202
  // A result stashed for this page load (./stash.ts): the query typed on the
196
203
  // previous page, or the listing as the shopper left it (Back). Seeded like a
@@ -199,7 +206,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
199
206
  /** The page's templates prepared ahead (`rehearse`), once. */
200
207
  let rehearsed = false;
201
208
  if (!seeded.size) {
202
- stashed = readStash(route.id, location.href);
209
+ // A typed query's stash is under the query: this page's, as the search
210
+ // route reads it (sync for search; a category's lookup is not asked here).
211
+ const here = new URL(location.href);
212
+ const resolved = route.id === 'search' && route.accepts(here) ? route.resolve(here) : null;
213
+ stashed = readStash(route.id, location.href, resolved instanceof Promise ? null : resolved);
203
214
  if (stashed?.sr) adoptScroll();
204
215
  // Too old to render from: where the shopper was only (the results are asked for as on any load).
205
216
  if (stashed?.stale) mark(`${route.id} back: stash too old, its scroll only`);
@@ -209,12 +220,12 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
209
220
  mark(`${route.id} from stash (${stashed.kind}, ${stashed.result.hits.length} hits)`);
210
221
  // A prerendered page reads (and drops) it in a copy of sessionStorage; at
211
222
  // its activation the opener's storage, still holding it, comes back: used once.
212
- const at = location.href;
213
- if (stashed.kind === 'spec' && (document as any).prerendering) document.addEventListener('prerenderingchange', () => dropStash(at), { once: true });
223
+ const key = specKey(route.id, stashed.query.q ?? '');
224
+ if (stashed.kind === 'spec' && (document as any).prerendering) document.addEventListener('prerenderingchange', () => removeStash(key), { once: true });
214
225
  }
215
226
  }
216
- const restoreOn = cfg.restoreOnBack !== false;
217
- const skipOut = cfg.contentVisibility === true;
227
+ const restoreOn = (NAMED_RESTORE_ON_BACK ? cfg.restoreOnBack !== false : true);
228
+ const skipOut = CONTENT_VISIBILITY ? cfg.contentVisibility === true : false;
218
229
 
219
230
  // The request for the URL we loaded on starts now, before the element exists.
220
231
  const bootUrl = new URL(location.href);
@@ -235,7 +246,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
235
246
  // usually arrives while the chunk's templates, the fonts or the anchor are
236
247
  // still on their way. A server-rendered page has them in its HTML (the server renderer).
237
248
  const imgs = ctx.global.images ?? {};
238
- const preloadOn = imgs.preload !== false;
249
+ const preloadOn = (NAMED_PRELOAD ? imgs.preload !== false : true);
239
250
  const warm = imgs.warm ?? 0;
240
251
  const preloadRow = (res: ListingResult | null | undefined) => {
241
252
  if (res?.hits.length) preloadImages(firstRowImages(imgs, res.hits.map(ctx.result)));
@@ -398,19 +409,30 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
398
409
  };
399
410
 
400
411
  connectedCallback() {
401
- onQuickLanded(route.id, (r) => this.quickIn(r));
412
+ // The quick types landed in a result shown without them (where they do
413
+ // not sit above the products): drawn now, into the render in steps if it
414
+ // is still going on. No hook, no announcement: the same result. A
415
+ // search page's only (core/features.ts): here, not a method, so a build
416
+ // without search/ leaves it out.
417
+ if (SEARCH)
418
+ onQuickLanded(route.id, (r) => {
419
+ if (this.stepping?.hits === r.hits) return void this.stepping.again();
420
+ if (this.last !== r) return; // not shown (yet): a render of it shows them
421
+ mark(`${route.id} quick types after the products`);
422
+ this.redraw();
423
+ });
402
424
  // Not in the constructor: an element the parser creates after our class
403
425
  // is defined (an async include) is constructed before its attributes are set.
404
426
  if (this.ssr == null) {
405
427
  // Every server element is checked against its own state (a later visit's page is another page).
406
- this.ssr = this.hasAttribute('data-lbx-ssr') && adopt();
428
+ this.ssr = SSR ? this.hasAttribute('data-lbx-ssr') && adopt() : false;
407
429
  // Ours from now on, before it drops its marker (the reconciler keeps it).
408
430
  if (this.ssr) claim(route.id, this);
409
431
  // Server markup whose state we cannot use (another URL: a param the page
410
432
  // stripped, another host name): ours replaces it once it has rendered.
411
433
  // A parser-created element (an async bundle ran first) is still getting
412
434
  // its children: kept once they are all there (sync() awaits that).
413
- else if (this.hasAttribute('data-lbx-ssr')) {
435
+ else if (SSR && this.hasAttribute('data-lbx-ssr')) {
414
436
  claim(route.id, this);
415
437
  const keep = () => void (this.server = [...this.childNodes]);
416
438
  if (document.readyState === 'loading') this.cleared = new Promise<void>((r) => document.addEventListener('DOMContentLoaded', () => (keep(), r()), { once: true }));
@@ -427,20 +449,23 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
427
449
  if (!this.hasAttribute('tabindex')) this.tabIndex = -1;
428
450
  // Moved on within this document (an SPA's navigation): its scroll is the host's again.
429
451
  this.unsub = onUrlChange(() => (releaseScroll(), this.urlMoved(), this.resync()));
430
- this.unBack ??= onOwnBack(this.onBack);
452
+ // The drawer's own history entry: only where a template has a drawer (core/features.ts DRAWER).
453
+ if (DRAWER) this.unBack ??= onOwnBack(this.onBack);
431
454
  // Product clicks and add-to-carts on our tiles: the framework's, never a template's (core/tiles.ts).
432
455
  watch(this, { list: `lbx_${route.id}`, global: ctx.global, results: () => this.last?.hits.map(ctx.result) ?? [] });
433
456
  this.addEventListener('click', this.onClick);
434
457
  this.addEventListener('keydown', this.onKey);
435
458
  this.addEventListener('focusout', this.onFocusOut);
436
- document.addEventListener('click', this.onOutside, true);
437
- document.addEventListener('pointerdown', this.onOutside, true);
459
+ if (MENU || DROPDOWN) {
460
+ document.addEventListener('click', this.onOutside, true);
461
+ document.addEventListener('pointerdown', this.onOutside, true);
462
+ }
438
463
  addEventListener('pagehide', this.onPageHide);
439
464
  addEventListener('pageshow', this.onPageShow);
440
465
  addEventListener('scroll', this.onScroll, { passive: true });
441
466
  if (prefetchFilters) this.intent ??= watchIntent(this, (el) => this.onIntent(el));
442
467
  // Put back by a host re-render with its drawer open: still watched.
443
- if (this.hasAttribute('data-drawer-open') && !this.unwatchApply) this.watchApply();
468
+ if (DRAWER && this.hasAttribute('data-drawer-open') && !this.unwatchApply) this.watchApply();
444
469
  // Microtask: runs before paint (no empty frame) but outside the
445
470
  // reconciler's placement pass.
446
471
  queueMicrotask(() => this.resync());
@@ -454,8 +479,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
454
479
  this.removeEventListener('click', this.onClick);
455
480
  this.removeEventListener('keydown', this.onKey);
456
481
  this.removeEventListener('focusout', this.onFocusOut);
457
- document.removeEventListener('click', this.onOutside, true);
458
- document.removeEventListener('pointerdown', this.onOutside, true);
482
+ if (MENU || DROPDOWN) {
483
+ document.removeEventListener('click', this.onOutside, true);
484
+ document.removeEventListener('pointerdown', this.onOutside, true);
485
+ }
459
486
  removeEventListener('pagehide', this.onPageHide);
460
487
  removeEventListener('pageshow', this.onPageShow);
461
488
  removeEventListener('scroll', this.onScroll);
@@ -487,7 +514,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
487
514
  // A menu in the host's markup (their dropdown classes): [data-lbx-toggle]
488
515
  // opens/closes its [data-lbx-menu] ancestor by a data-lbx-open attribute
489
516
  // (the integration's CSS maps it onto their open state).
490
- const tg = target.closest?.('[data-lbx-toggle]');
517
+ const tg = MENU ? target.closest?.('[data-lbx-toggle]') : null;
491
518
  if (tg && this.contains(tg)) {
492
519
  e.preventDefault();
493
520
  // Ours alone: host code delegating on the same (their) dropdown classes must not toggle it too.
@@ -497,7 +524,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
497
524
  if (menu) setMenu(menu, !menu.hasAttribute('data-lbx-open'), byKeyboard(e));
498
525
  return;
499
526
  }
500
- const d = target.closest?.('[data-lbx-drawer]');
527
+ const d = DRAWER ? target.closest?.('[data-lbx-drawer]') : null;
501
528
  if (!d || !this.contains(d)) {
502
529
  // A link to another page (a product): remembered for Back.
503
530
  const away = restoreOn && leavingLink(e, this);
@@ -547,7 +574,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
547
574
  if (this.href) this.scrolls.set(this.href, this.y);
548
575
  const y = this.scrolls.get(href);
549
576
  this.restoreY = y == null ? null : { href, y };
550
- if (this.hasAttribute('data-drawer-open') && !this.drawerBack) this.setDrawer(false);
577
+ if (DRAWER && this.hasAttribute('data-drawer-open') && !this.drawerBack) this.setDrawer(false);
551
578
  }
552
579
 
553
580
  /**
@@ -567,7 +594,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
567
594
 
568
595
  /** Changes go to the drawer's held state instead of the URL: it is open, where it stages. */
569
596
  private staging() {
570
- return this.stagedMode && this.hasAttribute('data-drawer-open');
597
+ return DRAWER ? this.stagedMode && this.hasAttribute('data-drawer-open') : false;
571
598
  }
572
599
 
573
600
  /**
@@ -577,6 +604,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
577
604
  * middle, the count and the first tiles above the viewport.
578
605
  */
579
606
  private applyStaged(from?: HTMLElement) {
607
+ if (!DRAWER) return;
580
608
  const next = this.staged;
581
609
  this.staged = null;
582
610
  this.setDrawer(false, from, next ? () => (this.navigate(next), (this.topFor = this.wantedHref), this.toListTop()) : undefined);
@@ -610,6 +638,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
610
638
  * takes the entry back first, then runs `then`.
611
639
  */
612
640
  private setDrawer(open: boolean, from?: HTMLElement, then?: () => void) {
641
+ if (!DRAWER) return;
613
642
  const was = this.hasAttribute('data-drawer-open');
614
643
  this.toggleAttribute('data-drawer-open', open);
615
644
  for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(open));
@@ -675,6 +704,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
675
704
  * is safer than applying: the shopper never pressed Apply.
676
705
  */
677
706
  private watchApply() {
707
+ if (!DRAWER) return;
678
708
  const v = cfg.facetsApply;
679
709
  if (!v || typeof v !== 'object' || typeof matchMedia !== 'function') return;
680
710
  const mode = atWidth(v, 'instant', viewport);
@@ -693,14 +723,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
693
723
  */
694
724
  private onKey = (e: KeyboardEvent) => {
695
725
  const target = e.target as Element;
696
- const menu = target.closest?.('[data-lbx-menu][data-lbx-open]');
726
+ const menu = MENU ? target.closest?.('[data-lbx-menu][data-lbx-open]') : null;
697
727
  if (e.key === 'Escape') {
698
728
  if (menu && this.contains(menu)) {
699
729
  e.preventDefault();
700
730
  e.stopPropagation(); // ours, not a host modal's
701
731
  setMenu(menu, false);
702
732
  triggerOf(menu)?.focus();
703
- } else if (this.hasAttribute('data-drawer-open')) {
733
+ } else if (DRAWER && this.hasAttribute('data-drawer-open')) {
704
734
  e.preventDefault();
705
735
  e.stopPropagation();
706
736
  this.setDrawer(false);
@@ -716,7 +746,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
716
746
  items[next].focus();
717
747
  return;
718
748
  }
719
- if (e.key === 'Tab' && this.hasAttribute('data-drawer-open')) {
749
+ if (DRAWER && e.key === 'Tab' && this.hasAttribute('data-drawer-open')) {
720
750
  const panel = this.querySelector('[data-lbx-drawer-panel]');
721
751
  if (panel) trapTab(e, panel);
722
752
  return;
@@ -726,7 +756,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
726
756
 
727
757
  /** Focus leaving an open menu (Tab past it) closes it. */
728
758
  private onFocusOut = (e: FocusEvent) => {
729
- const menu = (e.target as Element).closest?.('[data-lbx-menu][data-lbx-open]');
759
+ const menu = MENU ? (e.target as Element).closest?.('[data-lbx-menu][data-lbx-open]') : null;
730
760
  if (menu && !(e.relatedTarget && menu.contains(e.relatedTarget as Node))) setMenu(menu, false);
731
761
  };
732
762
 
@@ -737,12 +767,12 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
737
767
  root.setAttribute('data-lbx-a11y', '');
738
768
  const live = liveRegion();
739
769
  root.append(live.el);
740
- if (ctx.global.contrastToggle) root.append(contrastToggle(t('contrast')));
770
+ toggle?.then((m) => root.append(m.contrastToggle(t('contrast'))), (e) => reportError(`${route.id} contrast toggle`, e));
741
771
  this.a11y = { root, live };
742
772
  }
743
773
  if (this.firstChild !== this.a11y.root) this.prepend(this.a11y.root);
744
- for (const m of this.querySelectorAll('[data-lbx-menu]')) setMenu(m, m.hasAttribute('data-lbx-open'));
745
- for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(this.hasAttribute('data-drawer-open')));
774
+ if (MENU) for (const m of this.querySelectorAll('[data-lbx-menu]')) setMenu(m, m.hasAttribute('data-lbx-open'));
775
+ if (DRAWER) for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(this.hasAttribute('data-drawer-open')));
746
776
  const was = this.focusWas;
747
777
  this.focusWas = null;
748
778
  const tile = this.moreByKey ? this.querySelector('[data-lbx-list]')?.children[this.shownBefore] : null;
@@ -770,8 +800,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
770
800
  * not clickable (a heading, the page's background), only the pointer events.
771
801
  */
772
802
  private onOutside = (e: Event) => {
773
- for (const d of this.querySelectorAll('details[data-lbx-dropdown][open]')) if (!d.contains(e.target as Node)) d.removeAttribute('open');
774
- for (const d of this.querySelectorAll('[data-lbx-menu][data-lbx-open]')) if (!d.contains(e.target as Node)) setMenu(d, false);
803
+ if (DROPDOWN) for (const d of this.querySelectorAll('details[data-lbx-dropdown][open]')) if (!d.contains(e.target as Node)) d.removeAttribute('open');
804
+ if (MENU) for (const d of this.querySelectorAll('[data-lbx-menu][data-lbx-open]')) if (!d.contains(e.target as Node)) setMenu(d, false);
775
805
  };
776
806
 
777
807
  /**
@@ -930,7 +960,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
930
960
  // A host re-render may destroy and re-create us for the same URL: render
931
961
  // synchronously from cache so the replacement never shows an empty frame.
932
962
  const hit = cache.get(key);
933
- if (hit && this.ssr && !this.hydrateWith) {
963
+ if (SSR && hit && this.ssr && !this.hydrateWith) {
934
964
  // Hydrate complete markup only: a parser-created element connects before its children are parsed.
935
965
  if (document.readyState === 'loading') await new Promise((r) => document.addEventListener('DOMContentLoaded', r, { once: true }));
936
966
  try {
@@ -962,6 +992,9 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
962
992
  const ek = !pf && prefetchFilters ? earlyKey(q) : null;
963
993
  const pre = ek ? early.take(ek) : undefined;
964
994
  early.abortInFlight();
995
+ // Server markup still being parsed (a streamed include): decided out
996
+ // here, not in the try below (Rollup folds no flag in a try).
997
+ const serverParsing = SSR ? () => !!this.ssr && document.readyState === 'loading' : () => false;
965
998
  try {
966
999
  let res: ListingResult;
967
1000
  let shownEarly = false;
@@ -1013,7 +1046,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1013
1046
  }
1014
1047
  if (shownEarly) this.quiet = true; // said with the products
1015
1048
  // Server markup still being parsed (a streamed include): render once it is complete.
1016
- if (this.ssr && document.readyState === 'loading') {
1049
+ if (serverParsing()) {
1017
1050
  await new Promise((r) => document.addEventListener('DOMContentLoaded', r, { once: true }));
1018
1051
  if (this.href !== href) return;
1019
1052
  }
@@ -1070,7 +1103,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1070
1103
 
1071
1104
  /** Images a render put in that are there already (cached, persisted tiles) lose their placeholder now: no flash, no fade. */
1072
1105
  private images() {
1073
- settleImages(this, imgs.fade !== false);
1106
+ settleImages(this, (NAMED_FADE ? imgs.fade !== false : true));
1074
1107
  }
1075
1108
 
1076
1109
  /** Render anew, dropping the state lit keeps on the container (or it reuses it and paints nothing). */
@@ -1155,11 +1188,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1155
1188
  this.ahead = null;
1156
1189
  if (skipOut) this.sizeTiles();
1157
1190
  // Not while prerendered: a next page of a page nobody may open is a request for nothing.
1158
- if (cfg.prefetchNextPage !== false && hasMore(res)) {
1191
+ if ((NAMED_PREFETCH_NEXT_PAGE ? cfg.prefetchNextPage !== false : true) && hasMore(res)) {
1159
1192
  const href = this.href;
1160
1193
  whenShown(() => this.href === href && !this.ahead && (this.ahead = nearEnd(this, () => this.prefetchNext(q, res))));
1161
1194
  }
1162
- if (res.hits[0] && ctx.global.preconnectImages !== false) preconnectImages(ctx.result(res.hits[0]).c.image);
1195
+ if (res.hits[0] && (NAMED_PRECONNECT_IMAGES ? ctx.global.preconnectImages !== false : true)) preconnectImages(ctx.result(res.hits[0]).c.image);
1163
1196
  }
1164
1197
 
1165
1198
  /**
@@ -1349,18 +1382,6 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1349
1382
  }, 600);
1350
1383
  }
1351
1384
 
1352
- /**
1353
- * The quick types landed in a result shown without them (where they do
1354
- * not sit above the products): drawn now, into the render in steps if it
1355
- * is still going on. No hook, no announcement: the same result.
1356
- */
1357
- private quickIn(r: ListingResult) {
1358
- if (this.stepping?.hits === r.hits) return void this.stepping.again();
1359
- if (this.last !== r) return; // not shown (yet): a render of it shows them
1360
- mark(`${route.id} quick types after the products`);
1361
- this.redraw();
1362
- }
1363
-
1364
1385
  /** The last render's data again with the current view state: no fetch, no announcement, focus where it is. */
1365
1386
  private redraw() {
1366
1387
  const q = this.lastQ;
@@ -1388,8 +1409,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1388
1409
  if (!link || m.button > 0 || m.metaKey || m.ctrlKey || m.shiftKey || m.altKey) return; // new tab etc.: the browser's
1389
1410
  e.preventDefault();
1390
1411
  const at = e.currentTarget as Element | null;
1391
- at?.closest('details[data-lbx-dropdown]')?.removeAttribute('open');
1392
- const menu = at?.closest('[data-lbx-menu]');
1412
+ if (DROPDOWN) at?.closest('details[data-lbx-dropdown]')?.removeAttribute('open');
1413
+ const menu = MENU ? at?.closest('[data-lbx-menu]') : null;
1393
1414
  if (menu) {
1394
1415
  setMenu(menu, false);
1395
1416
  // Its item had focus: back to the menu's button, not lost with the closed menu.
@@ -1468,11 +1489,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1468
1489
  this.drawn = data;
1469
1490
  // Server markup that is not hydrated (no cached result for it: its facets
1470
1491
  // were missing) is replaced, not rendered after.
1471
- if (this.ssr && !this.hydrateWith) {
1492
+ if (SSR && this.ssr && !this.hydrateWith) {
1472
1493
  this.ssr = false;
1473
1494
  this.server ??= [...this.childNodes];
1474
1495
  }
1475
- if (this.ssr && this.hydrateWith) {
1496
+ if (SSR && this.ssr && this.hydrateWith) {
1476
1497
  this.ssr = false;
1477
1498
  const markup = [...this.childNodes];
1478
1499
  try {
@@ -1605,6 +1626,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1605
1626
  if (!res.pending) this.setBusy(false); // the products alone: busy until their facets
1606
1627
  return this.finish(q, res, url, total, fonts, shown);
1607
1628
  }
1629
+ // The next step after a frame (afterFrame: a task soon where frames are stalled; each step's
1630
+ // wait was 500 ms then, five steps 2.5 s of nothing shown, 2026-10-19).
1608
1631
  afterFrame(() => this.renders === n && this.href === href && this.guarded(() => step(false)));
1609
1632
  };
1610
1633
  // The scope and the first step in one task were 17-18 ms on a production storefront at 4x: apart when they would not fit.
@@ -1796,7 +1819,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1796
1819
  // yet: the hook (titles, and often the site's analytics) runs when it
1797
1820
  // is shown, never for a prerender the shopper does not open.
1798
1821
  // The products alone (`pending`): the hook once the facets are there too, once a visit.
1799
- if (!res.pending)
1822
+ if (ON_RENDER && !res.pending)
1800
1823
  whenShown(() => {
1801
1824
  try {
1802
1825
  cfg.onRender?.(info);
@@ -2,6 +2,7 @@
2
2
  // (a search page's request starts before any widget chunk), the listing
3
3
  // element and the edge render service share it.
4
4
 
5
+ import { SEARCH } from '../../core/features';
5
6
  import { isPromise } from '../../core/scheduler';
6
7
  import type { Api, FacetData, FilterNode, Hit, ListingRequest, Page, Sort } from '../../api';
7
8
  import type { GlobalConfigType, ListingConfigType, SearchConfigType } from '../../types';
@@ -9,6 +10,7 @@ import { facetFields, type Query, type Route } from './state';
9
10
  import { lru } from '../../core/storage';
10
11
  import { reportError } from '../../core/health';
11
12
  import { MAX_PAGE } from '../../core/routes';
13
+ import { EMPTY_VALUES } from '../../core/features';
12
14
 
13
15
  const num = (s: string | undefined) => (s === undefined || s === '' ? null : Number(s));
14
16
  /** A range filter value `min|max` (either end may be empty). */
@@ -184,7 +186,8 @@ export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingCo
184
186
  }
185
187
  const req = (omit?: string, size = cfg.size): ListingRequest => listingRequest(g, cfg, route, q, omit, size, asValues);
186
188
  const quickKey = JSON.stringify([cfg.surface, q.q]);
187
- const wantsQuick = route.endpoint === 'search' && !!q.q && !!cfg.quick?.length;
189
+ // Quick types are a search page's (no search/ folder: none, core/features.ts).
190
+ const wantsQuick = SEARCH ? route.endpoint === 'search' && !!q.q && !!cfg.quick?.length : false;
188
191
  const quick =
189
192
  wantsQuick && cfg.quick
190
193
  ? quickMemo.get(quickKey) ?? remember(quickKey, (() => {
@@ -255,7 +258,7 @@ export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingCo
255
258
  ? facetsOf().catch((e) => ((partial = true), (e as Error)?.name === 'AbortError' || reportError('facets', e), {}))
256
259
  : {};
257
260
  // Optional too: without them no value shows as empty.
258
- const all = cfg.emptyValues && narrowed ? allOf().catch(() => undefined) : undefined;
261
+ const all = EMPTY_VALUES && cfg.emptyValues && narrowed ? allOf().catch(() => undefined) : undefined;
259
262
  // The products now, the facets when they come: a request (theirs needs
260
263
  // this result set's guid) the shopper would otherwise wait for.
261
264
  const early: ListingResult | null =
@@ -265,7 +268,7 @@ export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingCo
265
268
  if (early) onProducts!(early);
266
269
  // Not waited for (onQuick): the facets alone hold the result.
267
270
  const [facetData, quickHits, facetsAll] = await Promise.all([facets, onQuick ? quickNow ?? LATE : soon(quick), all]);
268
- if (cfg.emptyValues && !narrowed && !partial && Object.keys(facetData).length) unfiltered.set(allKey, Promise.resolve(facetData));
271
+ if (EMPTY_VALUES && cfg.emptyValues && !narrowed && !partial && Object.keys(facetData).length) unfiltered.set(allKey, Promise.resolve(facetData));
269
272
  // Shown early without them and not where they may come late: they come
270
273
  // with a later render, not this one (above the grid, they would push it down).
271
274
  const late = quickHits === LATE || (!onQuick && !!early && !Object.keys(early.quick).length && quickHits !== early.quick);
@@ -1,10 +1,14 @@
1
- // Listing results for the next page load of their URL, in sessionStorage
2
- // (`lbx:r:<pageKey>`, core/stash.ts), so that load renders at once, as from a
3
- // server-rendered state:
1
+ // Listing results for the next page load that shows them, in sessionStorage
2
+ // (core/stash.ts), so that load renders at once, as from a server-rendered
3
+ // state:
4
4
  // - `spec`: the search page of a query the shopper was typing on another page
5
- // (widgets/search/speculate.ts). Used by a plain navigation (or a prerender
6
- // of the page) within its `ttl` (2 minutes), once.
7
- // - `back`: the listing as the shopper left it for a product page (pages
5
+ // (widgets/search/speculate.ts), under the query (`lbx:r:spec:<route>:<q>`),
6
+ // not the URL: the form's URL and the page's often differ (a server that
7
+ // redirects `/search?q=x` to `/search/x/`, a `queryUrl` the form does not
8
+ // know). Used by a plain navigation (or a prerender of the page) whose URL
9
+ // resolves to the same query (its filters, flags and sort too), within its
10
+ // `ttl` (2 minutes), once.
11
+ // - `back` (`lbx:r:<pageKey>`): the listing as the shopper left it for a product page (pages
8
12
  // 1..N, where they were). Used by a back/forward load within 30 minutes, or
9
13
  // by a reload or a navigation from the page itself within a minute (a link
10
14
  // whose server redirects back to it); the listing then restores the scroll
@@ -14,10 +18,10 @@
14
18
  // Bounded: at most 3 entries (one `spec`), 400k characters each; a full store
15
19
  // drops ours first. Best-effort: storage may be unavailable, never required.
16
20
 
17
- import { navigation, STASH, stashFits, stashKey } from '../../core/stash';
21
+ import { navigation, specKey, STASH, stashFits, stashKey } from '../../core/stash';
18
22
  import { pageKey } from '../../core/page';
19
23
  import type { ListingResult } from './query';
20
- import type { Query } from './state';
24
+ import { queryKey, type Query } from './state';
21
25
  import type { Spot } from './instant';
22
26
 
23
27
  export interface Stash {
@@ -57,10 +61,14 @@ const entries = (except?: string) => {
57
61
  return out.sort((a, b) => a.at - b.at);
58
62
  };
59
63
 
60
- /** The stashed result this page load may render from, or null. `stale`: a Back entry too old to render, for its scroll only. */
61
- export const readStash = (route: string, href: string): Stash | null => {
62
- try {
63
- const key = stashKey(href);
64
+ /**
65
+ * The stashed result this page load may render from, or null: the page's
66
+ * (`back`), else the search query's (`spec`, when `query` is this page's
67
+ * resolved state, under `specKey(route, query.q)`). `stale`: a Back entry too
68
+ * old to render, for its scroll only.
69
+ */
70
+ export const readStash = (route: string, href: string, query?: Query | null): Stash | null => {
71
+ const read = (key: string): Stash | null => {
64
72
  const raw = sessionStorage.getItem(key);
65
73
  if (!raw) return null;
66
74
  const s = JSON.parse(raw) as Stash;
@@ -69,12 +77,18 @@ export const readStash = (route: string, href: string): Stash | null => {
69
77
  const fresh = s.v === 1 && age >= 0 && age < (s.ttl ?? MAX_AGE[s.kind] ?? 0);
70
78
  if (!fresh || s.kind === 'spec') sessionStorage.removeItem(key); // a speculation is used once
71
79
  if (s.route !== route || pageKey(nav.url) !== pageKey(href)) return null;
80
+ if (s.kind === 'spec' && !(query && queryKey(s.query) === queryKey(query))) return null; // the form's other fields changed since
72
81
  if (fresh && stashFits(s.kind, s.at)) return s;
73
82
  // The entry left loaded again (Back after long, a reload after the round
74
83
  // trip's minute): the browser restores nothing where Luma turned its
75
84
  // restoration off; where the shopper was is still known.
76
85
  const again = nav.type === 'back_forward' || nav.type === 'reload';
77
86
  return s.v === 1 && s.kind === 'back' && s.sr && again ? { ...s, stale: true } : null;
87
+ };
88
+ try {
89
+ // The page's own entry (Back) first; one it cannot use (a navigation from
90
+ // another page: the same query typed on a product page) does not hide the query's.
91
+ return read(stashKey(href)) ?? (query?.q ? read(specKey(route, query.q)) : null);
78
92
  } catch {
79
93
  return null;
80
94
  }
@@ -90,18 +104,21 @@ export const peekStash = (href: string): Stash | null => {
90
104
  };
91
105
 
92
106
  /** A page the browser restored itself (the back/forward cache): its stash is not needed. */
93
- export const dropStash = (href: string) => {
107
+ export const dropStash = (href: string) => removeStash(stashKey(href));
108
+
109
+ /** The entry at `key` (`stashKey` or `specKey`) is not needed any more. */
110
+ export const removeStash = (key: string) => {
94
111
  try {
95
- sessionStorage.removeItem(stashKey(href));
112
+ sessionStorage.removeItem(key);
96
113
  } catch {}
97
114
  };
98
115
 
99
- /** false: too big, or storage refused it. */
116
+ /** Under the page's URL (`back`) or the query (`spec`). false: too big, or storage refused it. */
100
117
  export const writeStash = (href: string, s: Omit<Stash, 'v' | 'at'>) => {
101
118
  try {
102
119
  const json = JSON.stringify({ at: Date.now(), v: 1, kind: s.kind, ttl: s.ttl, route: s.route, query: s.query, result: s.result, scroll: s.scroll, sr: s.sr });
103
120
  if (json.length > MAX_CHARS) return false;
104
- const key = stashKey(href);
121
+ const key = s.kind === 'spec' ? specKey(s.route, s.query.q ?? '') : stashKey(href);
105
122
  const others = entries(key);
106
123
  // One speculation at a time; the newest few overall.
107
124
  const drop = [...others.filter((e) => s.kind === 'spec' && e.spec), ...others.filter((e) => !(s.kind === 'spec' && e.spec)).slice(0, -(KEEP - 1) || undefined)];
@@ -28,6 +28,18 @@ export interface Query {
28
28
  */
29
29
  export type UiState = Pick<Query, 'filters' | 'flags' | 'sort'>;
30
30
 
31
+ /** The listing state a query is, as a key: its query or scope, filters and flags whatever their order, sort (not its title or layout). */
32
+ export const queryKey = (q: Query) =>
33
+ JSON.stringify([
34
+ q.q ?? '',
35
+ q.scope ?? null,
36
+ Object.entries(q.filters)
37
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
38
+ .map(([f, vs]) => [f, [...vs].sort()]),
39
+ [...q.flags].sort(),
40
+ q.sort,
41
+ ]);
42
+
31
43
  /** What differs between search and category pages: URL <-> state. */
32
44
  export interface Route {
33
45
  id: string;
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { WidgetContext } from '../../main';
12
12
  import type { Helpers, SearchScope } from '../../types';
13
+ import { SEARCH } from '../../core/features';
13
14
  import { fold } from '../../core/fold';
14
15
  import { navigable } from '../../core/result';
15
16
  import { hasMore, parseRange, type ListingConfig, type ListingResult } from './query';
@@ -222,7 +223,7 @@ export const listingData = (ctx: Pick<WidgetContext<ListingConfig>, 'config' | '
222
223
  showTitle: !!cfg.showTitle,
223
224
  total: res.total,
224
225
  results: res.hits.map(resultOf(ctx.result)),
225
- quick: Object.fromEntries(Object.entries(res.quick ?? {}).map(([k, hs]) => [k, hs.map(ctx.result).filter(navigable)])),
226
+ quick: SEARCH ? Object.fromEntries(Object.entries(res.quick ?? {}).map(([k, hs]) => [k, hs.map(ctx.result).filter(navigable)])) : {},
226
227
  facets,
227
228
  ranges,
228
229
  layout,
@@ -17,6 +17,7 @@ import { flagId, has, P, paramValues, plpAccepts, TRACKING, withoutTracking } fr
17
17
  import { lru, session } from '../../core/storage';
18
18
  import type { CategoryFilter, FacetLayout, GlobalConfigType, PlpConfigType } from '../../types';
19
19
  import { reportError } from '../../core/health';
20
+ import { FACETS_FOR } from '../../core/features';
20
21
  import { readOwn, type Query, type Route, type UiState } from '../listing/state';
21
22
  import { parseRange } from '../listing/query';
22
23
 
@@ -43,7 +44,7 @@ export const plpRoute = (pc: PlpConfigType, api: Api, g: GlobalConfigType): Rout
43
44
  const mappedParams = new Set(mapped.map(([, fp]) => fp.param));
44
45
  // Our value -> their value, per facet with a value map.
45
46
  const inverse = Object.fromEntries(Object.entries(facetParams).map(([facet, fp]) => [facet, fp.values ? new Map(Object.entries(fp.values).map(([h, v]) => [v, h])) : null]));
46
- const ignore = new RegExp(pm.ignore ?? TRACKING);
47
+ const ignore = RegExp(pm.ignore ?? TRACKING);
47
48
  const values = (url: URL, param: string) => paramValues(url, param, sep);
48
49
 
49
50
  /**
@@ -157,7 +158,7 @@ export const plpRoute = (pc: PlpConfigType, api: Api, g: GlobalConfigType): Rout
157
158
 
158
159
  /** The page's own facet list (`facetsFor`, _init.ts): e.g. the category's parameters. */
159
160
  const withFacets = (url: URL, q: Query): Query | Promise<Query> => {
160
- if (!pc.facetsFor) return q;
161
+ if (!FACETS_FOR || !pc.facetsFor) return q;
161
162
  const apply = (l: FacetLayout | null | undefined): Query => (l?.facets ? { ...q, facets: l.facets, layout: l.layout } : q);
162
163
  const failed = (e: unknown) => (reportError('plp facetsFor', e), q);
163
164
  try {