@luigisbox/luma 0.0.0-stage → 0.2.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 (145) hide show
  1. package/LICENSE.md +90 -0
  2. package/README.md +615 -2
  3. package/a11y/audit.mjs +735 -0
  4. package/a11y/axe.mjs +73 -0
  5. package/a11y/report.mjs +347 -0
  6. package/a11y/run.mjs +256 -0
  7. package/a11y/wcag.mjs +233 -0
  8. package/bin/config.mjs +60 -0
  9. package/bin/luma.mjs +99 -0
  10. package/bin/paths.mjs +11 -0
  11. package/bin/tools.mjs +43 -0
  12. package/build/baseline.d.mts +8 -0
  13. package/build/baseline.mjs +167 -0
  14. package/build/tooling.mjs +102 -0
  15. package/build/vite.mjs +507 -0
  16. package/chrome/block.mjs +8 -0
  17. package/chrome/run.mjs +150 -0
  18. package/docs/ACCESSIBILITY.md +501 -0
  19. package/docs/INTEGRATION-PERFORMANCE.md +213 -0
  20. package/docs/PARITY.md +946 -0
  21. package/footprint/model.mjs +184 -0
  22. package/footprint/run.mjs +331 -0
  23. package/package.json +58 -4
  24. package/parity/alignment.mjs +109 -0
  25. package/parity/archive.mjs +223 -0
  26. package/parity/args.mjs +137 -0
  27. package/parity/behaviour.mjs +379 -0
  28. package/parity/compare.mjs +985 -0
  29. package/parity/engine.mjs +848 -0
  30. package/parity/hints.mjs +132 -0
  31. package/parity/hold.mjs +54 -0
  32. package/parity/init.mjs +703 -0
  33. package/parity/map.mjs +640 -0
  34. package/parity/owner.mjs +170 -0
  35. package/parity/pairing.mjs +110 -0
  36. package/parity/pixels.mjs +33 -0
  37. package/parity/probe.mjs +1239 -0
  38. package/parity/report.mjs +236 -0
  39. package/parity/run.mjs +267 -0
  40. package/snippet/early-hold.js +64 -0
  41. package/snippet/early-hold.min.js +1 -0
  42. package/src/analytics/chain.ts +137 -0
  43. package/src/analytics/collect.ts +64 -0
  44. package/src/analytics/datalayer.ts +67 -0
  45. package/src/analytics/from-ga4.ts +68 -0
  46. package/src/analytics/from-luma.ts +21 -0
  47. package/src/analytics/index.ts +24 -0
  48. package/src/analytics/outbox.ts +118 -0
  49. package/src/analytics/page.ts +46 -0
  50. package/src/analytics/pageview.ts +82 -0
  51. package/src/analytics/ref.ts +19 -0
  52. package/src/analytics/setup.ts +41 -0
  53. package/src/analytics/start.ts +53 -0
  54. package/src/analytics/triggers.ts +239 -0
  55. package/src/analytics/user.ts +12 -0
  56. package/src/analytics/wire.ts +67 -0
  57. package/src/api/ai.ts +98 -0
  58. package/src/api/index.ts +87 -0
  59. package/src/api/token-key.ts +42 -0
  60. package/src/api/token.ts +312 -0
  61. package/src/core/a11y-keys.ts +60 -0
  62. package/src/core/a11y.ts +160 -0
  63. package/src/core/boot-state.ts +6 -0
  64. package/src/core/color.ts +42 -0
  65. package/src/core/context.ts +108 -0
  66. package/src/core/early-rules.ts +96 -0
  67. package/src/core/entries.ts +25 -0
  68. package/src/core/fold.ts +7 -0
  69. package/src/core/health.ts +26 -0
  70. package/src/core/hold.ts +74 -0
  71. package/src/core/i18n.ts +66 -0
  72. package/src/core/image-load.ts +102 -0
  73. package/src/core/image-tags.ts +26 -0
  74. package/src/core/images.ts +170 -0
  75. package/src/core/integration.ts +126 -0
  76. package/src/core/kept.ts +25 -0
  77. package/src/core/listings.ts +100 -0
  78. package/src/core/locales.ts +14 -0
  79. package/src/core/own.ts +49 -0
  80. package/src/core/page.ts +14 -0
  81. package/src/core/prefetch.ts +88 -0
  82. package/src/core/recommenders.ts +166 -0
  83. package/src/core/reconciler.ts +397 -0
  84. package/src/core/result.ts +73 -0
  85. package/src/core/routes.ts +115 -0
  86. package/src/core/scheduler.ts +83 -0
  87. package/src/core/sense.ts +107 -0
  88. package/src/core/speculation.ts +59 -0
  89. package/src/core/stash.ts +38 -0
  90. package/src/core/storage.ts +45 -0
  91. package/src/core/styles.ts +106 -0
  92. package/src/core/support.ts +12 -0
  93. package/src/core/takeover.ts +220 -0
  94. package/src/core/templates.ts +74 -0
  95. package/src/core/tiles.ts +137 -0
  96. package/src/core/turbo.ts +64 -0
  97. package/src/core/url.ts +164 -0
  98. package/src/core/widget-inits.ts +40 -0
  99. package/src/env.d.ts +3 -0
  100. package/src/lit.ts +11 -0
  101. package/src/locales/cs.json +94 -0
  102. package/src/locales/de.json +88 -0
  103. package/src/locales/en.json +88 -0
  104. package/src/main.ts +493 -0
  105. package/src/types.ts +778 -0
  106. package/src/widgets/autocomplete/index.ts +1387 -0
  107. package/src/widgets/autocomplete/style-autocomplete.css +266 -0
  108. package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
  109. package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
  110. package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
  111. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
  112. package/src/widgets/listing/element.ts +1772 -0
  113. package/src/widgets/listing/flow.ts +30 -0
  114. package/src/widgets/listing/instant.ts +133 -0
  115. package/src/widgets/listing/intent.ts +193 -0
  116. package/src/widgets/listing/query.ts +298 -0
  117. package/src/widgets/listing/stash.ts +97 -0
  118. package/src/widgets/listing/state.ts +131 -0
  119. package/src/widgets/listing/view.ts +257 -0
  120. package/src/widgets/plp/index.ts +11 -0
  121. package/src/widgets/plp/route.ts +263 -0
  122. package/src/widgets/recommender/index.ts +261 -0
  123. package/src/widgets/recommender/style-recommender.css +26 -0
  124. package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
  125. package/src/widgets/recommender/template-recommender-popup.ts +12 -0
  126. package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
  127. package/src/widgets/search/assets.ts +7 -0
  128. package/src/widgets/search/first.ts +15 -0
  129. package/src/widgets/search/form-url.ts +59 -0
  130. package/src/widgets/search/in-place.ts +226 -0
  131. package/src/widgets/search/index.ts +16 -0
  132. package/src/widgets/search/range.ts +260 -0
  133. package/src/widgets/search/route.ts +32 -0
  134. package/src/widgets/search/speculate.ts +202 -0
  135. package/src/widgets/search/style-search.css +65 -0
  136. package/src/widgets/search/template-search-box.ts +17 -0
  137. package/src/widgets/search/template-search-facets-default.ts +73 -0
  138. package/src/widgets/search/template-search-header.ts +14 -0
  139. package/src/widgets/search/template-search-pagination.ts +12 -0
  140. package/src/widgets/search/template-search-range-default.ts +48 -0
  141. package/src/widgets/search/template-search-result-default.ts +13 -0
  142. package/src/widgets/search/template-search-results-default.ts +8 -0
  143. package/tasks/model.mjs +116 -0
  144. package/tasks/run.mjs +305 -0
  145. package/tsconfig.json +14 -0
