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