@luigisbox/luma 0.4.0 → 0.6.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/CHANGELOG.md +152 -0
- package/README.md +76 -9
- package/build/baseline.mjs +2 -2
- package/build/vite.mjs +255 -20
- package/docs/ACCESSIBILITY.md +31 -5
- package/package.json +3 -2
- package/src/analytics/chain.ts +1 -1
- package/src/analytics/pageview.ts +1 -1
- package/src/analytics/start.ts +3 -4
- package/src/analytics/wire.ts +1 -1
- package/src/api/ai.ts +32 -20
- package/src/api/token.ts +7 -7
- package/src/core/a11y.ts +6 -35
- package/src/core/context.ts +2 -0
- package/src/core/contrast-toggle.ts +32 -0
- package/src/core/default-button.ts +66 -0
- package/src/core/early-rules.ts +3 -2
- package/src/core/features.ts +109 -0
- package/src/core/frameworks-check.ts +32 -0
- package/src/core/frameworks.ts +35 -0
- package/src/core/image-load.ts +0 -26
- package/src/core/image-preload.ts +29 -0
- package/src/core/image-tags.ts +7 -1
- package/src/core/images.ts +3 -2
- package/src/core/integration.ts +42 -26
- package/src/core/listings.ts +9 -5
- package/src/core/locales.ts +2 -1
- package/src/core/own.ts +7 -3
- package/src/core/recommenders.ts +2 -2
- package/src/core/reconciler.ts +32 -23
- package/src/core/routes.ts +7 -6
- package/src/core/scheduler.ts +39 -5
- package/src/core/speculation.ts +2 -0
- package/src/core/stash.ts +15 -6
- package/src/core/styles.ts +6 -5
- package/src/core/support.ts +1 -1
- package/src/core/takeover.ts +6 -5
- package/src/core/templates.ts +5 -2
- package/src/core/tiles.ts +33 -10
- package/src/core/url.ts +6 -3
- package/src/core/widget-inits.ts +4 -1
- package/src/lit.ts +2 -1
- package/src/locales/cs.json +1 -1
- package/src/locales/de.json +1 -1
- package/src/locales/en.json +1 -1
- package/src/main.ts +70 -53
- package/src/types.ts +48 -14
- package/src/widgets/autocomplete/index.ts +167 -34
- package/src/widgets/autocomplete/style-autocomplete.css +16 -8
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +1 -1
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +13 -9
- package/src/widgets/listing/applied.ts +87 -0
- package/src/widgets/listing/element.ts +279 -78
- package/src/widgets/listing/intent.ts +15 -0
- package/src/widgets/listing/query.ts +6 -3
- package/src/widgets/listing/stash.ts +33 -16
- package/src/widgets/listing/state.ts +12 -0
- package/src/widgets/listing/view.ts +2 -1
- package/src/widgets/plp/route.ts +3 -2
- package/src/widgets/recommender/index.ts +3 -2
- package/src/widgets/search/direct.ts +39 -0
- package/src/widgets/search/form-url.ts +38 -34
- package/src/widgets/search/in-place.ts +43 -108
- package/src/widgets/search/index.ts +7 -2
- package/src/widgets/search/speculate.ts +48 -27
- package/src/widgets/search/submit.ts +136 -0
|
@@ -13,14 +13,16 @@
|
|
|
13
13
|
// (lit SSR) instead of rebuilding it, so nothing is fetched or repainted on
|
|
14
14
|
// load. From then on the listing is client-side as usual.
|
|
15
15
|
|
|
16
|
-
import { borrow,
|
|
16
|
+
import { borrow, liveRegion, useContrast } from '../../core/a11y';
|
|
17
17
|
import { activateByKey, byKeyboard, focusables, trapTab } from '../../core/a11y-keys';
|
|
18
|
+
import { CONTENT_VISIBILITY, DRAWER, DROPDOWN, MENU, NAMED_FADE, NAMED_PRECONNECT_IMAGES, NAMED_PREFETCH_FILTERS, NAMED_PREFETCH_NEXT_PAGE, NAMED_PRELOAD, NAMED_RESTORE_ON_BACK, ON_RENDER, SEARCH, SSR } from '../../core/features';
|
|
18
19
|
import { mark, reportError } from '../../core/health';
|
|
19
20
|
import { claim } from '../../core/reconciler';
|
|
20
21
|
import { afterLayout, rememberHeight } from '../../core/hold';
|
|
21
22
|
import { hasPrefetch, observe, peekPrefetch, peekProducts, productsChannel, putPrefetch, takePrefetch, typicalMs, type Prefetch } from '../../core/prefetch';
|
|
22
23
|
// Through the support chunk, which every widget loads with its own (src/main.ts load()).
|
|
23
|
-
import {
|
|
24
|
+
import { preloadImages } from '../../core/image-preload';
|
|
25
|
+
import { firstRowCount, firstRowImages, imageHelpers, imageOf, settleImages, warmImages } from '../../core/support';
|
|
24
26
|
import { view } from '../../core/templates';
|
|
25
27
|
import { afterFrame, isPromise } from '../../core/scheduler';
|
|
26
28
|
import { onUrlChange } from '../../core/sense';
|
|
@@ -31,19 +33,25 @@ import { canon, pageKey, writeUrl } from '../../core/url';
|
|
|
31
33
|
import { lru } from '../../core/storage';
|
|
32
34
|
import type { WidgetContext } from '../../main';
|
|
33
35
|
import type { Link } from '../../types';
|
|
34
|
-
import { facetFields, handsBack, nextState, stateOf, type Query, type Route, type UiState } from './state';
|
|
36
|
+
import { facetFields, handsBack, nextState, queryKey, stateOf, type Query, type Route, type UiState } from './state';
|
|
37
|
+
import { specKey } from '../../core/stash';
|
|
35
38
|
import { firstRequest, hasMore, listingMore, listingQuery, type ListingConfig, type ListingResult } from './query';
|
|
36
39
|
import { flowKey, notAbove, onQuickLanded, quickChannel } from './flow';
|
|
37
|
-
import { firstPages, targetOf, thrifty, watchIntent } from './intent';
|
|
40
|
+
import { firstPages, SETTLE_MS, targetOf, thrifty, watchIntent } from './intent';
|
|
38
41
|
import type { Page } from '../../api';
|
|
39
42
|
import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, viewport, type ListingView, type SsrState } from './view';
|
|
40
|
-
import { dropStash, peekStash, readStash, writeStash, type Stash } from './stash';
|
|
43
|
+
import { dropStash, peekStash, readStash, removeStash, writeStash, type Stash } from './stash';
|
|
41
44
|
import { adoptScroll, holdScroll, keepViewport, leavingLink, nearEnd, preconnectImages, releaseScroll, restoreScroll, scrollSpot } from './instant';
|
|
42
45
|
import { watch } from '../../core/tiles';
|
|
43
46
|
|
|
44
47
|
/** A re-render answered within DIM_MS (no dim shown) fades its list in from this opacity, over FADE_MS. */
|
|
45
48
|
const FADE_FROM = 0.6;
|
|
46
49
|
const FADE_MS = 150;
|
|
50
|
+
/** An Apply's status (./applied.ts) shows this long; its new tiles come in up to SWAP_TILES (those in view). */
|
|
51
|
+
const APPLIED_MS = 2500;
|
|
52
|
+
const SWAP_TILES = 16;
|
|
53
|
+
/** Reduced motion asked for (read when needed: the setting may change). */
|
|
54
|
+
const reduced = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
47
55
|
/** A hidden first render in frames: the first STEP tiles, then about BUDGET ms of rendering per frame. */
|
|
48
56
|
const STEP = 4;
|
|
49
57
|
/** 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. */
|
|
@@ -88,7 +96,7 @@ const sameUrl = (u: unknown, href: string) => {
|
|
|
88
96
|
return false;
|
|
89
97
|
}
|
|
90
98
|
};
|
|
91
|
-
const keyOf =
|
|
99
|
+
const keyOf = queryKey;
|
|
92
100
|
|
|
93
101
|
type Hydrate = (v: unknown, c: HTMLElement | ShadowRoot) => void;
|
|
94
102
|
|
|
@@ -100,7 +108,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
100
108
|
useContrast();
|
|
101
109
|
const query = (q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void) => {
|
|
102
110
|
const t0 = performance.now();
|
|
103
|
-
const p = listingQuery(ctx.api, ctx.global, cfg, route, q, signal, pre, onProducts, quickChannel(route.id));
|
|
111
|
+
const p = listingQuery(ctx.api, ctx.global, cfg, route, q, signal, pre, onProducts, SEARCH ? quickChannel(route.id) : undefined);
|
|
104
112
|
// A whole listing (not a first page fetched already): how long a fresh one takes here.
|
|
105
113
|
if (!pre) p.then((r) => !r.partial && observe(performance.now() - t0), () => {});
|
|
106
114
|
return p;
|
|
@@ -158,8 +166,23 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
158
166
|
};
|
|
159
167
|
// Filter clicks that render at once (./intent.ts): first pages fetched on intent, by their request.
|
|
160
168
|
const early = firstPages();
|
|
161
|
-
const prefetchFilters = ctx.global.prefetchFilters !== false;
|
|
169
|
+
const prefetchFilters = (NAMED_PREFETCH_FILTERS ? ctx.global.prefetchFilters !== false : true);
|
|
170
|
+
// The high-contrast toggle (`_global.contrastToggle`, off by default): its
|
|
171
|
+
// own chunk (lbx-contrast), asked for now where it is on, so a site without
|
|
172
|
+
// it does not download it with every listing.
|
|
173
|
+
const toggle = ctx.global.contrastToggle ? import('../../core/contrast-toggle') : null;
|
|
162
174
|
const visitOf = (q: Query) => JSON.stringify([q.q, q.scope]);
|
|
175
|
+
const prefetchFailed = (e: unknown) => reportError(`${route.id} filter prefetch`, e);
|
|
176
|
+
/** What a render says it shows: the count found, or nothing found. */
|
|
177
|
+
const found = (q: Query, res: ListingResult) => (res.total ? t('results', { count: res.total, query: q.title }) : t('empty', { query: q.title }));
|
|
178
|
+
// An Apply's cue (./applied.ts, its own chunk): asked for when a drawer opens where it stages.
|
|
179
|
+
let applied: typeof import('./applied') | null = null;
|
|
180
|
+
let appliedAsked = false;
|
|
181
|
+
const askApplied = () => {
|
|
182
|
+
if (appliedAsked) return;
|
|
183
|
+
appliedAsked = true;
|
|
184
|
+
import('./applied').then((m) => (m.prepare(), (applied = m)), (e) => reportError(`${route.id} applied cue`, e));
|
|
185
|
+
};
|
|
163
186
|
const earlyKey = (q: Query) => {
|
|
164
187
|
const r = firstRequest(ctx.global, cfg, route, q);
|
|
165
188
|
return r && JSON.stringify(r);
|
|
@@ -190,7 +213,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
190
213
|
return false;
|
|
191
214
|
}
|
|
192
215
|
};
|
|
193
|
-
if (document.querySelector(`${route.tag}[data-lbx-ssr]`)) adopt();
|
|
216
|
+
if (SSR && document.querySelector(`${route.tag}[data-lbx-ssr]`)) adopt();
|
|
194
217
|
|
|
195
218
|
// A result stashed for this page load (./stash.ts): the query typed on the
|
|
196
219
|
// previous page, or the listing as the shopper left it (Back). Seeded like a
|
|
@@ -199,7 +222,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
199
222
|
/** The page's templates prepared ahead (`rehearse`), once. */
|
|
200
223
|
let rehearsed = false;
|
|
201
224
|
if (!seeded.size) {
|
|
202
|
-
|
|
225
|
+
// A typed query's stash is under the query: this page's, as the search
|
|
226
|
+
// route reads it (sync for search; a category's lookup is not asked here).
|
|
227
|
+
const here = new URL(location.href);
|
|
228
|
+
const resolved = route.id === 'search' && route.accepts(here) ? route.resolve(here) : null;
|
|
229
|
+
stashed = readStash(route.id, location.href, resolved instanceof Promise ? null : resolved);
|
|
203
230
|
if (stashed?.sr) adoptScroll();
|
|
204
231
|
// Too old to render from: where the shopper was only (the results are asked for as on any load).
|
|
205
232
|
if (stashed?.stale) mark(`${route.id} back: stash too old, its scroll only`);
|
|
@@ -209,12 +236,12 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
209
236
|
mark(`${route.id} from stash (${stashed.kind}, ${stashed.result.hits.length} hits)`);
|
|
210
237
|
// A prerendered page reads (and drops) it in a copy of sessionStorage; at
|
|
211
238
|
// its activation the opener's storage, still holding it, comes back: used once.
|
|
212
|
-
const
|
|
213
|
-
if (stashed.kind === 'spec' && (document as any).prerendering) document.addEventListener('prerenderingchange', () =>
|
|
239
|
+
const key = specKey(route.id, stashed.query.q ?? '');
|
|
240
|
+
if (stashed.kind === 'spec' && (document as any).prerendering) document.addEventListener('prerenderingchange', () => removeStash(key), { once: true });
|
|
214
241
|
}
|
|
215
242
|
}
|
|
216
|
-
const restoreOn = cfg.restoreOnBack !== false;
|
|
217
|
-
const skipOut = cfg.contentVisibility === true;
|
|
243
|
+
const restoreOn = (NAMED_RESTORE_ON_BACK ? cfg.restoreOnBack !== false : true);
|
|
244
|
+
const skipOut = CONTENT_VISIBILITY ? cfg.contentVisibility === true : false;
|
|
218
245
|
|
|
219
246
|
// The request for the URL we loaded on starts now, before the element exists.
|
|
220
247
|
const bootUrl = new URL(location.href);
|
|
@@ -235,7 +262,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
235
262
|
// usually arrives while the chunk's templates, the fonts or the anchor are
|
|
236
263
|
// still on their way. A server-rendered page has them in its HTML (the server renderer).
|
|
237
264
|
const imgs = ctx.global.images ?? {};
|
|
238
|
-
const preloadOn = imgs.preload !== false;
|
|
265
|
+
const preloadOn = (NAMED_PRELOAD ? imgs.preload !== false : true);
|
|
239
266
|
const warm = imgs.warm ?? 0;
|
|
240
267
|
const preloadRow = (res: ListingResult | null | undefined) => {
|
|
241
268
|
if (res?.hits.length) preloadImages(firstRowImages(imgs, res.hits.map(ctx.result)));
|
|
@@ -323,6 +350,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
323
350
|
*/
|
|
324
351
|
private staged: UiState | null = null;
|
|
325
352
|
private stagedMode = false;
|
|
353
|
+
/**
|
|
354
|
+
* The held state fetched in the background (prefetchHeld()): the timer
|
|
355
|
+
* waiting for the changes to pause, the held state's URL (a query
|
|
356
|
+
* resolved late is for it only), and the request it started (its key in
|
|
357
|
+
* `early`), aborted by a newer held state while it is out.
|
|
358
|
+
*/
|
|
359
|
+
private heldTimer = 0;
|
|
360
|
+
private heldKey: string | null = null;
|
|
326
361
|
/** Stops watching the `facetsApply` breakpoints of the open drawer (watchApply()). */
|
|
327
362
|
private unwatchApply: (() => void) | null = null;
|
|
328
363
|
/** Renders started: a render in chunks stops when a newer one has started. */
|
|
@@ -398,19 +433,30 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
398
433
|
};
|
|
399
434
|
|
|
400
435
|
connectedCallback() {
|
|
401
|
-
|
|
436
|
+
// The quick types landed in a result shown without them (where they do
|
|
437
|
+
// not sit above the products): drawn now, into the render in steps if it
|
|
438
|
+
// is still going on. No hook, no announcement: the same result. A
|
|
439
|
+
// search page's only (core/features.ts): here, not a method, so a build
|
|
440
|
+
// without search/ leaves it out.
|
|
441
|
+
if (SEARCH)
|
|
442
|
+
onQuickLanded(route.id, (r) => {
|
|
443
|
+
if (this.stepping?.hits === r.hits) return void this.stepping.again();
|
|
444
|
+
if (this.last !== r) return; // not shown (yet): a render of it shows them
|
|
445
|
+
mark(`${route.id} quick types after the products`);
|
|
446
|
+
this.redraw();
|
|
447
|
+
});
|
|
402
448
|
// Not in the constructor: an element the parser creates after our class
|
|
403
449
|
// is defined (an async include) is constructed before its attributes are set.
|
|
404
450
|
if (this.ssr == null) {
|
|
405
451
|
// Every server element is checked against its own state (a later visit's page is another page).
|
|
406
|
-
this.ssr = this.hasAttribute('data-lbx-ssr') && adopt();
|
|
452
|
+
this.ssr = SSR ? this.hasAttribute('data-lbx-ssr') && adopt() : false;
|
|
407
453
|
// Ours from now on, before it drops its marker (the reconciler keeps it).
|
|
408
454
|
if (this.ssr) claim(route.id, this);
|
|
409
455
|
// Server markup whose state we cannot use (another URL: a param the page
|
|
410
456
|
// stripped, another host name): ours replaces it once it has rendered.
|
|
411
457
|
// A parser-created element (an async bundle ran first) is still getting
|
|
412
458
|
// its children: kept once they are all there (sync() awaits that).
|
|
413
|
-
else if (this.hasAttribute('data-lbx-ssr')) {
|
|
459
|
+
else if (SSR && this.hasAttribute('data-lbx-ssr')) {
|
|
414
460
|
claim(route.id, this);
|
|
415
461
|
const keep = () => void (this.server = [...this.childNodes]);
|
|
416
462
|
if (document.readyState === 'loading') this.cleared = new Promise<void>((r) => document.addEventListener('DOMContentLoaded', () => (keep(), r()), { once: true }));
|
|
@@ -427,20 +473,23 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
427
473
|
if (!this.hasAttribute('tabindex')) this.tabIndex = -1;
|
|
428
474
|
// Moved on within this document (an SPA's navigation): its scroll is the host's again.
|
|
429
475
|
this.unsub = onUrlChange(() => (releaseScroll(), this.urlMoved(), this.resync()));
|
|
430
|
-
|
|
476
|
+
// The drawer's own history entry: only where a template has a drawer (core/features.ts DRAWER).
|
|
477
|
+
if (DRAWER) this.unBack ??= onOwnBack(this.onBack);
|
|
431
478
|
// Product clicks and add-to-carts on our tiles: the framework's, never a template's (core/tiles.ts).
|
|
432
479
|
watch(this, { list: `lbx_${route.id}`, global: ctx.global, results: () => this.last?.hits.map(ctx.result) ?? [] });
|
|
433
480
|
this.addEventListener('click', this.onClick);
|
|
434
481
|
this.addEventListener('keydown', this.onKey);
|
|
435
482
|
this.addEventListener('focusout', this.onFocusOut);
|
|
436
|
-
|
|
437
|
-
|
|
483
|
+
if (MENU || DROPDOWN) {
|
|
484
|
+
document.addEventListener('click', this.onOutside, true);
|
|
485
|
+
document.addEventListener('pointerdown', this.onOutside, true);
|
|
486
|
+
}
|
|
438
487
|
addEventListener('pagehide', this.onPageHide);
|
|
439
488
|
addEventListener('pageshow', this.onPageShow);
|
|
440
489
|
addEventListener('scroll', this.onScroll, { passive: true });
|
|
441
490
|
if (prefetchFilters) this.intent ??= watchIntent(this, (el) => this.onIntent(el));
|
|
442
491
|
// Put back by a host re-render with its drawer open: still watched.
|
|
443
|
-
if (this.hasAttribute('data-drawer-open') && !this.unwatchApply) this.watchApply();
|
|
492
|
+
if (DRAWER && this.hasAttribute('data-drawer-open') && !this.unwatchApply) this.watchApply();
|
|
444
493
|
// Microtask: runs before paint (no empty frame) but outside the
|
|
445
494
|
// reconciler's placement pass.
|
|
446
495
|
queueMicrotask(() => this.resync());
|
|
@@ -454,8 +503,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
454
503
|
this.removeEventListener('click', this.onClick);
|
|
455
504
|
this.removeEventListener('keydown', this.onKey);
|
|
456
505
|
this.removeEventListener('focusout', this.onFocusOut);
|
|
457
|
-
|
|
458
|
-
|
|
506
|
+
if (MENU || DROPDOWN) {
|
|
507
|
+
document.removeEventListener('click', this.onOutside, true);
|
|
508
|
+
document.removeEventListener('pointerdown', this.onOutside, true);
|
|
509
|
+
}
|
|
459
510
|
removeEventListener('pagehide', this.onPageHide);
|
|
460
511
|
removeEventListener('pageshow', this.onPageShow);
|
|
461
512
|
removeEventListener('scroll', this.onScroll);
|
|
@@ -463,6 +514,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
463
514
|
this.ahead?.();
|
|
464
515
|
this.intent?.stop();
|
|
465
516
|
this.intent = null;
|
|
517
|
+
clearTimeout(this.heldTimer);
|
|
518
|
+
this.heldTimer = 0;
|
|
519
|
+
this.endCue();
|
|
520
|
+
this.removeAttribute('data-lbx-swap');
|
|
466
521
|
this.sized?.ro.disconnect();
|
|
467
522
|
this.sized = null;
|
|
468
523
|
this.unwatchApply?.();
|
|
@@ -487,7 +542,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
487
542
|
// A menu in the host's markup (their dropdown classes): [data-lbx-toggle]
|
|
488
543
|
// opens/closes its [data-lbx-menu] ancestor by a data-lbx-open attribute
|
|
489
544
|
// (the integration's CSS maps it onto their open state).
|
|
490
|
-
const tg = target.closest?.('[data-lbx-toggle]');
|
|
545
|
+
const tg = MENU ? target.closest?.('[data-lbx-toggle]') : null;
|
|
491
546
|
if (tg && this.contains(tg)) {
|
|
492
547
|
e.preventDefault();
|
|
493
548
|
// Ours alone: host code delegating on the same (their) dropdown classes must not toggle it too.
|
|
@@ -497,7 +552,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
497
552
|
if (menu) setMenu(menu, !menu.hasAttribute('data-lbx-open'), byKeyboard(e));
|
|
498
553
|
return;
|
|
499
554
|
}
|
|
500
|
-
const d = target.closest?.('[data-lbx-drawer]');
|
|
555
|
+
const d = DRAWER ? target.closest?.('[data-lbx-drawer]') : null;
|
|
501
556
|
if (!d || !this.contains(d)) {
|
|
502
557
|
// A link to another page (a product): remembered for Back.
|
|
503
558
|
const away = restoreOn && leavingLink(e, this);
|
|
@@ -547,14 +602,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
547
602
|
if (this.href) this.scrolls.set(this.href, this.y);
|
|
548
603
|
const y = this.scrolls.get(href);
|
|
549
604
|
this.restoreY = y == null ? null : { href, y };
|
|
550
|
-
if (this.hasAttribute('data-drawer-open') && !this.drawerBack) this.setDrawer(false);
|
|
605
|
+
if (DRAWER && this.hasAttribute('data-drawer-open') && !this.drawerBack) this.setDrawer(false);
|
|
551
606
|
}
|
|
552
607
|
|
|
553
608
|
/**
|
|
554
609
|
* Where the shopper was in each state of the visit left by a change of
|
|
555
610
|
* ours or a traversal (canonical href → scrollY), and the one a traversal
|
|
556
611
|
* goes back to: drawn whole (not in steps) and placed there. Bug (a
|
|
557
|
-
*
|
|
612
|
+
* production storefront, 2026-10-15): Back after a drawer filter
|
|
558
613
|
* drew the list in steps, the browser's restored scroll was clamped to
|
|
559
614
|
* the first step's short page, and Chromium's scroll anchoring kept the
|
|
560
615
|
* footer in view as the tiles went in above it.
|
|
@@ -567,7 +622,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
567
622
|
|
|
568
623
|
/** Changes go to the drawer's held state instead of the URL: it is open, where it stages. */
|
|
569
624
|
private staging() {
|
|
570
|
-
return this.stagedMode && this.hasAttribute('data-drawer-open');
|
|
625
|
+
return DRAWER ? this.stagedMode && this.hasAttribute('data-drawer-open') : false;
|
|
571
626
|
}
|
|
572
627
|
|
|
573
628
|
/**
|
|
@@ -577,24 +632,52 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
577
632
|
* middle, the count and the first tiles above the viewport.
|
|
578
633
|
*/
|
|
579
634
|
private applyStaged(from?: HTMLElement) {
|
|
635
|
+
if (!DRAWER) return;
|
|
580
636
|
const next = this.staged;
|
|
581
637
|
this.staged = null;
|
|
582
|
-
|
|
638
|
+
// Changes not fetched yet (Apply within SETTLE_MS of the last): their
|
|
639
|
+
// request starts in the next task (the drawer closes first), while the
|
|
640
|
+
// drawer's entry is taken back, and the navigation joins it.
|
|
641
|
+
if (next && this.heldTimer) setTimeout(() => this.prefetchHeld(next), 0);
|
|
642
|
+
// Another state to show: the list it replaces dims now, as the drawer closes (./applied.ts).
|
|
643
|
+
const cue = (this.applyCue = next && canon(route.write(new URL(location.href), next).href) !== this.href ? { href: '' } : null);
|
|
644
|
+
this.toggleAttribute('data-lbx-swap', !!cue);
|
|
645
|
+
this.setDrawer(false, from, next ? () => {
|
|
646
|
+
this.navigate(next);
|
|
647
|
+
this.topFor = this.wantedHref;
|
|
648
|
+
if (cue) cue.href = this.wantedHref ?? '';
|
|
649
|
+
if (cue && !cue.href) this.removeAttribute('data-lbx-swap'), (this.applyCue = null); // nothing to fetch after all
|
|
650
|
+
this.toListTop();
|
|
651
|
+
} : undefined);
|
|
583
652
|
}
|
|
584
653
|
|
|
585
654
|
/** The state an Apply asked for: its render brings the listing's top into view again (late layout, a smooth scroll cut short). */
|
|
586
655
|
private topFor: string | null = null;
|
|
656
|
+
/**
|
|
657
|
+
* An Apply's swap (./applied.ts): the state it asked for ('' until its
|
|
658
|
+
* navigation; the old list dimmed meanwhile, `data-lbx-swap`); its words
|
|
659
|
+
* and state while its render puts the new tiles in (swapIn(), from the
|
|
660
|
+
* step that draws them); then the status shown for the state applied,
|
|
661
|
+
* what takes it away, and when.
|
|
662
|
+
*/
|
|
663
|
+
private applyCue: { href: string } | null = null;
|
|
664
|
+
private swapping: { say: string; href: string } | null = null;
|
|
665
|
+
private cue: { end: (fade: boolean) => void; href: string; timer: number } | null = null;
|
|
666
|
+
/** What the applied state's render announces (the status's words). */
|
|
667
|
+
private appliedSay: string | null = null;
|
|
587
668
|
/** The top of the listing in view when it is above the viewport (smooth, unless reduced motion is asked for). */
|
|
588
669
|
private toListTop() {
|
|
589
670
|
if (!this.isConnected || this.getBoundingClientRect().top >= 0) return;
|
|
590
|
-
|
|
591
|
-
this.scrollIntoView({ block: 'start', behavior: (reduce ? 'instant' : 'smooth') as ScrollBehavior });
|
|
671
|
+
this.scrollIntoView({ block: 'start', behavior: (reduced() ? 'instant' : 'smooth') as ScrollBehavior });
|
|
592
672
|
}
|
|
593
673
|
|
|
594
674
|
/** The drawer's held changes dropped: it shows the applied state again. */
|
|
595
675
|
private discardStaged() {
|
|
596
676
|
if (!this.staged) return;
|
|
597
677
|
this.staged = null;
|
|
678
|
+
// A fetch of what it held, started already, is left to expire (./intent.ts); one not started yet is not.
|
|
679
|
+
clearTimeout(this.heldTimer);
|
|
680
|
+
this.heldTimer = 0;
|
|
598
681
|
this.redraw();
|
|
599
682
|
}
|
|
600
683
|
|
|
@@ -610,6 +693,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
610
693
|
* takes the entry back first, then runs `then`.
|
|
611
694
|
*/
|
|
612
695
|
private setDrawer(open: boolean, from?: HTMLElement, then?: () => void) {
|
|
696
|
+
if (!DRAWER) return;
|
|
613
697
|
const was = this.hasAttribute('data-drawer-open');
|
|
614
698
|
this.toggleAttribute('data-drawer-open', open);
|
|
615
699
|
for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(open));
|
|
@@ -618,6 +702,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
618
702
|
if (!open) this.discardStaged();
|
|
619
703
|
if (open && !was) {
|
|
620
704
|
this.stagedMode = atWidth(cfg.facetsApply, 'instant', viewport) === 'staged';
|
|
705
|
+
if (this.stagedMode) askApplied();
|
|
621
706
|
if (this.stagedMode && !this.drawerEntry)
|
|
622
707
|
try {
|
|
623
708
|
history.pushState(null, '', location.href);
|
|
@@ -667,14 +752,15 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
667
752
|
/**
|
|
668
753
|
* The open drawer closes when the viewport crosses into another
|
|
669
754
|
* `facetsApply` mode (a phone rotated, a window widened): the drawer was a
|
|
670
|
-
* phone's, and what it held was never confirmed. Regression (a
|
|
671
|
-
*
|
|
755
|
+
* phone's, and what it held was never confirmed. Regression (a production
|
|
756
|
+
* storefront, `{ 0: 'staged', 992: 'instant' }`, 2026-10-14): a value ticked in
|
|
672
757
|
* the drawer at 820 px stayed ticked in the 992 px sidebar with the
|
|
673
758
|
* results unfiltered and no apply button there, and rotating back showed
|
|
674
759
|
* the full-screen drawer again (`data-drawer-open` had stayed). Dropping
|
|
675
760
|
* is safer than applying: the shopper never pressed Apply.
|
|
676
761
|
*/
|
|
677
762
|
private watchApply() {
|
|
763
|
+
if (!DRAWER) return;
|
|
678
764
|
const v = cfg.facetsApply;
|
|
679
765
|
if (!v || typeof v !== 'object' || typeof matchMedia !== 'function') return;
|
|
680
766
|
const mode = atWidth(v, 'instant', viewport);
|
|
@@ -693,14 +779,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
693
779
|
*/
|
|
694
780
|
private onKey = (e: KeyboardEvent) => {
|
|
695
781
|
const target = e.target as Element;
|
|
696
|
-
const menu = target.closest?.('[data-lbx-menu][data-lbx-open]');
|
|
782
|
+
const menu = MENU ? target.closest?.('[data-lbx-menu][data-lbx-open]') : null;
|
|
697
783
|
if (e.key === 'Escape') {
|
|
698
784
|
if (menu && this.contains(menu)) {
|
|
699
785
|
e.preventDefault();
|
|
700
786
|
e.stopPropagation(); // ours, not a host modal's
|
|
701
787
|
setMenu(menu, false);
|
|
702
788
|
triggerOf(menu)?.focus();
|
|
703
|
-
} else if (this.hasAttribute('data-drawer-open')) {
|
|
789
|
+
} else if (DRAWER && this.hasAttribute('data-drawer-open')) {
|
|
704
790
|
e.preventDefault();
|
|
705
791
|
e.stopPropagation();
|
|
706
792
|
this.setDrawer(false);
|
|
@@ -716,7 +802,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
716
802
|
items[next].focus();
|
|
717
803
|
return;
|
|
718
804
|
}
|
|
719
|
-
if (e.key === 'Tab' && this.hasAttribute('data-drawer-open')) {
|
|
805
|
+
if (DRAWER && e.key === 'Tab' && this.hasAttribute('data-drawer-open')) {
|
|
720
806
|
const panel = this.querySelector('[data-lbx-drawer-panel]');
|
|
721
807
|
if (panel) trapTab(e, panel);
|
|
722
808
|
return;
|
|
@@ -726,7 +812,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
726
812
|
|
|
727
813
|
/** Focus leaving an open menu (Tab past it) closes it. */
|
|
728
814
|
private onFocusOut = (e: FocusEvent) => {
|
|
729
|
-
const menu = (e.target as Element).closest?.('[data-lbx-menu][data-lbx-open]');
|
|
815
|
+
const menu = MENU ? (e.target as Element).closest?.('[data-lbx-menu][data-lbx-open]') : null;
|
|
730
816
|
if (menu && !(e.relatedTarget && menu.contains(e.relatedTarget as Node))) setMenu(menu, false);
|
|
731
817
|
};
|
|
732
818
|
|
|
@@ -737,12 +823,12 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
737
823
|
root.setAttribute('data-lbx-a11y', '');
|
|
738
824
|
const live = liveRegion();
|
|
739
825
|
root.append(live.el);
|
|
740
|
-
|
|
826
|
+
toggle?.then((m) => root.append(m.contrastToggle(t('contrast'))), (e) => reportError(`${route.id} contrast toggle`, e));
|
|
741
827
|
this.a11y = { root, live };
|
|
742
828
|
}
|
|
743
829
|
if (this.firstChild !== this.a11y.root) this.prepend(this.a11y.root);
|
|
744
|
-
for (const m of this.querySelectorAll('[data-lbx-menu]')) setMenu(m, m.hasAttribute('data-lbx-open'));
|
|
745
|
-
for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(this.hasAttribute('data-drawer-open')));
|
|
830
|
+
if (MENU) for (const m of this.querySelectorAll('[data-lbx-menu]')) setMenu(m, m.hasAttribute('data-lbx-open'));
|
|
831
|
+
if (DRAWER) for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(this.hasAttribute('data-drawer-open')));
|
|
746
832
|
const was = this.focusWas;
|
|
747
833
|
this.focusWas = null;
|
|
748
834
|
const tile = this.moreByKey ? this.querySelector('[data-lbx-list]')?.children[this.shownBefore] : null;
|
|
@@ -770,8 +856,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
770
856
|
* not clickable (a heading, the page's background), only the pointer events.
|
|
771
857
|
*/
|
|
772
858
|
private onOutside = (e: Event) => {
|
|
773
|
-
for (const d of this.querySelectorAll('details[data-lbx-dropdown][open]')) if (!d.contains(e.target as Node)) d.removeAttribute('open');
|
|
774
|
-
for (const d of this.querySelectorAll('[data-lbx-menu][data-lbx-open]')) if (!d.contains(e.target as Node)) setMenu(d, false);
|
|
859
|
+
if (DROPDOWN) for (const d of this.querySelectorAll('details[data-lbx-dropdown][open]')) if (!d.contains(e.target as Node)) d.removeAttribute('open');
|
|
860
|
+
if (MENU) for (const d of this.querySelectorAll('[data-lbx-menu][data-lbx-open]')) if (!d.contains(e.target as Node)) setMenu(d, false);
|
|
775
861
|
};
|
|
776
862
|
|
|
777
863
|
/**
|
|
@@ -809,12 +895,18 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
809
895
|
return w;
|
|
810
896
|
}
|
|
811
897
|
|
|
812
|
-
/**
|
|
898
|
+
/**
|
|
899
|
+
* UI -> URL. The resulting URL change drives sync(). In a drawer that
|
|
900
|
+
* stages: held there, its state fetched in the background once the
|
|
901
|
+
* changes pause (nothing drawn from it until Apply).
|
|
902
|
+
*/
|
|
813
903
|
go(patch: Partial<UiState>) {
|
|
814
904
|
if (!this.current) return;
|
|
815
905
|
this.intent?.cancel(); // a dwell still pending: this click's own state is what is fetched now
|
|
816
906
|
if (this.staging()) {
|
|
817
907
|
this.staged = nextState(this.staged ?? this.wanted ?? stateOf(this.current), this.shown ?? stateOf(this.current), patch);
|
|
908
|
+
clearTimeout(this.heldTimer);
|
|
909
|
+
if (DRAWER && prefetchFilters) this.heldTimer = window.setTimeout(() => this.staging() && this.staged && this.prefetchHeld(this.staged), SETTLE_MS);
|
|
818
910
|
return this.redraw();
|
|
819
911
|
}
|
|
820
912
|
this.navigate(nextState(this.wanted ?? stateOf(this.current), this.shown ?? stateOf(this.current), patch));
|
|
@@ -930,7 +1022,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
930
1022
|
// A host re-render may destroy and re-create us for the same URL: render
|
|
931
1023
|
// synchronously from cache so the replacement never shows an empty frame.
|
|
932
1024
|
const hit = cache.get(key);
|
|
933
|
-
if (hit && this.ssr && !this.hydrateWith) {
|
|
1025
|
+
if (SSR && hit && this.ssr && !this.hydrateWith) {
|
|
934
1026
|
// Hydrate complete markup only: a parser-created element connects before its children are parsed.
|
|
935
1027
|
if (document.readyState === 'loading') await new Promise((r) => document.addEventListener('DOMContentLoaded', r, { once: true }));
|
|
936
1028
|
try {
|
|
@@ -962,6 +1054,9 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
962
1054
|
const ek = !pf && prefetchFilters ? earlyKey(q) : null;
|
|
963
1055
|
const pre = ek ? early.take(ek) : undefined;
|
|
964
1056
|
early.abortInFlight();
|
|
1057
|
+
// Server markup still being parsed (a streamed include): decided out
|
|
1058
|
+
// here, not in the try below (Rollup folds no flag in a try).
|
|
1059
|
+
const serverParsing = SSR ? () => !!this.ssr && document.readyState === 'loading' : () => false;
|
|
965
1060
|
try {
|
|
966
1061
|
let res: ListingResult;
|
|
967
1062
|
let shownEarly = false;
|
|
@@ -994,7 +1089,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
994
1089
|
this.message = null;
|
|
995
1090
|
// Another state of the visit shown (a filter, a sort): its sidebar
|
|
996
1091
|
// stays until the new facets come, as from the intent prefetch.
|
|
997
|
-
// Bug (a
|
|
1092
|
+
// Bug (a production storefront, 2026-10-15): drawn with no
|
|
998
1093
|
// facets, the sidebar collapsed to one group for ~250 ms and
|
|
999
1094
|
// Chromium's scroll anchoring threw the page to its top.
|
|
1000
1095
|
const last = this.last;
|
|
@@ -1013,7 +1108,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1013
1108
|
}
|
|
1014
1109
|
if (shownEarly) this.quiet = true; // said with the products
|
|
1015
1110
|
// Server markup still being parsed (a streamed include): render once it is complete.
|
|
1016
|
-
if (
|
|
1111
|
+
if (serverParsing()) {
|
|
1017
1112
|
await new Promise((r) => document.addEventListener('DOMContentLoaded', r, { once: true }));
|
|
1018
1113
|
if (this.href !== href) return;
|
|
1019
1114
|
}
|
|
@@ -1066,11 +1161,12 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1066
1161
|
}
|
|
1067
1162
|
this.images();
|
|
1068
1163
|
if (tk.state === 'ours') this.watch();
|
|
1164
|
+
if (DRAWER) this.swapIn(); // an Apply's new tiles, if these are they
|
|
1069
1165
|
}
|
|
1070
1166
|
|
|
1071
1167
|
/** Images a render put in that are there already (cached, persisted tiles) lose their placeholder now: no flash, no fade. */
|
|
1072
1168
|
private images() {
|
|
1073
|
-
settleImages(this, imgs.fade !== false);
|
|
1169
|
+
settleImages(this, (NAMED_FADE ? imgs.fade !== false : true));
|
|
1074
1170
|
}
|
|
1075
1171
|
|
|
1076
1172
|
/** Render anew, dropping the state lit keeps on the container (or it reuses it and paints nothing). */
|
|
@@ -1155,11 +1251,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1155
1251
|
this.ahead = null;
|
|
1156
1252
|
if (skipOut) this.sizeTiles();
|
|
1157
1253
|
// Not while prerendered: a next page of a page nobody may open is a request for nothing.
|
|
1158
|
-
if (cfg.prefetchNextPage !== false && hasMore(res)) {
|
|
1254
|
+
if ((NAMED_PREFETCH_NEXT_PAGE ? cfg.prefetchNextPage !== false : true) && hasMore(res)) {
|
|
1159
1255
|
const href = this.href;
|
|
1160
1256
|
whenShown(() => this.href === href && !this.ahead && (this.ahead = nearEnd(this, () => this.prefetchNext(q, res))));
|
|
1161
1257
|
}
|
|
1162
|
-
if (res.hits[0] && ctx.global.preconnectImages !== false) preconnectImages(ctx.result(res.hits[0]).c.image);
|
|
1258
|
+
if (res.hits[0] && (NAMED_PRECONNECT_IMAGES ? ctx.global.preconnectImages !== false : true)) preconnectImages(ctx.result(res.hits[0]).c.image);
|
|
1163
1259
|
}
|
|
1164
1260
|
|
|
1165
1261
|
/**
|
|
@@ -1227,25 +1323,89 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1227
1323
|
private onIntent(el: Element) {
|
|
1228
1324
|
const cur = this.current;
|
|
1229
1325
|
// In a drawer that stages, a control leads nowhere until Apply: nothing to fetch ahead.
|
|
1230
|
-
if (this.staging()) return;
|
|
1231
|
-
if (!cur || !this.last || this.lastQ !== cur || this.inFlight || this.wanted || tk.state !== 'ours' || thrifty()) return;
|
|
1326
|
+
if (this.staging() || !this.settled(cur)) return;
|
|
1232
1327
|
// "Show more" (`data-lbx-more`): its next page, from the cursor, as nearing the end does.
|
|
1233
|
-
if (el.matches('[data-lbx-more]')) return this.prefetchNext(cur, this.last);
|
|
1328
|
+
if (el.matches('[data-lbx-more]')) return this.prefetchNext(cur, this.last!);
|
|
1234
1329
|
const raw = targetOf(el);
|
|
1235
1330
|
if (!raw) return;
|
|
1236
1331
|
const url = new URL(raw, location.href);
|
|
1237
1332
|
if (url.origin !== location.origin || url.pathname !== location.pathname || !route.accepts(url)) return;
|
|
1333
|
+
this.prefetchState(url, 'on intent');
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* What the open staged drawer holds, fetched in the background (its
|
|
1338
|
+
* changes paused for SETTLE_MS, or Apply came first): its first page into
|
|
1339
|
+
* `early`, which Apply's sync() takes, done or in flight. Nothing is drawn
|
|
1340
|
+
* from it, no hook runs, no URL is written: the results change on Apply
|
|
1341
|
+
* only. A newer held state aborts the older one still out; back at the
|
|
1342
|
+
* state shown, nothing is fetched. Limits as on intent (thrifty(), the
|
|
1343
|
+
* visit's cap; none with `prefetchFilters` off: no timer is set).
|
|
1344
|
+
*/
|
|
1345
|
+
private prefetchHeld(st: UiState) {
|
|
1346
|
+
if (!DRAWER) return;
|
|
1347
|
+
clearTimeout(this.heldTimer);
|
|
1348
|
+
this.heldTimer = 0;
|
|
1349
|
+
const cur = this.current;
|
|
1350
|
+
if (!this.settled(cur)) return;
|
|
1351
|
+
const why = "of the drawer's held state";
|
|
1352
|
+
// The request this held state starts (or finds cached, or none): the older one still out goes.
|
|
1353
|
+
const keyed = (k: string | null) => {
|
|
1354
|
+
const was = this.heldKey;
|
|
1355
|
+
if (was && was !== k) early.drop(was);
|
|
1356
|
+
this.heldKey = k;
|
|
1357
|
+
};
|
|
1358
|
+
// From a timer: a route hook that throws (fromUrl) must not reach the
|
|
1359
|
+
// site. Nothing is fetched; the drawer keeps what it holds, and Apply
|
|
1360
|
+
// reports the hook's failure as any navigation does.
|
|
1361
|
+
try {
|
|
1362
|
+
const url = route.write(new URL(location.href), st);
|
|
1363
|
+
if (canon(url.href) === this.href) return keyed(null);
|
|
1364
|
+
// The query Apply's sync() will resolve from that URL: the same
|
|
1365
|
+
// request, to the order of the values (a value map puts some in the
|
|
1366
|
+
// site's param, the others in ours, read back first). When the route
|
|
1367
|
+
// has it at hand (memoised), that one. Else the state held, at once
|
|
1368
|
+
// (resolving the URL's slugs may ask the category's values first:
|
|
1369
|
+
// two requests in a row), and once resolved, the URL's own query
|
|
1370
|
+
// supersedes it if its request differs (the same: nothing more).
|
|
1371
|
+
const r = route.resolve(url);
|
|
1372
|
+
if (!isPromise(r)) return this.prefetchState(r, why, keyed);
|
|
1373
|
+
this.prefetchState({ ...cur, ...st }, why, keyed);
|
|
1374
|
+
r.then((q) => void (this.staged === st && this.prefetchState(q, why, keyed))).catch(prefetchFailed);
|
|
1375
|
+
} catch (e) {
|
|
1376
|
+
prefetchFailed(e);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** A listing of ours to fetch ahead from: the current state shown, nothing pending for it, a connection that allows it. */
|
|
1381
|
+
private settled(cur: Query | null): cur is Query {
|
|
1382
|
+
return !!cur && !!this.last && this.lastQ === cur && !this.inFlight && !this.wanted && tk.state === 'ours' && !thrifty();
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Another state of this visit (a control's URL, or a held state's
|
|
1387
|
+
* query), not cached: its first page, the request sync() starts with
|
|
1388
|
+
* (`firstRequest`), fetched ahead into `early` and never rendered from
|
|
1389
|
+
* here. `keyed`: told the request's key (null: none) before it starts,
|
|
1390
|
+
* also when there is nothing to start (cached).
|
|
1391
|
+
*/
|
|
1392
|
+
private prefetchState(to: URL | Query | null, why: string, keyed?: (k: string | null) => void) {
|
|
1393
|
+
const cur = this.current!;
|
|
1238
1394
|
const from = this.href;
|
|
1239
|
-
if (canon(url.href) === from) return;
|
|
1240
1395
|
const then = (q: Query | null) => {
|
|
1241
|
-
if (!q || this.href !== from || this.inFlight || this.wanted || visitOf(q) !== visitOf(cur)
|
|
1396
|
+
if (!q || this.href !== from || this.inFlight || this.wanted || visitOf(q) !== visitOf(cur)) return;
|
|
1242
1397
|
const req = firstRequest(ctx.global, cfg, route, q);
|
|
1398
|
+
const k = req && JSON.stringify(req);
|
|
1399
|
+
keyed?.(k);
|
|
1400
|
+
if (!k || cache.get(keyOf(q))) return;
|
|
1243
1401
|
// Its first images too (`_global.images.warm`, low priority): the click then shows them at once.
|
|
1244
1402
|
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));
|
|
1245
|
-
if (
|
|
1403
|
+
if (early.start(k, visitOf(q), fetch)) mark(`${route.id} prefetch ${why}`);
|
|
1246
1404
|
};
|
|
1247
|
-
|
|
1248
|
-
if (
|
|
1405
|
+
if (!(to instanceof URL)) return then(to);
|
|
1406
|
+
if (canon(to.href) === from) return;
|
|
1407
|
+
const r = route.resolve(to);
|
|
1408
|
+
if (isPromise(r)) r.then(then).catch(prefetchFailed);
|
|
1249
1409
|
else then(r);
|
|
1250
1410
|
}
|
|
1251
1411
|
|
|
@@ -1266,14 +1426,63 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1266
1426
|
this.waited = null;
|
|
1267
1427
|
const was = this.last;
|
|
1268
1428
|
const quiet = this.quiet;
|
|
1429
|
+
// An Apply's swap (./applied.ts): any render after its navigation ends
|
|
1430
|
+
// it; the applied state's own render, not an error's, says what was
|
|
1431
|
+
// applied, and its first step brings the new tiles in (swapIn()).
|
|
1432
|
+
const href = DRAWER ? canon(url.href) : '';
|
|
1433
|
+
const cue = DRAWER && this.applyCue?.href ? this.applyCue : null;
|
|
1434
|
+
const say = cue && href === cue.href && !this.message ? t('applied', { results: found(q, res) }) : null;
|
|
1435
|
+
if (cue) this.applyCue = null;
|
|
1436
|
+
if (DRAWER && this.cue && this.cue.href !== href) this.endCue();
|
|
1437
|
+
// A render of the same state before the swap's step drew its tiles (the
|
|
1438
|
+
// facets, before a first step a frame late on a phone): it carries the
|
|
1439
|
+
// swap on; it dropped it, and the list stayed dimmed for the visit.
|
|
1440
|
+
if (DRAWER) (this.appliedSay = say), (this.swapping = say && tk.state === 'ours' ? { say, href } : this.swapping?.href === href ? this.swapping : null);
|
|
1269
1441
|
this.guarded(() => this.draw(q, res, url));
|
|
1270
|
-
if (
|
|
1442
|
+
if (DRAWER) (this.appliedSay = null), !this.swapping && this.removeAttribute('data-lbx-swap');
|
|
1443
|
+
if (!say && w && !w.dimmed && was && !quiet && tk.state === 'ours' && !sameHits(res, was)) this.fadeIn();
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* The applied state's tiles are in (the render's first step): the old
|
|
1448
|
+
* list's dim ends, those in view come in, and the status says what was
|
|
1449
|
+
* applied for APPLIED_MS (`data-lbx-applied` on the element meanwhile,
|
|
1450
|
+
* for the site's own cue). Without the cue's chunk (not loaded yet, or
|
|
1451
|
+
* failed), only the dim ends: the status region still says it.
|
|
1452
|
+
*/
|
|
1453
|
+
private swapIn() {
|
|
1454
|
+
const s = DRAWER ? this.swapping : null;
|
|
1455
|
+
if (!s) return;
|
|
1456
|
+
this.swapping = null;
|
|
1457
|
+
this.removeAttribute('data-lbx-swap');
|
|
1458
|
+
if (!applied) return;
|
|
1459
|
+
this.endCue();
|
|
1460
|
+
const tiles = [...(this.querySelector('[data-lbx-list]')?.children ?? [])].slice(0, SWAP_TILES);
|
|
1461
|
+
let end: (fade: boolean) => void;
|
|
1462
|
+
try {
|
|
1463
|
+
end = applied.swapIn(this, tiles, s.say, this.a11y?.root ?? null, reduced());
|
|
1464
|
+
} catch (e) {
|
|
1465
|
+
// In the render's step: a failure of the cue costs the cue, never the listing (guarded() would hand it back).
|
|
1466
|
+
return reportError(`${route.id} applied cue`, e);
|
|
1467
|
+
}
|
|
1468
|
+
this.setAttribute('data-lbx-applied', '');
|
|
1469
|
+
this.cue = { end, href: s.href, timer: window.setTimeout(() => this.endCue(true), APPLIED_MS) };
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** The status of an Apply taken away: faded out once its time is up, at once when another state renders. */
|
|
1473
|
+
private endCue(fade = false) {
|
|
1474
|
+
const c = DRAWER ? this.cue : null;
|
|
1475
|
+
if (!c) return;
|
|
1476
|
+
this.cue = null;
|
|
1477
|
+
clearTimeout(c.timer);
|
|
1478
|
+
this.removeAttribute('data-lbx-applied');
|
|
1479
|
+
c.end(fade);
|
|
1271
1480
|
}
|
|
1272
1481
|
|
|
1273
1482
|
/** The list fading in from FADE_FROM (Web Animations: no class to restart, no layout read); not under reduced motion. */
|
|
1274
1483
|
private fadeIn() {
|
|
1275
1484
|
const list = this.querySelector('[data-lbx-list]');
|
|
1276
|
-
if (!list || typeof list.animate !== 'function' ||
|
|
1485
|
+
if (!list || typeof list.animate !== 'function' || reduced()) return;
|
|
1277
1486
|
this.fade?.cancel();
|
|
1278
1487
|
this.fade = list.animate([{ opacity: FADE_FROM }, { opacity: 1 }], { duration: FADE_MS, easing: 'cubic-bezier(.2,0,0,1)' });
|
|
1279
1488
|
}
|
|
@@ -1285,6 +1494,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1285
1494
|
} catch (e) {
|
|
1286
1495
|
reportError(`${route.id} render`, e);
|
|
1287
1496
|
this.setBusy(false);
|
|
1497
|
+
// An Apply's swap under way: the list handed back is not left dimmed.
|
|
1498
|
+
if (DRAWER) (this.swapping = null), this.removeAttribute('data-lbx-swap');
|
|
1288
1499
|
// The server's markup is still the page's: back it goes (the takeover keeps a listing that is there).
|
|
1289
1500
|
if (this.server) {
|
|
1290
1501
|
delete (this as any)._$litPart$;
|
|
@@ -1349,18 +1560,6 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1349
1560
|
}, 600);
|
|
1350
1561
|
}
|
|
1351
1562
|
|
|
1352
|
-
/**
|
|
1353
|
-
* The quick types landed in a result shown without them (where they do
|
|
1354
|
-
* not sit above the products): drawn now, into the render in steps if it
|
|
1355
|
-
* is still going on. No hook, no announcement: the same result.
|
|
1356
|
-
*/
|
|
1357
|
-
private quickIn(r: ListingResult) {
|
|
1358
|
-
if (this.stepping?.hits === r.hits) return void this.stepping.again();
|
|
1359
|
-
if (this.last !== r) return; // not shown (yet): a render of it shows them
|
|
1360
|
-
mark(`${route.id} quick types after the products`);
|
|
1361
|
-
this.redraw();
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
1563
|
/** The last render's data again with the current view state: no fetch, no announcement, focus where it is. */
|
|
1365
1564
|
private redraw() {
|
|
1366
1565
|
const q = this.lastQ;
|
|
@@ -1388,8 +1587,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1388
1587
|
if (!link || m.button > 0 || m.metaKey || m.ctrlKey || m.shiftKey || m.altKey) return; // new tab etc.: the browser's
|
|
1389
1588
|
e.preventDefault();
|
|
1390
1589
|
const at = e.currentTarget as Element | null;
|
|
1391
|
-
at?.closest('details[data-lbx-dropdown]')?.removeAttribute('open');
|
|
1392
|
-
const menu = at?.closest('[data-lbx-menu]');
|
|
1590
|
+
if (DROPDOWN) at?.closest('details[data-lbx-dropdown]')?.removeAttribute('open');
|
|
1591
|
+
const menu = MENU ? at?.closest('[data-lbx-menu]') : null;
|
|
1393
1592
|
if (menu) {
|
|
1394
1593
|
setMenu(menu, false);
|
|
1395
1594
|
// Its item had focus: back to the menu's button, not lost with the closed menu.
|
|
@@ -1440,7 +1639,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1440
1639
|
const before = this.lastQ;
|
|
1441
1640
|
const grew = !!before && !!this.last && keyOf(q) === keyOf(before) && res.page > this.last.page && res.guid === this.last.guid;
|
|
1442
1641
|
this.shownBefore = grew ? (this.last?.hits.length ?? 0) : 0;
|
|
1443
|
-
const said = this.message ?? (grew ? t('shown', { shown: res.hits.length, total: res.total, count: res.total }) :
|
|
1642
|
+
const said = this.message ?? this.appliedSay ?? (grew ? t('shown', { shown: res.hits.length, total: res.total, count: res.total }) : found(q, res));
|
|
1444
1643
|
// A quiet render keeps what is still to be said: the facets' render (said
|
|
1445
1644
|
// with the products) cut short their redraw in steps, before it spoke,
|
|
1446
1645
|
// and a filter was never announced (found by Luma's accessibility tests on a phone, 2026-10-11).
|
|
@@ -1468,11 +1667,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1468
1667
|
this.drawn = data;
|
|
1469
1668
|
// Server markup that is not hydrated (no cached result for it: its facets
|
|
1470
1669
|
// were missing) is replaced, not rendered after.
|
|
1471
|
-
if (this.ssr && !this.hydrateWith) {
|
|
1670
|
+
if (SSR && this.ssr && !this.hydrateWith) {
|
|
1472
1671
|
this.ssr = false;
|
|
1473
1672
|
this.server ??= [...this.childNodes];
|
|
1474
1673
|
}
|
|
1475
|
-
if (this.ssr && this.hydrateWith) {
|
|
1674
|
+
if (SSR && this.ssr && this.hydrateWith) {
|
|
1476
1675
|
this.ssr = false;
|
|
1477
1676
|
const markup = [...this.childNodes];
|
|
1478
1677
|
try {
|
|
@@ -1605,6 +1804,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1605
1804
|
if (!res.pending) this.setBusy(false); // the products alone: busy until their facets
|
|
1606
1805
|
return this.finish(q, res, url, total, fonts, shown);
|
|
1607
1806
|
}
|
|
1807
|
+
// The next step after a frame (afterFrame: a task soon where frames are stalled; each step's
|
|
1808
|
+
// wait was 500 ms then, five steps 2.5 s of nothing shown, 2026-10-19).
|
|
1608
1809
|
afterFrame(() => this.renders === n && this.href === href && this.guarded(() => step(false)));
|
|
1609
1810
|
};
|
|
1610
1811
|
// The scope and the first step in one task were 17-18 ms on a production storefront at 4x: apart when they would not fit.
|
|
@@ -1796,7 +1997,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1796
1997
|
// yet: the hook (titles, and often the site's analytics) runs when it
|
|
1797
1998
|
// is shown, never for a prerender the shopper does not open.
|
|
1798
1999
|
// The products alone (`pending`): the hook once the facets are there too, once a visit.
|
|
1799
|
-
if (!res.pending)
|
|
2000
|
+
if (ON_RENDER && !res.pending)
|
|
1800
2001
|
whenShown(() => {
|
|
1801
2002
|
try {
|
|
1802
2003
|
cfg.onRender?.(info);
|