@@ -0,0 +1,1772 @@
1
+ // A result listing (search results, category listing) as a light-DOM custom
2
+ // element. The URL is the single source of truth: UI actions write the URL
3
+ // (in the host's own parameter format where the route defines one), URL
4
+ // changes (ours, the host router's, back/forward) re-derive state. Element
5
+ // lifecycle = cleanup, so a host framework destroying its page takes our
6
+ // listeners with it. Markup comes from the widget's templates
7
+ // (search-box and its includes); this file only owns state and behaviour.
8
+ //
9
+ // Server-rendered pages (the edge render service): the page arrives with our listing in
10
+ // it, rendered from the same templates, plus its state (query + API result)
11
+ // in a JSON script before the element. The element then adopts it: the state
12
+ // seeds the result cache and the first render hydrates the server's markup
13
+ // (lit SSR) instead of rebuilding it, so nothing is fetched or repainted on
14
+ // load. From then on the listing is client-side as usual.
15
+
16
+ import { borrow, contrastToggle, liveRegion, useContrast } from '../../core/a11y';
17
+ import { activateByKey, byKeyboard, focusables, trapTab } from '../../core/a11y-keys';
18
+ import { mark, reportError } from '../../core/health';
19
+ import { claim } from '../../core/reconciler';
20
+ import { afterLayout, rememberHeight } from '../../core/hold';
21
+ import { hasPrefetch, observe, peekPrefetch, peekProducts, productsChannel, putPrefetch, takePrefetch, typicalMs, type Prefetch } from '../../core/prefetch';
22
+ // 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 { view } from '../../core/templates';
25
+ import { afterFrame, isPromise } from '../../core/scheduler';
26
+ import { onUrlChange } from '../../core/sense';
27
+ import { layered, setCss } from '../../core/styles';
28
+ import type { Takeover } from '../../core/takeover';
29
+ import { canon, pageKey, writeUrl } from '../../core/url';
30
+ import { lru } from '../../core/storage';
31
+ import type { WidgetContext } from '../../main';
32
+ import type { Link } from '../../types';
33
+ import { facetFields, handsBack, nextState, stateOf, type Query, type Route, type UiState } from './state';
34
+ import { firstRequest, hasMore, listingMore, listingQuery, type ListingConfig, type ListingResult } from './query';
35
+ import { flowKey, notAbove, onQuickLanded, quickChannel } from './flow';
36
+ import { firstPages, targetOf, thrifty, watchIntent } from './intent';
37
+ import type { Page } from '../../api';
38
+ import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, viewport, type ListingView, type SsrState } from './view';
39
+ import { dropStash, readStash, writeStash, type Stash } from './stash';
40
+ import { keepViewport, leavingLink, nearEnd, preconnectImages, restoreScroll, scrollSpot } from './instant';
41
+ import { watch } from '../../core/tiles';
42
+
43
+ /** A re-render answered within DIM_MS (no dim shown) fades its list in from this opacity, over FADE_MS. */
44
+ const FADE_FROM = 0.6;
45
+ const FADE_MS = 150;
46
+ /** A hidden first render in frames: the first STEP tiles, then about BUDGET ms of rendering per frame. */
47
+ const STEP = 4;
48
+ /** 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. */
49
+ const SHOW_TILES = 8;
50
+ /** A unit already drawn, run again by a render, against one it adds (a production storefront at 4x: ~0.1-0.3; 0.25 errs on the safe side). */
51
+ const RERUN = 0.25;
52
+
53
+ /**
54
+ * A step's rendering time (ms). A step is one render, sized from what a tile
55
+ * and a facet group cost so far, with what is there run again (RERUN). Its
56
+ * task holds more than the render (the scope, the hooks once complete): 16
57
+ * gave tasks of 18-21 ms on a production storefront at 4x CPU, 12 keeps them within a 16 ms
58
+ * frame (2026-10-12). 40 gave steps of 32-49 ms (tried 2026-10-11). Each
59
+ * step's time is in the "rendered in" / "drawn in" mark.
60
+ */
61
+ const BUDGET = 12;
62
+ /**
63
+ * Back after longer away than this (a page restored from the back/forward
64
+ * cache, or one rendered from our stash) revalidates its results; a quick
65
+ * round trip to a product shows them as left.
66
+ */
67
+ const BFCACHE_STALE_MS = 60_000;
68
+ /** The same products, whatever their order: an API that orders ties anew on every request is not a change. */
69
+ export const sameHits = (a: ListingResult, b: ListingResult) => {
70
+ const ids = (r: ListingResult) => r.hits.map((h) => h['@id']).sort().join('\n');
71
+ return a.hits.length === b.hits.length && ids(a) === ids(b);
72
+ };
73
+
74
+ /**
75
+ * Per-page-session result cache, keyed by the resolved query: what is loaded
76
+ * for that state (its first page, and the pages "show more" appended since),
77
+ * so back/forward to it, or a host re-creating our element, shows it as left.
78
+ */
79
+ const cache = lru<ListingResult>(30, Infinity);
80
+ /** "Show more" fetched ahead (prefetchNextPage, intent on the button): the next result of a result, by the result. */
81
+ const nextOf = new WeakMap<ListingResult, Promise<ListingResult>>();
82
+ /** The same page (a result's URL, often relative, and a link's href). */
83
+ const sameUrl = (u: unknown, href: string) => {
84
+ try {
85
+ return new URL(String(u ?? ''), location.href).href === href;
86
+ } catch {
87
+ return false;
88
+ }
89
+ };
90
+ const keyOf = (q: Query) => JSON.stringify([q.q, q.scope, q.filters, q.flags, q.sort]);
91
+
92
+ type Hydrate = (v: unknown, c: HTMLElement | ShadowRoot) => void;
93
+
94
+ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeover, route: Route) => {
95
+ const cfg = ctx.config;
96
+ const t = ctx.t;
97
+ for (const [i, css] of ctx.styles.entries()) setCss(`itg:${route.id}:${i}`, css);
98
+ setCss('listing-loading', layered(LOADING_CSS));
99
+ useContrast();
100
+ const query = (q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void) => {
101
+ const t0 = performance.now();
102
+ const p = listingQuery(ctx.api, ctx.global, cfg, route, q, signal, pre, onProducts, quickChannel(route.id));
103
+ // A whole listing (not a first page fetched already): how long a fresh one takes here.
104
+ if (!pre) p.then((r) => !r.partial && observe(performance.now() - t0), () => {});
105
+ return p;
106
+ };
107
+ /** "Show more": `res` with its next page, the one fetched ahead if there is one (never asked twice). */
108
+ const more = (q: Query, res: ListingResult) => {
109
+ let p = nextOf.get(res);
110
+ if (!p) {
111
+ p = listingMore(ctx.api, ctx.global, cfg, route, q, res);
112
+ nextOf.set(res, p);
113
+ p.catch(() => nextOf.delete(res)); // a failed one: the click asks again
114
+ }
115
+ return p;
116
+ };
117
+ /**
118
+ * The result a prefetch brings, or a fresh request's. A page's own
119
+ * prefetch (the entry's, started for this load) is the request: it is
120
+ * waited for. A speculation (a phrase typed) is reused while it is on time:
121
+ * once it is older than twice a fresh request's usual duration on this page
122
+ * (a stuck or slow answer), a fresh request starts next to it and the first
123
+ * answer wins; the shopper never waits on a speculation much longer than
124
+ * asking anew would take. A failed or empty one (null): asked anew.
125
+ */
126
+ const fromPrefetch = (pre: Prefetch | undefined, q: Query, signal: AbortSignal, onProducts?: (r: ListingResult) => void): Promise<ListingResult> => {
127
+ if (!pre) return query(q, signal, undefined, onProducts);
128
+ if (pre.done || !pre.spec) {
129
+ // Its products first, while its facets are still asked for.
130
+ if (!pre.done && onProducts) pre.products?.then((r) => !pre.done && onProducts(r));
131
+ return pre.p.then((r) => r ?? query(q, signal, undefined, onProducts));
132
+ }
133
+ return new Promise<ListingResult>((ok, fail) => {
134
+ let fresh: AbortController | undefined;
135
+ let over = false;
136
+ const win = (r: ListingResult, prefetched: boolean) => {
137
+ if (over) return;
138
+ over = true;
139
+ clearTimeout(timer);
140
+ if (prefetched) fresh?.abort(); // it came first after all: the fresh one is not needed
141
+ else pre.abort?.(); // the fresh one did: the speculation is not needed
142
+ ok(r);
143
+ };
144
+ const ask = () => {
145
+ if (fresh || over) return;
146
+ const f = (fresh = new AbortController());
147
+ signal.addEventListener('abort', () => f.abort(), { once: true });
148
+ mark(`${route.id} prefetch overdue: asking anew`);
149
+ query(q, f.signal).then(
150
+ (r) => win(r, false),
151
+ (e) => !over && ((over = true), fail(e)),
152
+ );
153
+ };
154
+ const timer = window.setTimeout(ask, Math.max(0, pre.at + 2 * typicalMs() - performance.now()));
155
+ pre.p.then((r) => (r ? win(r, true) : ask()), ask);
156
+ });
157
+ };
158
+ // Filter clicks that render at once (./intent.ts): first pages fetched on intent, by their request.
159
+ const early = firstPages();
160
+ const prefetchFilters = ctx.global.prefetchFilters !== false;
161
+ const visitOf = (q: Query) => JSON.stringify([q.q, q.scope]);
162
+ const earlyKey = (q: Query) => {
163
+ const r = firstRequest(ctx.global, cfg, route, q);
164
+ return r && JSON.stringify(r);
165
+ };
166
+
167
+ // Server-rendered listing: its state seeds the cache, its query skips
168
+ // resolve(). Adopted when the server's element connects: at our boot, or
169
+ // later when the bundle ran before the fragment was parsed (an async include).
170
+ const seeded = new Map<string, Query>();
171
+ let hydrate: Promise<Hydrate> | null = null;
172
+ const adopt = () => {
173
+ try {
174
+ // Our script by type too: an element of the host with that id (clobbering) is not our state.
175
+ const st = JSON.parse(document.querySelector(`script#${ssrStateId(route.id)}[type="application/json"]`)?.textContent || 'null') as SsrState | null;
176
+ if (!st || pageKey(st.href) !== pageKey(location.href)) return false;
177
+ if (seeded.has(pageKey(st.href))) return true; // this state, adopted already (at our start)
178
+ seeded.set(pageKey(st.href), st.query);
179
+ if (!st.result.partial) cache.set(keyOf(st.query), st.result);
180
+ hydrate ??= import('@lit-labs/ssr-client').then((m) => m.hydrate as Hydrate);
181
+ hydrate.catch(() => {}); // a failed chunk: the listing renders anew (sync())
182
+ mark(`${route.id} server-rendered, adopted`);
183
+ tk.serverRendered(); // the include may have arrived after our boot: this visit is the server's
184
+ // A client-side copy may already sit at the anchor: the server's wins.
185
+ for (const el of document.querySelectorAll(`${route.tag}:not([data-lbx-ssr])`)) el.remove();
186
+ return true;
187
+ } catch (e) {
188
+ reportError(`${route.id} ssr state`, e);
189
+ return false;
190
+ }
191
+ };
192
+ if (document.querySelector(`${route.tag}[data-lbx-ssr]`)) adopt();
193
+
194
+ // A result stashed for this page load (./stash.ts): the query typed on the
195
+ // previous page, or the listing as the shopper left it (Back). Seeded like a
196
+ // server-rendered state: the first render needs no request.
197
+ let stashed: Stash | null = null;
198
+ /** The page's templates prepared ahead (`rehearse`), once. */
199
+ let rehearsed = false;
200
+ if (!seeded.size) {
201
+ stashed = readStash(route.id, location.href);
202
+ if (stashed) {
203
+ seeded.set(pageKey(location.href), stashed.query);
204
+ cache.set(keyOf(stashed.query), stashed.result);
205
+ mark(`${route.id} from stash (${stashed.kind}, ${stashed.result.hits.length} hits)`);
206
+ // A prerendered page reads (and drops) it in a copy of sessionStorage; at
207
+ // its activation the opener's storage, still holding it, comes back: used once.
208
+ const at = location.href;
209
+ if (stashed.kind === 'spec' && (document as any).prerendering) document.addEventListener('prerenderingchange', () => dropStash(at), { once: true });
210
+ }
211
+ }
212
+ const restoreOn = cfg.restoreOnBack !== false;
213
+ const skipOut = cfg.contentVisibility === true;
214
+
215
+ // The request for the URL we loaded on starts now, before the element exists.
216
+ const bootUrl = new URL(location.href);
217
+ if (!seeded.size && route.accepts(bootUrl) && !hasPrefetch(bootUrl.href)) {
218
+ // No query for this URL (a category the catalog does not know): hand the page
219
+ // back now, even if our element was never placed (their page has no anchor),
220
+ // unless the shopper has moved on meanwhile.
221
+ const stillHere = () => canon(location.href) === canon(bootUrl.href);
222
+ const { products, onProducts } = productsChannel();
223
+ putPrefetch(
224
+ bootUrl.href,
225
+ new Promise<Query | null>((ok) => ok(route.resolve(bootUrl))).then((q) => (q ? query(q, undefined, undefined, onProducts) : (stillHere() && tk.release(), null))),
226
+ { products },
227
+ );
228
+ }
229
+ // The first row's images, preloaded as soon as the result is in hand: the
230
+ // request for this load (the entry's on a search page, or the one above)
231
+ // usually arrives while the chunk's templates, the fonts or the anchor are
232
+ // still on their way. A server-rendered page has them in its HTML (the server renderer).
233
+ const imgs = ctx.global.images ?? {};
234
+ const preloadOn = imgs.preload !== false;
235
+ const warm = imgs.warm ?? 0;
236
+ const preloadRow = (res: ListingResult | null | undefined) => {
237
+ if (res?.hits.length) preloadImages(firstRowImages(imgs, res.hits.map(ctx.result)));
238
+ };
239
+ // From its products, when they come first (they show before the facets): before their tiles.
240
+ if (preloadOn && !seeded.size) peekProducts(bootUrl.href)?.then(preloadRow, () => {});
241
+ if (preloadOn && !seeded.size) peekPrefetch(bootUrl.href)?.then(preloadRow, () => {});
242
+ // A Back restore lands at the followed tile, not at the top: the images
243
+ // around it (the HTTP cache has them; started now, not after the render,
244
+ // the scroll and the lazy images' own layout). A typed query's page: its first row.
245
+ if (preloadOn && stashed) {
246
+ const results = stashed.result.hits.map(ctx.result);
247
+ const cols = Math.max(1, firstRowCount(imgs));
248
+ const href = stashed.kind === 'back' ? stashed.scroll?.href : undefined;
249
+ const at = href ? results.findIndex((r) => sameUrl(r.c.url, href)) : -1;
250
+ if (at < 0) preloadRow(stashed.result);
251
+ else {
252
+ const from = Math.max(0, at - (at % cols) - cols);
253
+ preloadImages(results.slice(from, from + 3 * cols).map((r, k) => imageOf(imgs, r, from + k, { eager: true })).filter((im) => im.src));
254
+ }
255
+ }
256
+ // A URL we can show at once, from cache (no hold). Not an empty category on a
257
+ // route that hands those back: that one is released, never shown.
258
+ tk.cached = (url) => {
259
+ try {
260
+ const q = route.resolve(url);
261
+ if (isPromise(q)) q.catch(() => {}); // warms the memo; sync() reports failures
262
+ const hit = q && !isPromise(q) ? cache.get(keyOf(q)) : undefined;
263
+ return !!hit && !handsBack(route, q as Query, hit.total);
264
+ } catch {
265
+ return false;
266
+ }
267
+ };
268
+
269
+ class LbxListing extends HTMLElement {
270
+ private ctrl: AbortController | null = null;
271
+ private unsub: (() => void) | null = null;
272
+ private href = '';
273
+ private current: Query | null = null;
274
+ private last: ListingResult | null = null;
275
+ /** The query `last` was rendered for. */
276
+ private lastQ: Query | null = null;
277
+ /** A resolve or fetch of ours is running (busy is ours to clear). */
278
+ private inFlight = false;
279
+ /** The state the template's links were built from (the last render). */
280
+ private shown: UiState | null = null;
281
+ /** The latest state asked for by a click, until it is rendered. */
282
+ private wanted: UiState | null = null;
283
+ private wantedHref: string | null = null;
284
+ /** Server markup we are not hydrating, parsed completely (see connectedCallback). */
285
+ private cleared: Promise<void> | null = null;
286
+ /**
287
+ * The server's markup, kept until our first render has succeeded: stale but
288
+ * real content beats an empty box (with SSI their listing is not there).
289
+ */
290
+ private server: ChildNode[] | null = null;
291
+ /** The click waiting for its turn (one per task: the latest wins). */
292
+ private pending = 0;
293
+ private v = view(this, ctx.templates);
294
+ private message: string | null = null;
295
+ /** Server markup waiting to be hydrated by the first render. */
296
+ private ssr: boolean | null = null;
297
+ private hydrateWith: Hydrate | null = null;
298
+ /** Cancels the next page's prefetch trigger (./instant.ts nearEnd). */
299
+ private ahead: (() => void) | null = null;
300
+ /** Intent on our controls (./intent.ts): the request of the state they lead to starts early. */
301
+ private intent: ReturnType<typeof watchIntent> | null = null;
302
+ /** View state not in the URL (scope.ui), kept for the visit (a category, a query). */
303
+ private ui: Record<string, unknown> = {};
304
+ private uiVisit = '';
305
+ /** The last render's template data: a view state change re-renders it. */
306
+ private drawn: Record<string, unknown> | null = null;
307
+ /** The URL the last render was drawn for (a view state change draws it again). */
308
+ private drawnUrl: URL | null = null;
309
+ /** Facet groups the shopper opened or closed, and the text in a group's search box, for the visit. */
310
+ private groupOpen: Record<string, boolean> = {};
311
+ private groupText: Record<string, string> = {};
312
+ private groupSaid = 0;
313
+ /**
314
+ * The open drawer's held changes (`facetsApply: 'staged'`): the state its
315
+ * controls lead to, not applied yet; null: nothing held. `stagedMode`:
316
+ * the drawer was opened where it stages.
317
+ */
318
+ private staged: UiState | null = null;
319
+ private stagedMode = false;
320
+ /** Renders started: a render in chunks stops when a newer one has started. */
321
+ private renders = 0;
322
+ // Accessibility (docs/ACCESSIBILITY.md, "Listings"): our status region (and
323
+ // the contrast toggle) ahead of the templates' markup; what the next shown
324
+ // render announces; where focus goes once it has rendered.
325
+ private a11y: { root: HTMLElement; live: ReturnType<typeof liveRegion> } | null = null;
326
+ private toSay: string | null = null;
327
+ /** The next render is silent (a background revalidation). */
328
+ private quiet = false;
329
+ /** "Show more" from the keyboard: focus goes to the first new tile. */
330
+ private moreByKey = false;
331
+ private focusWas: Element | null = null;
332
+ private shownBefore = 0;
333
+ /** The drawer's opener (focus goes back to it) and the attributes lent to its panel. */
334
+ private drawerFrom: HTMLElement | null = null;
335
+ private panelAria: ReturnType<typeof borrow> | null = null;
336
+ /** Another state's results awaited (loading()): the timer that dims the list, and whether it has. */
337
+ private wait: { timer: number; dimmed: boolean } | null = null;
338
+ /** A wait that ended (loaded()) and whether it had dimmed the list: kept for the render that follows in the same task. */
339
+ private waited: { dimmed: boolean } | null = null;
340
+ /** The list's fade-in (fadeIn()). */
341
+ private fade: Animation | null = null;
342
+ /** A first render in steps under way: its products, and how a newer result of them (their facets) joins it. */
343
+ private stepping: { hits: unknown; take: (q: Query, res: ListingResult, url: URL) => void; again: () => void } | null = null;
344
+ /** Every tile's size, for while it is out of view (sizeTiles()). */
345
+ private sized: { ro: ResizeObserver; seen: WeakSet<Element> } | null = null;
346
+
347
+ /** When the page was hidden (pagehide): a restore from the back/forward cache after long is revalidated. */
348
+ private hiddenAt = 0;
349
+ private onPageHide = () => void (this.hiddenAt = Date.now());
350
+ /**
351
+ * Restored from the back/forward cache (pageshow, persisted): the page is as
352
+ * the shopper left it, from memory. Our own Back restore (./stash.ts) is not
353
+ * needed; after a while away, the results are revalidated in the background
354
+ * (re-rendered only if they changed, the viewport kept). A request in flight
355
+ * when the page froze needs nothing: Chrome evicts a cached page whose
356
+ * request completes there (its Back is then a page load), elsewhere it
357
+ * completes after the restore.
358
+ */
359
+ private onPageShow = (e: PageTransitionEvent) => {
360
+ if (!e.persisted) return;
361
+ dropStash(location.href);
362
+ mark(`${route.id} restored from the back/forward cache`);
363
+ const away = this.hiddenAt ? Date.now() - this.hiddenAt : 0;
364
+ if (away > BFCACHE_STALE_MS && this.last && this.lastQ && this.lastQ === this.current) this.revalidate(this.lastQ, this.last, new URL(location.href), this.href);
365
+ };
366
+
367
+ connectedCallback() {
368
+ onQuickLanded(route.id, (r) => this.quickIn(r));
369
+ // Not in the constructor: an element the parser creates after our class
370
+ // is defined (an async include) is constructed before its attributes are set.
371
+ if (this.ssr == null) {
372
+ // Every server element is checked against its own state (a later visit's page is another page).
373
+ this.ssr = this.hasAttribute('data-lbx-ssr') && adopt();
374
+ // Ours from now on, before it drops its marker (the reconciler keeps it).
375
+ if (this.ssr) claim(route.id, this);
376
+ // Server markup whose state we cannot use (another URL: a param the page
377
+ // stripped, another host name): ours replaces it once it has rendered.
378
+ // A parser-created element (an async bundle ran first) is still getting
379
+ // its children: kept once they are all there (sync() awaits that).
380
+ else if (this.hasAttribute('data-lbx-ssr')) {
381
+ claim(route.id, this);
382
+ const keep = () => void (this.server = [...this.childNodes]);
383
+ if (document.readyState === 'loading') this.cleared = new Promise<void>((r) => document.addEventListener('DOMContentLoaded', () => (keep(), r()), { once: true }));
384
+ else keep();
385
+ }
386
+ // A copy of our element (a Turbo snapshot restored): lit would render a second listing after it.
387
+ else this.replaceChildren();
388
+ }
389
+ if (!this.ssr && this.className !== (cfg.rootClass ?? '')) this.className = cfg.rootClass ?? '';
390
+ // A named region (WCAG 1.3.1, 2.4.1), focusable from script only: where
391
+ // focus goes when a render removed the focused control.
392
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'region');
393
+ if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', t('label'));
394
+ if (!this.hasAttribute('tabindex')) this.tabIndex = -1;
395
+ this.unsub = onUrlChange(() => this.resync());
396
+ // Product clicks and add-to-carts on our tiles: the framework's, never a template's (core/tiles.ts).
397
+ watch(this, { list: `lbx_${route.id}`, global: ctx.global, results: () => this.last?.hits.map(ctx.result) ?? [] });
398
+ this.addEventListener('click', this.onClick);
399
+ this.addEventListener('keydown', this.onKey);
400
+ this.addEventListener('focusout', this.onFocusOut);
401
+ document.addEventListener('click', this.onOutside, true);
402
+ document.addEventListener('pointerdown', this.onOutside, true);
403
+ addEventListener('pagehide', this.onPageHide);
404
+ addEventListener('pageshow', this.onPageShow);
405
+ if (prefetchFilters) this.intent ??= watchIntent(this, (el) => this.onIntent(el));
406
+ // Microtask: runs before paint (no empty frame) but outside the
407
+ // reconciler's placement pass.
408
+ queueMicrotask(() => this.resync());
409
+ }
410
+
411
+ disconnectedCallback() {
412
+ mark(`${route.tag} disconnected`);
413
+ this.unsub?.();
414
+ this.removeEventListener('click', this.onClick);
415
+ this.removeEventListener('keydown', this.onKey);
416
+ this.removeEventListener('focusout', this.onFocusOut);
417
+ document.removeEventListener('click', this.onOutside, true);
418
+ document.removeEventListener('pointerdown', this.onOutside, true);
419
+ removeEventListener('pagehide', this.onPageHide);
420
+ removeEventListener('pageshow', this.onPageShow);
421
+ this.ctrl?.abort();
422
+ this.ahead?.();
423
+ this.intent?.stop();
424
+ this.intent = null;
425
+ this.sized?.ro.disconnect();
426
+ this.sized = null;
427
+ // Not the pending click: the reconciler puts this same element back after a
428
+ // host re-render, and the click's timer then still applies it.
429
+ this.href = ''; // re-sync if we are ever re-attached
430
+ }
431
+
432
+ /** sync(), never throwing into the page (it runs from timers and listeners). */
433
+ private resync(url?: URL) {
434
+ this.sync(url).catch((e) => reportError(`${route.id} sync`, e));
435
+ }
436
+
437
+ /**
438
+ * The filter drawer (narrow screens): [data-lbx-drawer="open|close"]
439
+ * links. Without JavaScript they work through :target; with it, a
440
+ * state attribute on the element, so the URL keeps no #fragment.
441
+ */
442
+ private onClick = (e: MouseEvent) => {
443
+ const target = e.target as Element;
444
+ // A menu in the host's markup (their dropdown classes): [data-lbx-toggle]
445
+ // opens/closes its [data-lbx-menu] ancestor by a data-lbx-open attribute
446
+ // (the integration's CSS maps it onto their open state).
447
+ const tg = target.closest?.('[data-lbx-toggle]');
448
+ if (tg && this.contains(tg)) {
449
+ e.preventDefault();
450
+ // Ours alone: host code delegating on the same (their) dropdown classes must not toggle it too.
451
+ e.stopPropagation();
452
+ const menu = tg.closest('[data-lbx-menu]');
453
+ // From the keyboard (a click without a pointer): focus into the menu.
454
+ if (menu) setMenu(menu, !menu.hasAttribute('data-lbx-open'), byKeyboard(e));
455
+ return;
456
+ }
457
+ const d = target.closest?.('[data-lbx-drawer]');
458
+ if (!d || !this.contains(d)) {
459
+ // A link to another page (a product): remembered for Back.
460
+ const away = restoreOn && leavingLink(e, this);
461
+ if (away) this.leave(away);
462
+ return;
463
+ }
464
+ e.preventDefault();
465
+ const what = d.getAttribute('data-lbx-drawer');
466
+ // "apply" commits what the drawer holds (staged) and closes; "close" (and the overlay) discards it.
467
+ if (what === 'apply') this.applyStaged();
468
+ this.setDrawer(what === 'open', d as HTMLElement);
469
+ };
470
+
471
+ /** Changes go to the drawer's held state instead of the URL: it is open, where it stages. */
472
+ private staging() {
473
+ return this.stagedMode && this.hasAttribute('data-drawer-open');
474
+ }
475
+
476
+ /** The drawer's held changes, applied: one request, one history entry. */
477
+ private applyStaged() {
478
+ const next = this.staged;
479
+ this.staged = null;
480
+ if (next) this.navigate(next);
481
+ }
482
+
483
+ /** The drawer's held changes dropped: it shows the applied state again. */
484
+ private discardStaged() {
485
+ if (!this.staged) return;
486
+ this.staged = null;
487
+ this.redraw();
488
+ }
489
+
490
+ /**
491
+ * The drawer as a modal dialog while it is open (WCAG 2.4.3, 2.1.2): its
492
+ * panel (`[data-lbx-drawer-panel]` in the integration's markup) gets the
493
+ * dialog's role and name, focus moves into it and stays there (Tab
494
+ * cycles), Escape closes it, and focus goes back to the button that opened
495
+ * it. On wide screens the same markup is a sidebar: the role is lent while
496
+ * open only. Without a panel: the buttons' state, Escape and the way back.
497
+ */
498
+ private setDrawer(open: boolean, from?: HTMLElement) {
499
+ const was = this.hasAttribute('data-drawer-open');
500
+ this.toggleAttribute('data-drawer-open', open);
501
+ for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(open));
502
+ const panel = this.querySelector('[data-lbx-drawer-panel]');
503
+ // Closed other than by its apply button: what it held goes.
504
+ if (!open) this.discardStaged();
505
+ if (open && !was) {
506
+ this.stagedMode = atWidth(cfg.facetsApply, 'instant', viewport) === 'staged';
507
+ this.drawerFrom = (from?.getAttribute('data-lbx-drawer') === 'open' ? from : document.activeElement) as HTMLElement | null;
508
+ if (panel) {
509
+ const a = (this.panelAria = borrow(panel));
510
+ if (!panel.hasAttribute('role')) a.set('role', 'dialog');
511
+ a.set('aria-modal', 'true');
512
+ if (!panel.hasAttribute('aria-label') && !panel.hasAttribute('aria-labelledby')) a.set('aria-label', t('drawer'));
513
+ if (!panel.hasAttribute('tabindex')) a.set('tabindex', '-1');
514
+ }
515
+ // Once it is shown: focus() would force the page's style and layout in this task.
516
+ afterFrame(() => {
517
+ if (!this.hasAttribute('data-drawer-open')) return;
518
+ const root = panel ?? this;
519
+ (focusables(root).find((el) => el.getAttribute('data-lbx-drawer') !== 'open') ?? (root as HTMLElement)).focus();
520
+ });
521
+ } else if (!open && was) {
522
+ this.panelAria?.restore();
523
+ this.panelAria = null;
524
+ const back = this.drawerFrom;
525
+ this.drawerFrom = null;
526
+ const at = document.activeElement;
527
+ if (back?.isConnected && (!at || at === document.body || this.contains(at))) afterFrame(() => back.focus());
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Keys: Escape closes our open menu (focus back on its button) or the
533
+ * drawer; the arrows move through an open menu's items; Tab stays inside
534
+ * the open drawer; Enter and Space work on role="button" markup.
535
+ */
536
+ private onKey = (e: KeyboardEvent) => {
537
+ const target = e.target as Element;
538
+ const menu = target.closest?.('[data-lbx-menu][data-lbx-open]');
539
+ if (e.key === 'Escape') {
540
+ if (menu && this.contains(menu)) {
541
+ e.preventDefault();
542
+ e.stopPropagation(); // ours, not a host modal's
543
+ setMenu(menu, false);
544
+ triggerOf(menu)?.focus();
545
+ } else if (this.hasAttribute('data-drawer-open')) {
546
+ e.preventDefault();
547
+ e.stopPropagation();
548
+ this.setDrawer(false);
549
+ }
550
+ return;
551
+ }
552
+ if (menu && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
553
+ const items = focusables(menu).filter((el) => !el.hasAttribute('data-lbx-toggle'));
554
+ if (!items.length) return;
555
+ e.preventDefault();
556
+ const i = items.indexOf(target as HTMLElement);
557
+ const next = i < 0 ? (e.key === 'ArrowDown' ? 0 : items.length - 1) : (i + (e.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
558
+ items[next].focus();
559
+ return;
560
+ }
561
+ if (e.key === 'Tab' && this.hasAttribute('data-drawer-open')) {
562
+ const panel = this.querySelector('[data-lbx-drawer-panel]');
563
+ if (panel) trapTab(e, panel);
564
+ return;
565
+ }
566
+ activateByKey(e, this);
567
+ };
568
+
569
+ /** Focus leaving an open menu (Tab past it) closes it. */
570
+ private onFocusOut = (e: FocusEvent) => {
571
+ const menu = (e.target as Element).closest?.('[data-lbx-menu][data-lbx-open]');
572
+ if (menu && !(e.relatedTarget && menu.contains(e.relatedTarget as Node))) setMenu(menu, false);
573
+ };
574
+
575
+ /** After a render: our status region in place, the menus' and drawer's state on their buttons, focus where it belongs. */
576
+ private afterA11y() {
577
+ if (!this.a11y) {
578
+ const root = document.createElement('div');
579
+ root.setAttribute('data-lbx-a11y', '');
580
+ const live = liveRegion();
581
+ root.append(live.el);
582
+ if (ctx.global.contrastToggle) root.append(contrastToggle(t('contrast')));
583
+ this.a11y = { root, live };
584
+ }
585
+ if (this.firstChild !== this.a11y.root) this.prepend(this.a11y.root);
586
+ for (const m of this.querySelectorAll('[data-lbx-menu]')) setMenu(m, m.hasAttribute('data-lbx-open'));
587
+ for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(this.hasAttribute('data-drawer-open')));
588
+ const was = this.focusWas;
589
+ this.focusWas = null;
590
+ const tile = this.moreByKey ? this.querySelector('[data-lbx-list]')?.children[this.shownBefore] : null;
591
+ this.moreByKey = false;
592
+ // "Show more" from the keyboard: on to the first new tile (WCAG 2.4.3).
593
+ // A render that removed the focused control (a chip, the last "Show
594
+ // more"): the region, not the start of the page.
595
+ const to = tile
596
+ ? mainControl(tile)
597
+ : was && !was.isConnected
598
+ ? (this as HTMLElement)
599
+ : null;
600
+ if (to) afterFrame(() => to.isConnected && to.focus());
601
+ }
602
+
603
+ /** The pending announcement, once ours is shown (a hidden region is not read). */
604
+ private speak() {
605
+ if (this.toSay && this.a11y) this.a11y.live.say(this.toSay);
606
+ this.toSay = null;
607
+ }
608
+
609
+ /**
610
+ * Our menus (<details data-lbx-dropdown>, [data-lbx-menu]) close on a click
611
+ * or a press outside them: iOS Safari sends no click for a tap on what is
612
+ * not clickable (a heading, the page's background), only the pointer events.
613
+ */
614
+ private onOutside = (e: Event) => {
615
+ for (const d of this.querySelectorAll('details[data-lbx-dropdown][open]')) if (!d.contains(e.target as Node)) d.removeAttribute('open');
616
+ for (const d of this.querySelectorAll('[data-lbx-menu][data-lbx-open]')) if (!d.contains(e.target as Node)) setMenu(d, false);
617
+ };
618
+
619
+ /**
620
+ * Busy lives on our element itself, set directly and never bound in a
621
+ * template: instant feedback in an event handler without rendering anything.
622
+ */
623
+ private setBusy(b: boolean) {
624
+ this.setAttribute('aria-busy', String(b));
625
+ if (b) this.waited = null; // show more: its render is not a new state
626
+ else if (this.wait) this.waited = this.loaded(); // for the render that follows, in this task
627
+ }
628
+
629
+ /**
630
+ * Another state's results asked for while ours shows (a filter, a sort,
631
+ * back/forward): busy, and once the wait is longer than DIM_MS,
632
+ * `data-lbx-loading`, which dims the list (LOADING_CSS). From a timer, not
633
+ * a CSS transition-delay: a quick answer starts no transition at all
634
+ * (Chrome ran a delayed one on under the fade-in, the list dimming after
635
+ * it: a production storefront, 2026-10-12). Ends with the render of the new state, or busy's end.
636
+ */
637
+ private loading() {
638
+ this.setBusy(true);
639
+ if (tk.state !== 'ours' || !this.last || this.wait) return;
640
+ const w: { timer: number; dimmed: boolean } = (this.wait = { timer: 0, dimmed: false });
641
+ w.timer = window.setTimeout(() => ((w.dimmed = true), this.setAttribute('data-lbx-loading', '')), DIM_MS);
642
+ }
643
+
644
+ /** The wait ends: its dim, if shown, removed (CSS fades the list back). */
645
+ private loaded() {
646
+ const w = this.wait;
647
+ if (!w) return null;
648
+ clearTimeout(w.timer);
649
+ this.wait = null;
650
+ if (w.dimmed) this.removeAttribute('data-lbx-loading');
651
+ return w;
652
+ }
653
+
654
+ /** UI -> URL. The resulting URL change drives sync(). In a drawer that stages: held there, nothing fetched. */
655
+ go(patch: Partial<UiState>) {
656
+ if (!this.current) return;
657
+ this.intent?.cancel(); // a dwell still pending: this click's own state is what is fetched now
658
+ if (this.staging()) {
659
+ this.staged = nextState(this.staged ?? this.wanted ?? stateOf(this.current), this.shown ?? stateOf(this.current), patch);
660
+ return this.redraw();
661
+ }
662
+ this.navigate(nextState(this.wanted ?? stateOf(this.current), this.shown ?? stateOf(this.current), patch));
663
+ }
664
+
665
+ /** To a whole state: its URL written and fetched, in later tasks (respond first). */
666
+ private navigate(next: UiState) {
667
+ if (!this.current) return;
668
+ const url = route.write(new URL(location.href), next);
669
+ // Already what we show or are fetching (e.g. a drawer's Apply after its
670
+ // fields applied on Enter): nothing to do, and nothing would clear busy.
671
+ if (canon(url.href) === this.href) {
672
+ clearTimeout(this.pending);
673
+ this.wanted = this.wantedHref = null;
674
+ if (!this.inFlight) this.setBusy(false);
675
+ return;
676
+ }
677
+ this.wanted = next;
678
+ this.wantedHref = canon(url.href);
679
+ // Respond first, work later: the fetch and the history write run in later
680
+ // tasks, because host code wrapping fetch and pushState runs inside them.
681
+ // Several changes in one task (an Apply setting a range and a facet): only
682
+ // the last is fetched and becomes a history entry.
683
+ this.loading();
684
+ clearTimeout(this.pending);
685
+ this.pending = window.setTimeout(() => {
686
+ // The shopper has left the listing meanwhile: the click no longer applies.
687
+ if (!route.accepts(new URL(location.href))) return this.setBusy(false);
688
+ this.resync(url);
689
+ setTimeout(() => writeUrl(url), 0);
690
+ }, 0);
691
+ }
692
+
693
+ /** A "show more" in flight: the result it grows (a second click waits for it, never asks again). */
694
+ private growing: ListingResult | null = null;
695
+
696
+ /**
697
+ * "Show more": the next page appended to what is shown, from its cursor
698
+ * (the one fetched ahead when there is one). Not a state of the URL: no
699
+ * history entry, no URL change. The
700
+ * grown result is cached for its state, so back/forward to it or a host
701
+ * re-creating our element shows every page loaded. `byKey`: from the
702
+ * keyboard (focus moves on to the first new tile).
703
+ */
704
+ showMore(byKey = false) {
705
+ const q = this.current;
706
+ const res = this.last;
707
+ if (!q || !res || this.lastQ !== q || this.inFlight || this.wanted || !hasMore(res) || this.growing === res) return;
708
+ this.intent?.cancel();
709
+ this.growing = res;
710
+ const href = this.href;
711
+ this.setBusy(true);
712
+ const done = () => {
713
+ if (this.growing === res) this.growing = null;
714
+ return this.href === href && this.last === res; // else the shopper moved on meanwhile
715
+ };
716
+ // Respond first: the request (and the host's fetch wrappers) in a later task.
717
+ setTimeout(() => {
718
+ more(q, res).then(
719
+ (grown) => {
720
+ if (!done()) return;
721
+ if (!grown.partial) cache.set(keyOf(q), grown);
722
+ this.setBusy(false);
723
+ this.message = null;
724
+ this.moreByKey = byKey;
725
+ this.render(q, grown, new URL(location.href));
726
+ },
727
+ (e) => {
728
+ if (!done()) return;
729
+ reportError(`${route.id} show more`, e);
730
+ this.setBusy(false);
731
+ this.message = t('error');
732
+ this.render(q, res, new URL(location.href));
733
+ },
734
+ );
735
+ }, 0);
736
+ }
737
+
738
+ /** URL -> query -> fetch -> render. Idempotent. */
739
+ private async sync(url = new URL(location.href)) {
740
+ const href = canon(url.href);
741
+ // A URL change that is not the one we asked for (back/forward, the host):
742
+ // later clicks apply to what it shows, not to our last request.
743
+ if (this.wantedHref && href !== this.wantedHref) this.wanted = this.wantedHref = null;
744
+ if (!route.accepts(url) || href === this.href) return;
745
+ this.href = href;
746
+ this.staged = null; // another state (back/forward, an apply): nothing held for the one left
747
+ this.ctrl?.abort();
748
+ const c = (this.ctrl = new AbortController());
749
+ let q: Query | null;
750
+ this.inFlight = true;
751
+ try {
752
+ const r = seeded.get(pageKey(url.href)) ?? route.resolve(url);
753
+ if (isPromise(r)) {
754
+ this.loading();
755
+ q = await r;
756
+ if (c.signal.aborted || this.href !== href) return;
757
+ tk.progressed();
758
+ } else q = r;
759
+ } catch (e) {
760
+ if (this.href !== href) return;
761
+ return this.failed(`${route.id} resolve`, e, url);
762
+ }
763
+ if (!q) {
764
+ this.inFlight = false;
765
+ this.setBusy(false);
766
+ return tk.release(); // e.g. a category the catalog does not know
767
+ }
768
+ this.current = q;
769
+ const key = keyOf(q);
770
+ // A host re-render may destroy and re-create us for the same URL: render
771
+ // synchronously from cache so the replacement never shows an empty frame.
772
+ const hit = cache.get(key);
773
+ if (hit && this.ssr && !this.hydrateWith) {
774
+ // Hydrate complete markup only: a parser-created element connects before its children are parsed.
775
+ if (document.readyState === 'loading') await new Promise((r) => document.addEventListener('DOMContentLoaded', r, { once: true }));
776
+ try {
777
+ this.hydrateWith = await hydrate!; // a small chunk, already on its way
778
+ } catch (e) {
779
+ // No hydration chunk: the server's markup is replaced by a client render (once that succeeds).
780
+ reportError(`${route.id} hydrate chunk`, e);
781
+ this.ssr = false;
782
+ this.server = [...this.childNodes];
783
+ }
784
+ if (this.href !== href) return;
785
+ }
786
+ if (this.cleared) {
787
+ await this.cleared;
788
+ this.cleared = null;
789
+ if (this.href !== href) return;
790
+ }
791
+ if (hit) {
792
+ takePrefetch(url.href); // not needed: never left to block a later prefetch of this URL
793
+ this.inFlight = false;
794
+ this.setBusy(false);
795
+ this.message = null;
796
+ return this.render(q, hit, url);
797
+ }
798
+ this.loading();
799
+ // Prefetched on intent (./intent.ts): this state's first page, done or in
800
+ // flight. Requests still out for other controls are stale now.
801
+ const pf = takePrefetch(url.href);
802
+ const ek = !pf && prefetchFilters ? earlyKey(q) : null;
803
+ const pre = ek ? early.take(ek) : undefined;
804
+ early.abortInFlight();
805
+ try {
806
+ let res: ListingResult;
807
+ let shownEarly = false;
808
+ if (pre) {
809
+ const first = await pre.catch(() => null); // a failed prefetch: asked again below
810
+ if (c.signal.aborted || this.href !== href) return;
811
+ const last = this.last;
812
+ const lq = this.lastQ;
813
+ // The products at once, with the sidebar as it was (the clicked
814
+ // facet's own values are exact: they never depended on its
815
+ // selection); the facets of the new result set follow (they need its guid).
816
+ if (first && last && lq && tk.state === 'ours' && visitOf(lq) === visitOf(q)) {
817
+ mark(`${route.id} render from intent prefetch`);
818
+ this.setBusy(false);
819
+ this.message = null;
820
+ this.render(q, { hits: first.hits, total: first.total, guid: first.guid, page: 1, cursor: first.cursor, status: first.status, facets: last.facets, facetsAll: last.facetsAll, quick: last.quick, partial: true }, url);
821
+ shownEarly = true;
822
+ }
823
+ res = await query(q, c.signal, first ?? undefined);
824
+ } else {
825
+ // The products as soon as they are here, busy, the facets following
826
+ // (their request needs the products' result set): a facets request
827
+ // sooner on screen (2026-10-11).
828
+ let productsShown = false;
829
+ this.rehearse(q, url);
830
+ res = await fromPrefetch(pf, q, c.signal, !notAbove(route.id) ? undefined : (p) => {
831
+ // The quick types on their way and above the products: they wait together.
832
+ if (c.signal.aborted || this.href !== href || (p.quickPending && !notAbove(route.id, 'quick'))) return;
833
+ mark(`${route.id} products before their facets`);
834
+ this.message = null;
835
+ this.render(q, p, url);
836
+ productsShown = true;
837
+ });
838
+ if (productsShown) this.quiet = true; // said with the products
839
+ }
840
+ if (c.signal.aborted || this.href !== href) return;
841
+ if (shownEarly && res.partial) {
842
+ // The facets failed: the sidebar shown stays (not cached, asked again next time).
843
+ this.inFlight = false;
844
+ return;
845
+ }
846
+ if (shownEarly) this.quiet = true; // said with the products
847
+ // Server markup still being parsed (a streamed include): render once it is complete.
848
+ if (this.ssr && document.readyState === 'loading') {
849
+ await new Promise((r) => document.addEventListener('DOMContentLoaded', r, { once: true }));
850
+ if (this.href !== href) return;
851
+ }
852
+ this.inFlight = false;
853
+ if (!res.partial) cache.set(key, res); // a result missing its facets is asked for again next time
854
+ this.setBusy(false);
855
+ this.message = null;
856
+ this.render(q, res, url);
857
+ } catch (e) {
858
+ // A request for a URL we have left (a prefetch cannot be aborted) says nothing about this one.
859
+ if (c.signal.aborted || this.href !== href) return;
860
+ this.failed(route.id, e, url);
861
+ }
862
+ }
863
+
864
+ /**
865
+ * A resolve or fetch failed. Before the first reveal their listing is (or
866
+ * can be) showing: leave it rather than swapping to an error message. After
867
+ * it: the last results, as they were (their query and links), with the error.
868
+ */
869
+ private failed(where: string, e: unknown, url: URL) {
870
+ this.inFlight = false;
871
+ reportError(where, e);
872
+ this.setBusy(false);
873
+ this.href = ''; // the same click may retry: this URL is not what we show
874
+ if (tk.state === 'ours' && this.last && this.lastQ) {
875
+ this.message = t('error');
876
+ this.current = this.lastQ;
877
+ this.render(this.lastQ, this.last, url);
878
+ } else {
879
+ // Server markup kept on a visit that is not the server's (its include
880
+ // came late, for another URL): hidden with ours when we hand back.
881
+ if (tk.ssr !== 'rendered') this.removeAttribute('data-lbx-ssr');
882
+ tk.release(true);
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Host code that rewrites our markup (jQuery `.text()` / `.html()` on one of
888
+ * the site's classes we reuse) ejects lit's markers, and every later render
889
+ * throws: the listing would stay frozen. Drop lit's state and render anew
890
+ * from the same data instead (tens of ms; images come from the cache).
891
+ */
892
+ private renderOrRebuild(data: Record<string, unknown>) {
893
+ try {
894
+ this.v.render('search-box', data);
895
+ } catch (e) {
896
+ reportError(`${route.id} render (host rewrote our markup, rebuilt)`, e);
897
+ this.rebuild(data);
898
+ }
899
+ this.images();
900
+ if (tk.state === 'ours') this.watch();
901
+ }
902
+
903
+ /** Images a render put in that are there already (cached, persisted tiles) lose their placeholder now: no flash, no fade. */
904
+ private images() {
905
+ settleImages(this, imgs.fade !== false);
906
+ }
907
+
908
+ /** Render anew, dropping the state lit keeps on the container (or it reuses it and paints nothing). */
909
+ private rebuild(data: Record<string, unknown>) {
910
+ delete (this as any)._$litPart$;
911
+ this.replaceChildren();
912
+ this.v.render('search-box', data);
913
+ this.images();
914
+ if (this.a11y) this.prepend(this.a11y.root);
915
+ }
916
+
917
+ /**
918
+ * The shopper follows a link to another page (a product): the listing as
919
+ * it is, and where they are, for Back (./stash.ts). Only what is shown.
920
+ */
921
+ private leave(a: HTMLAnchorElement) {
922
+ if (!this.last || !this.lastQ || this.lastQ !== this.current || this.inFlight) return;
923
+ if (writeStash(location.href, { kind: 'back', route: route.id, query: this.lastQ, result: { ...this.last, quickPending: undefined }, scroll: scrollSpot(this, a) })) mark(`${route.id} stashed for back`);
924
+ }
925
+
926
+ /** After a render: back where the shopper was (a stashed page), the next page when they near the end, the image host. */
927
+ private afterRender(q: Query, res: ListingResult, url: URL) {
928
+ // Whether the facets sit above the products (notAbove): read a frame
929
+ // later, when layout is clean (the read costs only itself). Shown only.
930
+ // The quick types likewise (notAbove), where some are shown.
931
+ if (!res.partial && tk.state === 'ours')
932
+ afterFrame(() => {
933
+ const list = this.querySelector('[data-lbx-list]');
934
+ if (!list || !this.isConnected) return;
935
+ const b = list.getBoundingClientRect();
936
+ // The grid's columns (inView), and its tiles watched from now on (shown).
937
+ const tops = [...list.children].slice(0, 12).map((t) => t.getBoundingClientRect().top);
938
+ this.cols = Math.max(1, tops.filter((y) => Math.abs(y - tops[0]) < 1).length);
939
+ this.watch();
940
+ for (const part of ['facets', 'quick'] as const) {
941
+ const ps = [...this.querySelectorAll(`[data-lbx-part="${part}"]`)];
942
+ if (!ps.length) continue;
943
+ const above = ps.some((f) => {
944
+ const a = f.getBoundingClientRect();
945
+ return getComputedStyle(f).position !== 'fixed' && a.height > 0 && a.bottom <= b.top + 1;
946
+ });
947
+ try {
948
+ localStorage.setItem(flowKey(route.id, part), above ? '0' : '1');
949
+ } catch {}
950
+ }
951
+ });
952
+ const st = stashed;
953
+ stashed = null; // the first render only
954
+ if (st && pageKey(url.href) === pageKey(location.href) && keyOf(st.query) === keyOf(q)) {
955
+ if (st.kind === 'back') {
956
+ const href = this.href;
957
+ // After the reveal (a font wait may delay it): ours must be laid out to find the tile.
958
+ const at = (n = 0) =>
959
+ this.href === href && (tk.state === 'ours' ? (restoreScroll(this, st.scroll ?? { y: 0 }), mark(`${route.id} scroll restored`)) : n < 120 && requestAnimationFrame(() => at(n + 1)));
960
+ at();
961
+ // Silently fresh after a while away: asked again in the background,
962
+ // re-rendered only when the results changed. A quick round trip to
963
+ // a product shows the listing as left (a reshuffle in front of the
964
+ // shopper, on an API that orders ties anew, was the bug).
965
+ if (Date.now() - st.at > BFCACHE_STALE_MS) setTimeout(() => this.revalidate(q, st.result, url, href), 1500);
966
+ else mark(`${route.id} back: as left, not revalidated`);
967
+ }
968
+ }
969
+ this.ahead?.();
970
+ this.ahead = null;
971
+ if (skipOut) this.sizeTiles();
972
+ // Not while prerendered: a next page of a page nobody may open is a request for nothing.
973
+ if (cfg.prefetchNextPage !== false && hasMore(res)) {
974
+ const href = this.href;
975
+ whenShown(() => this.href === href && !this.ahead && (this.ahead = nearEnd(this, () => this.prefetchNext(q, res))));
976
+ }
977
+ if (res.hits[0] && ctx.global.preconnectImages !== false) preconnectImages(ctx.result(res.hits[0]).c.image);
978
+ }
979
+
980
+ /**
981
+ * Tiles out of view skip their style, layout and paint
982
+ * (`content-visibility: auto`, `contentVisibility`): a long list's
983
+ * re-render (a sort after "show more") laid out and painted every tile.
984
+ * Each tile keeps its own size while skipped: a ResizeObserver on every
985
+ * tile (after the frame's own layout: no read forced) writes its content
986
+ * box to its `contain-intrinsic-size`, and the first one measured stands
987
+ * in for tiles not measured yet. On only once the tiles drawn have been
988
+ * measured: a tile out of view at that moment is never laid out again
989
+ * until it comes near, and with one size for all (the first's) tiles of
990
+ * other heights lost their places (a production integration's stub page, tiles 445-531 px tall:
991
+ * found on its tiles, 2026-10-11). A hidden first render is as before.
992
+ */
993
+ private sizeTiles() {
994
+ const list = this.querySelector('[data-lbx-list]');
995
+ if (!list) return;
996
+ const s = (this.sized ??= {
997
+ seen: new WeakSet<Element>(),
998
+ ro: new ResizeObserver((es) => {
999
+ let first: ResizeObserverSize | undefined;
1000
+ for (const e of es) {
1001
+ // The content box: contain-intrinsic-size stands in for the contents (padding and border come on top).
1002
+ // Not rounded: WebKit's tiles are fractional (a listing 1.4 px off with whole pixels).
1003
+ const b = e.contentBoxSize?.[0];
1004
+ if (!b?.blockSize || !b.inlineSize) continue; // hidden: no size to give
1005
+ first ??= b;
1006
+ (e.target as HTMLElement).style.setProperty('contain-intrinsic-size', `auto ${b.inlineSize}px auto ${b.blockSize}px`);
1007
+ }
1008
+ if (!first || this.hasAttribute('data-lbx-cv')) return;
1009
+ this.style.setProperty('--lbx-tile-w', `${first.inlineSize}px`);
1010
+ this.style.setProperty('--lbx-tile-h', `${first.blockSize}px`);
1011
+ setCss('listing-cv', layered(CV_CSS));
1012
+ this.setAttribute('data-lbx-cv', '');
1013
+ }),
1014
+ });
1015
+ for (const t of list.children) if (!s.seen.has(t)) s.seen.add(t), s.ro.observe(t);
1016
+ }
1017
+
1018
+ private revalidate(q: Query, old: ListingResult, url: URL, href: string) {
1019
+ if (this.href !== href) return;
1020
+ // Pages appended by "show more" are not asked again (a chain of cursor
1021
+ // requests, and the shopper's place under them): shown as left. A first
1022
+ // page only is one request.
1023
+ if (old.page > 1) return mark(`${route.id} not revalidated: ${old.page} pages shown`);
1024
+ query({ ...q }, this.ctrl?.signal)
1025
+ .then((res) => {
1026
+ if (this.href !== href || res.partial) return;
1027
+ cache.set(keyOf(q), res);
1028
+ // The same tiles: nothing to redraw (a total the API approximates anew, or ties it orders anew, are not a change).
1029
+ if (sameHits(res, old)) return mark(`${route.id} revalidated: unchanged`);
1030
+ mark(`${route.id} revalidated: changed`);
1031
+ keepViewport(this, () => ((this.quiet = true), this.render(q, res, url)));
1032
+ })
1033
+ .catch((e) => (e as Error)?.name === 'AbortError' || reportError(`${route.id} revalidate`, e));
1034
+ }
1035
+
1036
+ /**
1037
+ * Intent on one of our controls (./intent.ts): the request its click would
1038
+ * make starts now. Only from a settled listing (nothing pending for the
1039
+ * current state: no click waiting, no request out), for another state of
1040
+ * this visit (same category or query) that is not cached already.
1041
+ */
1042
+ private onIntent(el: Element) {
1043
+ const cur = this.current;
1044
+ // In a drawer that stages, a control leads nowhere until Apply: nothing to fetch ahead.
1045
+ if (this.staging()) return;
1046
+ if (!cur || !this.last || this.lastQ !== cur || this.inFlight || this.wanted || tk.state !== 'ours' || thrifty()) return;
1047
+ // "Show more" (`data-lbx-more`): its next page, from the cursor, as nearing the end does.
1048
+ if (el.matches('[data-lbx-more]')) return this.prefetchNext(cur, this.last);
1049
+ const raw = targetOf(el);
1050
+ if (!raw) return;
1051
+ const url = new URL(raw, location.href);
1052
+ if (url.origin !== location.origin || url.pathname !== location.pathname || !route.accepts(url)) return;
1053
+ const from = this.href;
1054
+ if (canon(url.href) === from) return;
1055
+ const then = (q: Query | null) => {
1056
+ if (!q || this.href !== from || this.inFlight || this.wanted || visitOf(q) !== visitOf(cur) || cache.get(keyOf(q))) return;
1057
+ const req = firstRequest(ctx.global, cfg, route, q);
1058
+ // Its first images too (`_global.images.warm`, low priority): the click then shows them at once.
1059
+ 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));
1060
+ if (req && early.start(JSON.stringify(req), visitOf(q), fetch)) mark(`${route.id} prefetch on intent`);
1061
+ };
1062
+ const r = route.resolve(url);
1063
+ if (isPromise(r)) r.then(then).catch((e) => reportError(`${route.id} filter prefetch`, e));
1064
+ else then(r);
1065
+ }
1066
+
1067
+ /** The next page ("show more") from the cursor of what is shown, ready before the click: one request, taken by showMore(). */
1068
+ private prefetchNext(q: Query, res: ListingResult) {
1069
+ if (res !== this.last || q !== this.lastQ || !hasMore(res) || nextOf.has(res)) return;
1070
+ mark(`${route.id} prefetch page ${res.page + 1}`);
1071
+ more(q, res).catch(() => {}); // a failed one: the click asks again
1072
+ }
1073
+
1074
+ private render(q: Query, res: ListingResult, url: URL) {
1075
+ // The new state's results: the list comes back from its dim (LOADING_CSS
1076
+ // fades it back), or, asked and answered within DIM_MS (no dim shown:
1077
+ // cached, prefetched), fades in from FADE_FROM. One calm change for the
1078
+ // whole list: tiles moving to their new places (a View Transition's
1079
+ // morph) read as chaos on a sort or a broad filter (2026-10-12).
1080
+ const w = this.loaded() ?? this.waited;
1081
+ this.waited = null;
1082
+ const was = this.last;
1083
+ const quiet = this.quiet;
1084
+ this.guarded(() => this.draw(q, res, url));
1085
+ if (w && !w.dimmed && was && !quiet && tk.state === 'ours' && !sameHits(res, was)) this.fadeIn();
1086
+ }
1087
+
1088
+ /** The list fading in from FADE_FROM (Web Animations: no class to restart, no layout read); not under reduced motion. */
1089
+ private fadeIn() {
1090
+ const list = this.querySelector('[data-lbx-list]');
1091
+ if (!list || typeof list.animate !== 'function' || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
1092
+ this.fade?.cancel();
1093
+ this.fade = list.animate([{ opacity: FADE_FROM }, { opacity: 1 }], { duration: FADE_MS, easing: 'cubic-bezier(.2,0,0,1)' });
1094
+ }
1095
+
1096
+ /** A render that throws (a template, or a hit it does not expect) hands the page back rather than leaving it blank. */
1097
+ private guarded(f: () => void) {
1098
+ try {
1099
+ f();
1100
+ } catch (e) {
1101
+ reportError(`${route.id} render`, e);
1102
+ this.setBusy(false);
1103
+ // The server's markup is still the page's: back it goes (the takeover keeps a listing that is there).
1104
+ if (this.server) {
1105
+ delete (this as any)._$litPart$;
1106
+ this.replaceChildren(...this.server);
1107
+ if (tk.ssr === 'rendered') this.setAttribute('data-lbx-ssr', '');
1108
+ else this.removeAttribute('data-lbx-ssr');
1109
+ }
1110
+ tk.release(true);
1111
+ }
1112
+ }
1113
+
1114
+ /** afterRender(), once a render is complete (a render in chunks: its last one). */
1115
+ private rendered(q: Query, res: ListingResult, url: URL) {
1116
+ try {
1117
+ this.afterRender(q, res, url);
1118
+ } catch (e) {
1119
+ reportError(`${route.id} after render`, e);
1120
+ }
1121
+ }
1122
+
1123
+ /** The state a render shows: the URL's, or what the open drawer holds. */
1124
+ private shownQ(q: Query): Query {
1125
+ return this.staged ? { ...q, ...this.staged } : q;
1126
+ }
1127
+
1128
+ /** The view state templates see (not in the URL): the groups' accordion and search text, the drawer's mode and what it holds. */
1129
+ private view(): ListingView {
1130
+ const cur = this.current;
1131
+ const at = (st: UiState) => canon(route.write(new URL(location.href), st).href);
1132
+ return {
1133
+ open: this.groupOpen,
1134
+ openDefault: atWidth(cfg.facetGroups?.open, true, viewport),
1135
+ text: this.groupText,
1136
+ apply: atWidth(cfg.facetsApply, 'instant', viewport),
1137
+ pending: !!this.staged && !!cur && at(this.staged) !== at(stateOf(cur)),
1138
+ toggleGroup: (name) => this.toggleGroup(name),
1139
+ searchGroup: (name, text) => this.searchGroup(name, text),
1140
+ setRange: (name, from, to) => (this.drawn as { setRange?: (n: string, f: number | null, t: number | null) => void } | null)?.setRange?.(name, from, to),
1141
+ };
1142
+ }
1143
+
1144
+ /** A group opened or closed: drawn again, nothing fetched. */
1145
+ private toggleGroup(name: string) {
1146
+ const groups = [...((this.drawn?.facets ?? []) as { name: string; open: boolean }[]), ...((this.drawn?.ranges ?? []) as { name: string; open: boolean }[])];
1147
+ const g = groups.find((x) => x.name === name);
1148
+ if (!g) return;
1149
+ this.groupOpen = { ...this.groupOpen, [name]: !g.open };
1150
+ this.redraw();
1151
+ }
1152
+
1153
+ /** A group's search text: its values filtered, drawn again (nothing fetched); how many match is said once typing pauses. */
1154
+ private searchGroup(name: string, text: string) {
1155
+ if ((this.groupText[name] ?? '') === text) return;
1156
+ this.groupText = { ...this.groupText, [name]: text };
1157
+ this.redraw();
1158
+ clearTimeout(this.groupSaid);
1159
+ this.groupSaid = window.setTimeout(() => {
1160
+ const f = ((this.drawn?.facets ?? []) as { name: string; label: string; search: { text: string; matches: number } | null }[]).find((x) => x.name === name);
1161
+ if (f?.search?.text.trim() && this.a11y) this.a11y.live.say(t('facetMatches', { count: f.search.matches, facet: f.label }));
1162
+ }, 600);
1163
+ }
1164
+
1165
+ /**
1166
+ * The quick types landed in a result shown without them (where they do
1167
+ * not sit above the products): drawn now, into the render in steps if it
1168
+ * is still going on. No hook, no announcement: the same result.
1169
+ */
1170
+ private quickIn(r: ListingResult) {
1171
+ if (this.stepping?.hits === r.hits) return void this.stepping.again();
1172
+ if (this.last !== r) return; // not shown (yet): a render of it shows them
1173
+ mark(`${route.id} quick types after the products`);
1174
+ this.redraw();
1175
+ }
1176
+
1177
+ /** The last render's data again with the current view state: no fetch, no announcement, focus where it is. */
1178
+ private redraw() {
1179
+ const q = this.lastQ;
1180
+ const res = this.last;
1181
+ const url = this.drawnUrl;
1182
+ if (!q || !res || !url || !this.drawn) return;
1183
+ this.drawn = this.dataFor(q, res, url);
1184
+ this.renderOrRebuild(this.drawn);
1185
+ this.afterA11y();
1186
+ }
1187
+
1188
+ /** The template data of a render of (q, res) at url: the scope, its actions, the view state. Sets `shown`, the state its links build on. */
1189
+ private dataFor(q: Query, res: ListingResult, url: URL): Record<string, unknown> {
1190
+ q = this.shownQ(q);
1191
+ this.shown = stateOf(q);
1192
+ this.drawnUrl = url;
1193
+ const self = this;
1194
+ return {
1195
+ ...ctx.helpers,
1196
+ ...listingData(ctx, route, url, q, res, this.message, this.view()),
1197
+ facetsOpen: matchMedia('(min-width: 768px)').matches,
1198
+ // actions
1199
+ go: (link: Link | null) => (e: Event) => {
1200
+ const m = e as MouseEvent;
1201
+ if (!link || m.button > 0 || m.metaKey || m.ctrlKey || m.shiftKey || m.altKey) return; // new tab etc.: the browser's
1202
+ e.preventDefault();
1203
+ const at = e.currentTarget as Element | null;
1204
+ at?.closest('details[data-lbx-dropdown]')?.removeAttribute('open');
1205
+ const menu = at?.closest('[data-lbx-menu]');
1206
+ if (menu) {
1207
+ setMenu(menu, false);
1208
+ // Its item had focus: back to the menu's button, not lost with the closed menu.
1209
+ if (menu.contains(document.activeElement)) triggerOf(menu)?.focus();
1210
+ }
1211
+ self.go(link.patch as Partial<UiState>);
1212
+ },
1213
+ toggleFacet(name: string, value: string) {
1214
+ const cur = new Set(q.filters[name] || []);
1215
+ cur.has(value) ? cur.delete(value) : cur.add(value);
1216
+ self.go({ filters: { ...q.filters, [name]: [...cur] } });
1217
+ },
1218
+ toggleFlag(id: string) {
1219
+ const flags = new Set(q.flags);
1220
+ flags.has(id) ? flags.delete(id) : flags.add(id);
1221
+ self.go({ flags: [...flags] });
1222
+ },
1223
+ setRange(name: string, from: number | null, to: number | null) {
1224
+ const filters = { ...q.filters };
1225
+ if (from == null && to == null) delete filters[name];
1226
+ else filters[name] = [`${from ?? ''}|${to ?? ''}`];
1227
+ self.go({ filters });
1228
+ },
1229
+ setSort: (sort: string) => self.go({ sort }),
1230
+ showMore: (e?: Event) => {
1231
+ e?.preventDefault();
1232
+ self.showMore(!!e && byKeyboard(e));
1233
+ },
1234
+ ui: this.ui,
1235
+ setUi(key: string, value: unknown) {
1236
+ if (self.ui[key] === value || !self.drawn) return;
1237
+ self.ui = { ...self.ui, [key]: value };
1238
+ self.drawn = { ...self.drawn, ui: self.ui };
1239
+ self.renderOrRebuild(self.drawn);
1240
+ },
1241
+ };
1242
+ }
1243
+
1244
+ private draw(q: Query, res: ListingResult, url: URL) {
1245
+ // A first render in steps of these same products (shown before their
1246
+ // facets): it goes on with the facets, not a render of them all at once.
1247
+ if (this.stepping?.hits === res.hits) return this.stepping.take(q, res, url);
1248
+ this.stepping = null;
1249
+ const n = ++this.renders;
1250
+ if (canon(url.href) === this.wantedHref) this.wanted = this.wantedHref = null;
1251
+ // What this render announces once shown (WCAG 4.1.3). Not the page's
1252
+ // first render: the page itself is being read then.
1253
+ const before = this.lastQ;
1254
+ const grew = !!before && !!this.last && keyOf(q) === keyOf(before) && res.page > this.last.page && res.guid === this.last.guid;
1255
+ this.shownBefore = grew ? (this.last?.hits.length ?? 0) : 0;
1256
+ 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 }));
1257
+ // A quiet render keeps what is still to be said: the facets' render (said
1258
+ // with the products) cut short their redraw in steps, before it spoke,
1259
+ // and a filter was never announced (found by Luma's accessibility tests on a phone, 2026-10-11).
1260
+ this.toSay = n > 1 && !this.quiet ? said : this.quiet ? this.toSay : null;
1261
+ this.quiet = false;
1262
+ const at = document.activeElement;
1263
+ this.focusWas = at && at !== this && this.contains(at) ? at : null;
1264
+ if (!grew) this.moreByKey = false;
1265
+ mark(`${route.id} render ${res.hits.length} hits`);
1266
+ if (handsBack(route, q, res.total) && tk.state !== 'ours') return tk.release(), this.rendered(q, res, url);
1267
+ this.dataset.q = q.title; // the query shown, for tests and debugging
1268
+ // data-total also says "ours has rendered": integration CSS collapses
1269
+ // their held listing on it (style-host.css). While ours is still hidden
1270
+ // it is set at the reveal, or theirs would go a frame before ours shows.
1271
+ const total = () => void (this.dataset.total = String(res.total));
1272
+ if (tk.state !== 'hold') total();
1273
+ const visit = JSON.stringify([q.q, q.scope]);
1274
+ if (visit !== this.uiVisit) (this.uiVisit = visit), (this.ui = {}), (this.groupOpen = {}), (this.groupText = {}), (this.staged = null);
1275
+ const d0 = performance.now();
1276
+ let data = this.dataFor(q, res, url);
1277
+ /** The scope's own time (ms): a re-render's first step goes to the next frame when the two would not fit BUDGET. */
1278
+ const dataMs = performance.now() - d0;
1279
+ let cut: number | undefined;
1280
+ this.drawn = data;
1281
+ // Server markup that is not hydrated (no cached result for it: its facets
1282
+ // were missing) is replaced, not rendered after.
1283
+ if (this.ssr && !this.hydrateWith) {
1284
+ this.ssr = false;
1285
+ this.server ??= [...this.childNodes];
1286
+ }
1287
+ if (this.ssr && this.hydrateWith) {
1288
+ this.ssr = false;
1289
+ const markup = [...this.childNodes];
1290
+ try {
1291
+ // Bound to the server's markup with the data the server rendered it
1292
+ // from (no view state: the groups and the drawer's mode as
1293
+ // configured for the narrowest width, the facets open), then drawn
1294
+ // with this page's (its width): hydration never checks values, so
1295
+ // hydrating with ours would leave the server's in place.
1296
+ this.v.hydrate('search-box', { ...data, ...listingData(ctx, route, url, this.shownQ(q), res, this.message), facetsOpen: true }, this.hydrateWith);
1297
+ this.renderOrRebuild(data);
1298
+ mark(`${route.id} hydrated`);
1299
+ } catch (e) {
1300
+ // Markup that does not match the templates (a stale fragment from an
1301
+ // older deploy): render it anew rather than bind to the wrong nodes.
1302
+ reportError(`${route.id} hydrate`, e);
1303
+ this.server = markup;
1304
+ this.rebuild(data);
1305
+ mark(`${route.id} re-rendered after a hydration mismatch`);
1306
+ }
1307
+ } else if (tk.state !== 'ours' && !this.server && (data.results as unknown[]).length > STEP) {
1308
+ // A visit's first render, while ours is hidden: in steps, a frame
1309
+ // apart (a page of tiles and a sidebar of facets are tens of ms of
1310
+ // DOM work on a slow phone). Each step is one render of the same
1311
+ // template with longer slices of `results` and `facets`: the first
1312
+ // STEP tiles (with the skeleton), then the facet groups, then the
1313
+ // other tiles, as many as BUDGET ms holds by what a tile and a group
1314
+ // cost so far. Ours shows once what a screen holds is drawn (the
1315
+ // facets and SHOW_TILES tiles), busy until the rest is; the hooks,
1316
+ // the height and Back's scroll run when it is complete.
1317
+ const href = this.href;
1318
+ const tiles = data.results as unknown[];
1319
+ let groups = Array.isArray(data.facets) ? (data.facets as unknown[]) : [];
1320
+ const N = tiles.length;
1321
+ let G = groups.length;
1322
+ // The whole result (the facets) for these products, while this goes on.
1323
+ this.stepping = {
1324
+ hits: res.hits,
1325
+ take: (q2, r2, u2) => {
1326
+ q = q2;
1327
+ res = r2;
1328
+ url = u2;
1329
+ this.drawn = data = this.dataFor(q, res, url);
1330
+ groups = Array.isArray(data.facets) ? (data.facets as unknown[]) : [];
1331
+ G = groups.length;
1332
+ ng = Math.min(ng, G);
1333
+ this.quiet = false;
1334
+ if (res.pending || nt < N || ng < G) this.setBusy(true); // still drawing
1335
+ mark(`${route.id} facets into the render in steps`);
1336
+ },
1337
+ // The quick types landed in res: drawn with its next step.
1338
+ again: () => (this.drawn = data = this.dataFor(q, res, url)),
1339
+ };
1340
+ let nt = 0;
1341
+ let ng = 0;
1342
+ // A render's cost, ms: b per unit it adds, plus RERUN of that per unit
1343
+ // already there (lit runs every tile's template again, creating DOM
1344
+ // only for the new ones). b per kind, from the last render that added
1345
+ // that kind (the first render's, with the skeleton, for both until
1346
+ // then: an overestimate, the safe side).
1347
+ let perTile = 0;
1348
+ let perGroup = 0;
1349
+ let frames = 0;
1350
+ let shown = false;
1351
+ /** Each step's time (ms): the mark says them (a step over BUDGET shows there). */
1352
+ const spent: number[] = [];
1353
+ let fonts: Fonts | undefined;
1354
+ tk.progressed(); // the frames must not run into the hold's timeout
1355
+ this.setBusy(true); // until the last step: ours may show before it
1356
+ const draw = (t: number, g: number) => {
1357
+ const t0 = performance.now();
1358
+ // The quick types after the first step: the products' first tiles are never held back by them.
1359
+ this.renderOrRebuild(t === N && g === G ? data : { ...data, results: tiles.slice(0, t), facets: groups.slice(0, g), quick: nt ? data.quick : {} });
1360
+ const ms = performance.now() - t0;
1361
+ if (!nt) perTile = perGroup = ms / t;
1362
+ else if (t > nt && g === ng) perTile = ms / (t - nt + RERUN * (nt + ng));
1363
+ else if (g > ng && t === nt) perGroup = ms / (g - ng + RERUN * (nt + ng));
1364
+ nt = t;
1365
+ ng = g;
1366
+ };
1367
+ const show = () => {
1368
+ shown = true;
1369
+ this.revealFirst(total, () => {}, fonts);
1370
+ };
1371
+ /**
1372
+ * How many more fit BUDGET at `per` ms each, with what is there run
1373
+ * again; where not even one does (a slow phone, heavy tiles), what
1374
+ * is there again: one at a time would re-render the growing page N + G times.
1375
+ */
1376
+ const more = (per: number, have: number) => {
1377
+ const n = Math.floor((BUDGET - RERUN * per * (nt + ng)) / per);
1378
+ return n >= 1 ? n : Math.max(1, have);
1379
+ };
1380
+ const step = (first: boolean) => {
1381
+ frames++;
1382
+ const t0 = performance.now();
1383
+ if (!first) {
1384
+ // After a frame: styles and layout are clean, reading them costs only what we read.
1385
+ if (nt && ng === G) fonts ??= fontsOf(this);
1386
+ // What a screen holds is drawn (a taller one holds more rows;
1387
+ // innerHeight is no layout read): ours shows now, at the start of
1388
+ // this step, so the frame after it paints it (with this step's tiles).
1389
+ if (!shown && ng === G && nt >= Math.ceil((SHOW_TILES * Math.max(900, innerHeight)) / 900)) {
1390
+ show();
1391
+ // A screen taller than the 900 px SHOW_TILES is measured on,
1392
+ // with half of them or more drawn: the rest with this step.
1393
+ // Its end may be in view there, and then the footer would move
1394
+ // down after ours shows (ours is hidden until now: its layout
1395
+ // cannot tell). Found when tiles came from the templates' cache
1396
+ // and steps got cheaper: shown with 19 of 30 tiles at 1600 px,
1397
+ // the rest a frame later (2026-10-11).
1398
+ if (innerHeight > 900 && N - nt <= nt) draw(N, G);
1399
+ }
1400
+ // Shown, and the end of ours already in view (a first visit with
1401
+ // no height reserved): the rest now, not a footer moving down a
1402
+ // frame at a time.
1403
+ else if (shown && this.getBoundingClientRect().bottom < innerHeight) draw(N, G);
1404
+ }
1405
+ if (first) draw(Math.min(STEP, N), 0);
1406
+ else if (ng < G) {
1407
+ draw(nt, Math.min(G, ng + more(perGroup, ng)));
1408
+ } else if (nt < N) {
1409
+ draw(Math.min(N, nt + more(perTile, nt)), G);
1410
+ }
1411
+ spent.push(Math.round(performance.now() - t0));
1412
+ if (first) this.firstMs = performance.now() - t0;
1413
+ if (nt === N && ng === G) {
1414
+ mark(`${route.id} rendered in ${frames} frames (${spent.join('/')} ms)`);
1415
+ this.tileMs = perTile;
1416
+ this.stepping = null;
1417
+ if (!res.pending) this.setBusy(false); // the products alone: busy until their facets
1418
+ return this.finish(q, res, url, total, fonts, shown);
1419
+ }
1420
+ afterFrame(() => this.renders === n && this.href === href && this.guarded(() => step(false)));
1421
+ };
1422
+ // 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.
1423
+ if (dataMs + (this.firstMs || STEP * (this.tileMs || 3)) > BUDGET) afterFrame(() => this.renders === n && this.href === href && this.guarded(() => step(true)));
1424
+ else step(true);
1425
+ return;
1426
+ } else if (tk.state === 'ours' && !this.server && (data.results as unknown[]).length > (cut ??= grew ? this.drawnOf(res) + 2 * Math.max(1, this.cols) : Math.max(this.inView(), this.drawnOf(res)))) {
1427
+ // A shown listing drawn anew (a filter, a sort, Back to another
1428
+ // state): every tile at once was one task of 180-290 ms on a production
1429
+ // storefront at 4x CPU (styling and laying out all of them, under the View
1430
+ // Transition there was then). The facets and the tiles down to the
1431
+ // viewport's bottom first (counted on the tiles there now, a row
1432
+ // more: the page never ends above the viewport, the scroll stays),
1433
+ // the rest a frame at a time, as many as BUDGET holds; busy until then.
1434
+ // "Show more" too: the tiles there kept (the templates' cache) and two
1435
+ // rows of the new page, the rest a frame at a time (its end is in view,
1436
+ // so the viewport's count was all of them: 72 tiles at once, 26 ms on
1437
+ // a production storefront at 4x). They go under the last ones: nothing above moves.
1438
+ const href = this.href;
1439
+ const tiles = data.results as unknown[];
1440
+ const N = tiles.length;
1441
+ const had = this.drawnOf(res);
1442
+ let nt = cut;
1443
+ let t0 = 0;
1444
+ let per = this.tileMs || 3;
1445
+ const spent: number[] = [];
1446
+ const first = () => {
1447
+ if (this.renders !== n || this.href !== href) return;
1448
+ t0 = performance.now();
1449
+ this.renderOrRebuild({ ...data, results: tiles.slice(0, nt) });
1450
+ this.drawn$ = { hits: res.hits, n: nt };
1451
+ // A tile's cost (ms), learnt from the steps that added tiles (this
1452
+ // element's earlier renders too): a step adding none tells nothing.
1453
+ if (nt > had) per = (performance.now() - t0) / (nt - had + RERUN * had);
1454
+ this.tileMs = per;
1455
+ spent.push(Math.round(performance.now() - t0));
1456
+ this.firstMs = performance.now() - t0;
1457
+ afterFrame(() => this.guarded(step));
1458
+ };
1459
+ const step = () => {
1460
+ if (this.renders !== n || this.href !== href) return;
1461
+ t0 = performance.now();
1462
+ const k = Math.floor((BUDGET - RERUN * per * nt) / per);
1463
+ // Not even one within BUDGET (the tiles there run again cost it): a third of the rest, not one per frame.
1464
+ const t = Math.min(N, nt + (k >= 1 ? k : Math.ceil((N - nt) / 3)));
1465
+ this.renderOrRebuild(t === N ? data : { ...data, results: tiles.slice(0, t) });
1466
+ const ms = performance.now() - t0;
1467
+ spent.push(Math.round(ms));
1468
+ this.tileMs = per = ms / (t - nt + RERUN * nt);
1469
+ nt = t;
1470
+ this.drawn$ = { hits: res.hits, n: nt };
1471
+ if (nt < N) return void afterFrame(() => this.guarded(step));
1472
+ mark(`${route.id} drawn in ${spent.length} frames (${spent.join('/')} ms)`);
1473
+ if (!res.pending) this.setBusy(false);
1474
+ this.finish(q, res, url, total);
1475
+ };
1476
+ this.setBusy(true);
1477
+ // The scope and the first step in one task were 22-27 ms on a production
1478
+ // storefront at 4x (the scope 7-8 ms of it, outside the step's own time): apart when
1479
+ // they would not fit BUDGET together.
1480
+ if (dataMs + Math.max(this.firstMs, per * Math.max(1, nt - had + RERUN * had)) > BUDGET) afterFrame(() => this.guarded(first));
1481
+ else first();
1482
+ return;
1483
+ } else this.renderOrRebuild(data);
1484
+ this.finish(q, res, url, total);
1485
+ }
1486
+
1487
+ /**
1488
+ * Of these products, how many are drawn already (the same ones in the
1489
+ * same order: the products shown first, then with their facets): a
1490
+ * re-render goes on from there, never cutting them back.
1491
+ */
1492
+ private drawnOf(res: ListingResult) {
1493
+ const d = this.drawn$;
1494
+ if (!d) return 0;
1495
+ const id = (h: { [k: string]: unknown }) => String(h['@id']);
1496
+ const n = Math.min(d.n, res.hits.length);
1497
+ for (let i = 0; i < n; i++) if (id(d.hits[i]) !== id(res.hits[i])) return 0;
1498
+ return n;
1499
+ }
1500
+ /**
1501
+ * A page's first render prepared lit's templates (parsing each one's
1502
+ * HTML, walking it for its parts): about half of its first step, 88 ms
1503
+ * on a production storefront at 4x CPU, on every page load. Done while the products
1504
+ * are on their way: once per page, in two idle tasks, the template drawn
1505
+ * into a detached element, then again with two placeholder tiles, three
1506
+ * values a facet and a next page (their pictures lazy and about:blank:
1507
+ * nothing is requested; detached: no style or layout).
1508
+ * Skipped once something is drawn; a template throwing on the
1509
+ * placeholders only loses the head start (reported).
1510
+ */
1511
+ private rehearse(q: Query, url: URL) {
1512
+ if (rehearsed || this.renders || this.ssr) return;
1513
+ rehearsed = true;
1514
+ // A picture's template too (its `<img>` was most of what was left: on a phone, the first step 35 -> 18 ms):
1515
+ // the image helpers lazy (a detached lazy image is never fetched), about:blank for a template's own <img>.
1516
+ const fill = (f: string): unknown => (/price/i.test(f) ? 1290 : /image|img/i.test(f) ? 'about:blank' : /url/i.test(f) ? '/' : 'Lorem ipsum dolor');
1517
+ const lazy = imageHelpers(imgs, true);
1518
+ const hits = [0, 1].map((i) => ({ ...Object.fromEntries((cfg.fields ?? []).map((f) => [f, fill(f)])), '@id': `rehearsal/${i}`, '@type': ctx.global.itemType ?? 'product', '@title': 'Lorem ipsum dolor sit amet' }));
1519
+ const facets = Object.fromEntries(facetFields(cfg, q).map((f) => [f, /price/i.test(f) ? { field: f, type: 'range', min: 1, max: 1000 } : { field: f, type: 'terms', values: ['Lorem', 'Ipsum', 'Dolor'] }]));
1520
+ // Three idle tasks: the page's frame (no tiles, no facets), the tiles and a
1521
+ // next page ("show more"), then the facets (tiles and facets in one were
1522
+ // 17 ms on a production storefront at 4x CPU, 2026-10-12).
1523
+ const steps: ListingResult[] = [
1524
+ { hits: [], total: 0, guid: '', page: 1, cursor: null, status: 'end', facets: {}, quick: {} },
1525
+ { hits, total: 100, guid: '', page: 1, cursor: 'rehearsal', status: 'more', facets: {}, quick: {} },
1526
+ { hits, total: 100, guid: '', page: 1, cursor: 'rehearsal', status: 'more', facets, quick: {} },
1527
+ ];
1528
+ const into = document.createElement('div');
1529
+ const v = view(into, ctx.templates);
1530
+ const step = (k: number) => {
1531
+ if (this.renders) return;
1532
+ const was = { shown: this.shown, drawnUrl: this.drawnUrl };
1533
+ try {
1534
+ v.render('search-box', { ...this.dataFor(q, steps[k], url), ...lazy });
1535
+ } catch (e) {
1536
+ return reportError(`${route.id} rehearsal`, e);
1537
+ } finally {
1538
+ ({ shown: this.shown, drawnUrl: this.drawnUrl } = was);
1539
+ }
1540
+ if (k + 1 < steps.length) idle(() => step(k + 1));
1541
+ else mark(`${route.id} rehearsed`);
1542
+ };
1543
+ const idle = (f: () => void) => afterFrame(() => (typeof requestIdleCallback === 'function' ? requestIdleCallback(f, { timeout: 500 }) : setTimeout(f, 0)));
1544
+ idle(() => step(0));
1545
+ }
1546
+
1547
+ /** A tile's cost in a render, ms (re-renders in steps size their steps by it). */
1548
+ private tileMs = 0;
1549
+ /** The last first step's time (ms): the facets with the first tiles, what a render's first task costs besides its scope. */
1550
+ private firstMs = 0;
1551
+ /** The hits a render in steps drew, and how many of them (`drawnOf`). */
1552
+ private drawn$: { hits: ListingResult['hits']; n: number } | null = null;
1553
+
1554
+ /**
1555
+ * How many tiles a re-render draws first: those reaching the viewport's
1556
+ * bottom now and a row more (whole rows); all of them when the list is
1557
+ * not laid out (hidden) or ends in view.
1558
+ */
1559
+ private inView() {
1560
+ const list = this.querySelector('[data-lbx-list]');
1561
+ const tiles = list ? [...list.children] : [];
1562
+ if (!tiles.length) return Infinity;
1563
+ // From the tiles' IntersectionObserver, not their layout: read here (a
1564
+ // render, the page's layout dirty) it forced a style and layout.
1565
+ let last = -1;
1566
+ for (const t of this.seen) last = Math.max(last, tiles.indexOf(t));
1567
+ // None in view: the list is below the viewport (none needed yet), or above it (all of it: the page must not shorten under the scroll).
1568
+ if (last < 0) return this.past ? Infinity : STEP;
1569
+ if (last === tiles.length - 1) return Infinity;
1570
+ const cols = this.cols;
1571
+ return Math.max(STEP, (Math.ceil((last + 1) / cols) + 1) * cols);
1572
+ }
1573
+ /** Tiles in the viewport (an IntersectionObserver), whether the list's last one is above it, the grid's columns (read with layout clean). */
1574
+ private seen = new Set<Element>();
1575
+ private past = false;
1576
+ private cols = 1;
1577
+ private io: IntersectionObserver | null = null;
1578
+ private watched = new Set<Element>();
1579
+ /** After a render: its tiles watched (the new ones), the gone ones no longer. */
1580
+ private watch() {
1581
+ if (typeof IntersectionObserver !== 'function') return;
1582
+ this.io ??= new IntersectionObserver((es) => {
1583
+ const list = this.querySelector('[data-lbx-list]');
1584
+ for (const e of es) {
1585
+ if (e.isIntersecting) this.seen.add(e.target);
1586
+ else this.seen.delete(e.target);
1587
+ if (e.target === list?.lastElementChild) this.past = !e.isIntersecting && e.boundingClientRect.bottom <= 0;
1588
+ }
1589
+ });
1590
+ const now = new Set(this.querySelector('[data-lbx-list]')?.children ?? []);
1591
+ for (const t of this.watched) if (!now.has(t)) this.io.unobserve(t), this.watched.delete(t), this.seen.delete(t);
1592
+ for (const t of now) if (!this.watched.has(t)) this.io.observe(t), this.watched.add(t);
1593
+ }
1594
+
1595
+ /** After a render: the server's markup goes, the hook, the height and the first reveal. */
1596
+ private finish(q: Query, res: ListingResult, url: URL, total: () => void, fonts?: Fonts, shown = false) {
1597
+ // Ours rendered: the server's markup goes, and from now on this is a
1598
+ // client element like any other (the takeover may hide it).
1599
+ if (this.server) for (const n of this.server) n.remove();
1600
+ this.server = null;
1601
+ this.removeAttribute('data-lbx-ssr');
1602
+ this.last = res;
1603
+ this.drawn$ = { hits: res.hits, n: res.hits.length };
1604
+ this.lastQ = q;
1605
+ this.afterA11y();
1606
+ const info = { route: route.id, query: q.title, total: res.total, shown: res.hits.length, page: res.page, url };
1607
+ // A prerendered page (a search typed on the page before) is not seen
1608
+ // yet: the hook (titles, and often the site's analytics) runs when it
1609
+ // is shown, never for a prerender the shopper does not open.
1610
+ // The products alone (`pending`): the hook once the facets are there too, once a visit.
1611
+ if (!res.pending)
1612
+ whenShown(() => {
1613
+ try {
1614
+ cfg.onRender?.(info);
1615
+ } catch (e) {
1616
+ reportError(`${route.id} onRender`, e);
1617
+ }
1618
+ });
1619
+ // Our height on this URL, for the next visit's reserved space: measured
1620
+ // once shown. The first page's only: a reload (or any new load of the
1621
+ // URL) shows the first page, and the browser restores its scroll into
1622
+ // that height (pages "show more" appended are not loaded again).
1623
+ const remember = () => res.page === 1 && !res.pending && afterLayout(this, rememberHeight);
1624
+ // Shown already (a render in steps, before its last): the height once complete.
1625
+ if (shown) remember();
1626
+ else this.revealFirst(total, remember, fonts);
1627
+ // Back's scroll, the next page ahead: with the whole result.
1628
+ if (!res.pending) this.rendered(q, res, url);
1629
+ }
1630
+
1631
+ /**
1632
+ * The first reveal of a visit (later renders are visible already), and the
1633
+ * height remembered once shown. `fonts`: checked already (a render in chunks).
1634
+ */
1635
+ private revealFirst(total: () => void, remember: () => void, fonts?: Fonts) {
1636
+ // First reveal: in the site's final font, not its fallback (their webfont
1637
+ // may still be loading with font-display: swap). Then their listing and
1638
+ // ours swap in one frame. Later renders are already visible: no wait.
1639
+ // The fonts are read after a frame: right after a render the host page's
1640
+ // styles are dirty, and the first computed style would restyle all of it.
1641
+ if (tk.state === 'ours') return remember(), this.speak();
1642
+ tk.progressed(); // the checks must not run into the hold's timeout
1643
+ const shownHref = this.href;
1644
+ const reveal = () => {
1645
+ total();
1646
+ tk.reveal();
1647
+ remember();
1648
+ this.speak();
1649
+ };
1650
+ const then = (f: Fonts) => {
1651
+ if (!f.wait || f.done) return reveal();
1652
+ tk.progressed();
1653
+ f.wait.then(() => {
1654
+ if (this.href !== shownHref) return;
1655
+ mark(`${route.id} fonts waited ${Math.round(performance.now() - f.from)}ms`);
1656
+ reveal();
1657
+ });
1658
+ };
1659
+ if (fonts) return then(fonts);
1660
+ // Every font face of the page loaded already (a later page view): nothing to wait for, no frame to wait.
1661
+ if (facesLoaded()) return then({ wait: null, done: true, from: performance.now() });
1662
+ afterFrame(() => {
1663
+ if (this.href !== shownHref) return;
1664
+ if (tk.state === 'ours') return total(), this.speak(); // shown meanwhile (a later render of this URL)
1665
+ then(fontsOf(this));
1666
+ });
1667
+ }
1668
+ }
1669
+
1670
+ // Defining the class upgrades the element the entry (or the server) has already placed.
1671
+ if (!customElements.get(route.tag)) customElements.define(route.tag, LbxListing);
1672
+ };
1673
+
1674
+ /**
1675
+ * A menu (`[data-lbx-menu]` in the integration's markup) open or closed: the
1676
+ * attribute its CSS shows it by, and the state on its button (WCAG 4.1.2):
1677
+ * the toggle that says it has a popup (`aria-haspopup` or `aria-expanded`),
1678
+ * else the menu's first toggle. `focusIn` (opened from the keyboard): focus
1679
+ * goes to its selected item, else its first.
1680
+ */
1681
+ const setMenu = (menu: Element, open: boolean, focusIn = false) => {
1682
+ if (menu.hasAttribute('data-lbx-open') !== open) menu.toggleAttribute('data-lbx-open', open);
1683
+ const b = triggerOf(menu);
1684
+ if (b && b.getAttribute('aria-expanded') !== String(open)) b.setAttribute('aria-expanded', String(open));
1685
+ if (open && focusIn)
1686
+ afterFrame(() => {
1687
+ if (!menu.hasAttribute('data-lbx-open')) return;
1688
+ const at = document.activeElement;
1689
+ if (at && menu.contains(at) && !at.hasAttribute('data-lbx-toggle')) return; // the visitor's arrows were first
1690
+ const items = focusables(menu).filter((el) => !el.hasAttribute('data-lbx-toggle'));
1691
+ (items.find((el) => el.matches('[aria-selected="true"], [aria-current="true"], [aria-current="page"], [aria-checked="true"]')) ?? items[0])?.focus();
1692
+ });
1693
+ };
1694
+ /**
1695
+ * A tile's main control, where focus goes after "show more": the tile itself
1696
+ * when it is a link, else its first control in the Tab order that a screen
1697
+ * reader names. Not a picture link marked `tabindex="-1"` /
1698
+ * `aria-hidden="true"` (it repeats the title link): focus there was off the
1699
+ * Tab order, on nothing announced (a Magento shop's tile, 2026-10-02).
1700
+ */
1701
+ const mainControl = (tile: Element) =>
1702
+ ([tile, ...tile.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')] as HTMLElement[]).find(
1703
+ (el) => el.matches('a[href], button, input, select, textarea, [tabindex]') && el.tabIndex >= 0 && !(el as HTMLButtonElement).disabled && !el.closest('[aria-hidden="true"], [inert]'),
1704
+ ) ?? null;
1705
+ const triggerOf = (menu: Element) => (menu.querySelector('[data-lbx-toggle][aria-haspopup], [data-lbx-toggle][aria-expanded]') ?? menu.querySelector('[data-lbx-toggle]')) as HTMLElement | null;
1706
+
1707
+ /** A font check: its wait (null: nothing pending), whether it has settled, and since when. */
1708
+ type Fonts = { wait: Promise<void> | null; done: boolean; from: number };
1709
+ const fontsOf = (root: Element): Fonts => {
1710
+ const f: Fonts = { wait: fontsPending(root), done: false, from: performance.now() };
1711
+ f.wait?.then(() => void (f.done = true));
1712
+ return f;
1713
+ };
1714
+
1715
+ /** Every font face of the document has loaded (no style read). */
1716
+ const facesLoaded = () => {
1717
+ let settled = true;
1718
+ document.fonts?.forEach((f) => void (f.status !== 'loaded' && (settled = false)));
1719
+ return settled;
1720
+ };
1721
+
1722
+ /** Now, or once a prerendered document is shown (activated). */
1723
+ const whenShown = (f: () => void) => {
1724
+ if ((document as any).prerendering) document.addEventListener('prerenderingchange', f, { once: true });
1725
+ else f();
1726
+ };
1727
+
1728
+ /**
1729
+ * Fonts our listing's text will use that are not loaded yet: a promise that
1730
+ * settles when they are, capped at capMs so a slow font never holds the page.
1731
+ * null when nothing is pending. Text is checked in its own computed font with
1732
+ * its own characters (webfonts are split into unicode-range subsets).
1733
+ * Computed styles work while we are still display:none, but each costs a style
1734
+ * pass over the element: they are read for one element per kind of text
1735
+ * (tag, class and parent class decide the font in practice; a listing repeats
1736
+ * the same few dozen kinds for every tile), and not at all when every font
1737
+ * face of the page has loaded already.
1738
+ */
1739
+ const fontsPending = (root: Element, capMs = 800): Promise<void> | null => {
1740
+ if (facesLoaded()) return null;
1741
+ const kinds = new Map<string, { el: Element; chars: Set<string> }>();
1742
+ const els = [root, ...root.querySelectorAll('*')].slice(0, 400);
1743
+ for (const el of els) {
1744
+ let text = '';
1745
+ for (const n of el.childNodes) if (n.nodeType === 3) text += n.nodeValue;
1746
+ text = text.replace(/\s+/g, '');
1747
+ if (!text) continue;
1748
+ const kind = `${el.localName}.${el.className}<${el.parentElement?.className ?? ''}`;
1749
+ const k = kinds.get(kind) ?? (kinds.size < 60 ? { el, chars: new Set<string>() } : null);
1750
+ if (!k) continue;
1751
+ for (const c of text) k.chars.add(c);
1752
+ kinds.set(kind, k);
1753
+ }
1754
+ const byFont = new Map<string, Set<string>>();
1755
+ for (const { el, chars } of kinds.values()) {
1756
+ const cs = getComputedStyle(el);
1757
+ const font = `${cs.fontStyle} ${cs.fontWeight} 16px ${cs.fontFamily}`;
1758
+ const all = byFont.get(font) ?? new Set<string>();
1759
+ for (const c of chars) all.add(c);
1760
+ byFont.set(font, all);
1761
+ }
1762
+ const missing: [string, string][] = [];
1763
+ for (const [font, chars] of byFont) {
1764
+ const sample = [...chars].join('').slice(0, 300);
1765
+ try {
1766
+ if (!document.fonts.check(font, sample)) missing.push([font, sample]);
1767
+ } catch {}
1768
+ }
1769
+ if (!missing.length) return null;
1770
+ const loads = Promise.all(missing.map(([f, t]) => document.fonts.load(f, t).catch(() => [])));
1771
+ return Promise.race([loads, new Promise((r) => setTimeout(r, capMs))]).then(() => {});
1772
+ };