@luigisbox/luma 0.3.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 (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +95 -14
  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 +10 -2
  8. package/src/analytics/collect.ts +14 -7
  9. package/src/analytics/from-ga4.ts +5 -2
  10. package/src/analytics/from-luma.ts +6 -4
  11. package/src/analytics/index.ts +1 -1
  12. package/src/analytics/pageview.ts +1 -1
  13. package/src/analytics/start.ts +3 -4
  14. package/src/analytics/wire.ts +1 -1
  15. package/src/api/ai.ts +32 -20
  16. package/src/api/token.ts +7 -7
  17. package/src/core/a11y.ts +6 -35
  18. package/src/core/context.ts +2 -0
  19. package/src/core/contrast-toggle.ts +32 -0
  20. package/src/core/default-button.ts +66 -0
  21. package/src/core/early-rules.ts +18 -4
  22. package/src/core/features.ts +109 -0
  23. package/src/core/frameworks-check.ts +32 -0
  24. package/src/core/frameworks.ts +35 -0
  25. package/src/core/image-load.ts +0 -26
  26. package/src/core/image-preload.ts +29 -0
  27. package/src/core/image-tags.ts +7 -1
  28. package/src/core/images.ts +3 -2
  29. package/src/core/integration.ts +42 -26
  30. package/src/core/listings.ts +9 -5
  31. package/src/core/locales.ts +2 -1
  32. package/src/core/own.ts +7 -3
  33. package/src/core/recommenders.ts +2 -2
  34. package/src/core/reconciler.ts +32 -23
  35. package/src/core/routes.ts +7 -6
  36. package/src/core/scheduler.ts +39 -5
  37. package/src/core/speculation.ts +2 -0
  38. package/src/core/stash.ts +15 -6
  39. package/src/core/styles.ts +6 -5
  40. package/src/core/support.ts +1 -1
  41. package/src/core/takeover.ts +6 -5
  42. package/src/core/templates.ts +5 -2
  43. package/src/core/tiles.ts +33 -10
  44. package/src/core/url.ts +6 -3
  45. package/src/core/widget-inits.ts +4 -1
  46. package/src/lit.ts +2 -1
  47. package/src/locales/cs.json +0 -1
  48. package/src/locales/de.json +0 -1
  49. package/src/locales/en.json +0 -1
  50. package/src/main.ts +70 -53
  51. package/src/types.ts +29 -9
  52. package/src/widgets/autocomplete/index.ts +58 -19
  53. package/src/widgets/listing/element.ts +81 -58
  54. package/src/widgets/listing/query.ts +6 -3
  55. package/src/widgets/listing/stash.ts +33 -16
  56. package/src/widgets/listing/state.ts +12 -0
  57. package/src/widgets/listing/view.ts +2 -1
  58. package/src/widgets/plp/route.ts +3 -2
  59. package/src/widgets/recommender/index.ts +3 -2
  60. package/src/widgets/search/direct.ts +39 -0
  61. package/src/widgets/search/form-url.ts +38 -34
  62. package/src/widgets/search/in-place.ts +43 -108
  63. package/src/widgets/search/index.ts +7 -2
  64. package/src/widgets/search/speculate.ts +48 -27
  65. package/src/widgets/search/submit.ts +136 -0
@@ -16,7 +16,9 @@
16
16
 
17
17
  import { borrow, liveRegion, useContrast } from '../../core/a11y';
18
18
  import { luminanceOf, readable } from '../../core/color';
19
+ import { defaultSubmitter, formDataOf } from '../../core/default-button';
19
20
  import { onOwnBack } from '../../core/entries';
21
+ import { AC_HOOKS, ANALYTICS, QUERY_URL } from '../../core/features';
20
22
  import { mark, reportError } from '../../core/health';
21
23
  import { modalOf } from '../../core/own';
22
24
  import { byName, view } from '../../core/templates';
@@ -341,13 +343,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
341
343
  const intoMirror = (e: Event) => sheet && e.composedPath()[0] === mirror;
342
344
  box.addEventListener('pointerdown', (e) => e.pointerType !== 'touch' && !intoMirror(e) && e.preventDefault());
343
345
  box.addEventListener('mousedown', (e) => !intoMirror(e) && e.preventDefault());
344
- // A middle click opens a suggestion in a new tab (no choose()): a click too (core/tiles.ts).
345
- box.addEventListener('auxclick', (e) => {
346
- if ((e as MouseEvent).button !== 1) return;
347
- const a = (e.composedPath()[0] as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
348
- const i = a ? items.findIndex((r) => r.type !== 'query' && !!r.c.url && new URL(r.c.url, location.href).href === a.href) : -1;
349
- if (i >= 0) track(i);
350
- });
346
+ // A middle click opens a suggestion in a new tab (no choose()): a click too
347
+ // (core/tiles.ts), announced for analytics only (core/features.ts ANALYTICS).
348
+ if (ANALYTICS)
349
+ box.addEventListener('auxclick', (e) => {
350
+ if ((e as MouseEvent).button !== 1) return;
351
+ const a = (e.composedPath()[0] as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
352
+ const i = a ? items.findIndex((r) => r.type !== 'query' && !!r.c.url && new URL(r.c.url, location.href).href === a.href) : -1;
353
+ if (i >= 0) track(i);
354
+ });
351
355
  // "Show all products" has focus (Tab from the input): Shift+Tab goes back
352
356
  // to the input, Escape closes and goes back, Tab moves on as from the
353
357
  // input (focus put back on it, the browser's own Tab then leaves it: the
@@ -591,12 +595,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
591
595
  host!.showPopover();
592
596
  open = true;
593
597
  aria?.set('aria-expanded', 'true');
594
- hook('onOpen', ac.onOpen);
598
+ if (AC_HOOKS) hook('onOpen', ac.onOpen);
595
599
  cancelAnimationFrame(raf);
596
600
  // A hook may move the field (a storefront's opens its search strip, the
597
601
  // input lower): placed from where it is now, not from `m`, read before
598
602
  // it (otherwise the dropdown opens at the old place, then jumps).
599
- position(false, ac.onOpen ? undefined : m);
603
+ position(false, AC_HOOKS && ac.onOpen ? undefined : m);
600
604
  }
601
605
  };
602
606
 
@@ -605,11 +609,15 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
605
609
  * the focus's frame has rendered (layout clean then), ahead of the first
606
610
  * opening: read at the opening, after the page and our render had changed,
607
611
  * they forced a style and layout into the task that shows the results.
612
+ * Not once focus has left the input: putting the dropdown in the page
613
+ * borrows its ARIA, which focus leaving had put back (Shift+Tab back and
614
+ * Tab on within a frame left role=combobox on their input: WebKit on a slow
615
+ * machine, 2026-10-15).
608
616
  */
609
617
  let prepared: HTMLInputElement | null = null;
610
618
  const prepare = (i: HTMLInputElement) =>
611
619
  afterFrame(() => {
612
- if (open || input !== i || !i.isConnected || sheet) return;
620
+ if (open || input !== i || !i.isConnected || sheet || focused() !== i) return;
613
621
  lineUp(i);
614
622
  if (!light) sampleTokens(i, field!);
615
623
  prepared = i;
@@ -741,7 +749,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
741
749
  host!.style.setProperty('display', 'block', 'important');
742
750
  host!.showPopover();
743
751
  open = true;
744
- hook('onOpen', ac.onOpen);
752
+ if (AC_HOOKS) hook('onOpen', ac.onOpen);
745
753
  setCss('ac:sheet', ':root { overflow: hidden !important; }');
746
754
  try {
747
755
  history.pushState(null, '', location.href);
@@ -847,7 +855,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
847
855
  aria?.set('aria-activedescendant', null);
848
856
  live.clear();
849
857
  said = '';
850
- hook('onClose', ac.onClose, reason);
858
+ if (AC_HOOKS) hook('onClose', ac.onClose, reason);
851
859
  };
852
860
 
853
861
  /** The sheet input's value the last render drew (its clear button). */
@@ -956,16 +964,37 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
956
964
  * target only: its click still hands the query to the host (submitViaHost).
957
965
  * `value`: the phrase instead of the input's (the phone sheet's own input).
958
966
  */
967
+ // Where the site's server redirects the form to the phrase's page (search.queryUrl): that page,
968
+ // same origin and http(s) as the form's (as every other use of the hook checks), else the form's.
969
+ // Decided outside searchHref's try (a flag in a try block is never folded: build/vite.mjs).
970
+ const pageOf = QUERY_URL
971
+ ? (q: string, u: URL) => {
972
+ if (!q || !ctx.queryUrl) return u.href;
973
+ try {
974
+ const to = ctx.queryUrl(q, new URL(u.href));
975
+ if (to === '') return u.href;
976
+ const r = new URL(to, location.href);
977
+ return (r.protocol === 'https:' || r.protocol === 'http:') && r.origin === location.origin ? r.href : u.href;
978
+ } catch (e) {
979
+ reportError('search queryUrl', e);
980
+ return u.href;
981
+ }
982
+ }
983
+ : null;
959
984
  const searchHref = (i: HTMLInputElement | null, value?: string) => {
960
985
  const f = i?.form;
961
986
  if (!f || (f.getAttribute('method') || 'get').trim().toLowerCase() !== 'get') return '';
962
987
  try {
963
988
  const u = new URL(f.getAttribute('action')?.trim() || location.href, document.baseURI);
964
989
  const p = new URLSearchParams();
965
- for (const [k, v] of new FormData(f)) if (typeof v === 'string') p.append(k, v);
990
+ // With the form's default button, as Enter submits (its name=value, or an
991
+ // image button's x and y): the URL the result and the page were fetched
992
+ // ahead for (widgets/search/form-url.ts reads the same, core/default-button.ts).
993
+ for (const [k, v] of formDataOf(f, defaultSubmitter(f))) if (typeof v === 'string') p.append(k, v);
966
994
  if (value !== undefined && i!.name) p.set(i!.name, value);
967
995
  u.search = p.toString();
968
- return u.protocol === 'https:' || u.protocol === 'http:' ? u.href : '';
996
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return '';
997
+ return pageOf ? pageOf((value ?? i!.value).trim(), u) : u.href;
969
998
  } catch {
970
999
  return '';
971
1000
  }
@@ -1214,7 +1243,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1214
1243
  /** Hand a query to the host so its own router/form performs navigation. */
1215
1244
  const submitViaHost = (q: string) => {
1216
1245
  const i = input!;
1217
- hook('onSelect', ac.onSelect, null);
1246
+ if (AC_HOOKS) hook('onSelect', ac.onSelect, null);
1218
1247
  // Paint our close first; the host's router render then runs in its own task.
1219
1248
  if (sheet) return leave('select', () => handToHost(i, q));
1220
1249
  close('select');
@@ -1254,7 +1283,17 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1254
1283
  const opts = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true, composed: true } as KeyboardEventInit;
1255
1284
  const notCancelled = i.dispatchEvent(new KeyboardEvent('keydown', opts));
1256
1285
  i.dispatchEvent(new KeyboardEvent('keyup', opts));
1257
- if (notCancelled && i.form) i.form.requestSubmit();
1286
+ // As Enter would: with the form's default button (its name=value, formaction), so
1287
+ // the page loads at the URL the result and the document were fetched ahead for
1288
+ // (instant-search.spec 'a search button with a name', on a phone: here, not an Enter).
1289
+ if (notCancelled && i.form) {
1290
+ const f = i.form;
1291
+ try {
1292
+ f.requestSubmit(defaultSubmitter(f) ?? undefined);
1293
+ } catch {
1294
+ f.requestSubmit(); // a button the engine refuses as a submitter (none of this form)
1295
+ }
1296
+ }
1258
1297
  };
1259
1298
 
1260
1299
  /**
@@ -1274,8 +1313,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1274
1313
  if (r.type === 'query') submitViaHost(plain(r.c.title));
1275
1314
  else if (safeUrl(r.c.url)) {
1276
1315
  const url = safeUrl(r.c.url);
1277
- track(idx);
1278
- hook('onSelect', ac.onSelect, (r as any).__empty ? null : r);
1316
+ if (ANALYTICS) track(idx);
1317
+ if (AC_HOOKS) hook('onSelect', ac.onSelect, (r as any).__empty ? null : r);
1279
1318
  leave('select', () => location.assign(url));
1280
1319
  }
1281
1320
  }
@@ -1534,7 +1573,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
1534
1573
  close('navigate');
1535
1574
  });
1536
1575
 
1537
- hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && lineUp(input), position()) });
1576
+ if (AC_HOOKS) hook('onReady', ac.onReady, { close: () => (sheet ? leave('api') : close('api')), reposition: () => (cancelAnimationFrame(raf), input && lineUp(input), position()) });
1538
1577
 
1539
1578
  // The interaction that loaded us has already happened: pick it up.
1540
1579
  const now = focused();
@@ -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);