@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.
- package/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- 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
|
+
};
|