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