@luigisbox/luma 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Listing results for the next page load of their URL, in sessionStorage
|
|
2
|
+
// (`lbx:r:<pageKey>`, core/stash.ts), so that load renders at once, as from a
|
|
3
|
+
// server-rendered state:
|
|
4
|
+
// - `spec`: the search page of a query the shopper was typing on another page
|
|
5
|
+
// (widgets/search/speculate.ts). Used by a plain navigation (or a prerender
|
|
6
|
+
// of the page) within its `ttl` (2 minutes), once.
|
|
7
|
+
// - `back`: the listing as the shopper left it for a product page (pages
|
|
8
|
+
// 1..N, where they were). Used by a back/forward load within 30 minutes; the
|
|
9
|
+
// listing then restores the scroll position and revalidates in the background.
|
|
10
|
+
// Bounded: at most 3 entries (one `spec`), 400k characters each; a full store
|
|
11
|
+
// drops ours first. Best-effort: storage may be unavailable, never required.
|
|
12
|
+
|
|
13
|
+
import { navigation, STASH, stashFits, stashKey } from '../../core/stash';
|
|
14
|
+
import { pageKey } from '../../core/page';
|
|
15
|
+
import type { ListingResult } from './query';
|
|
16
|
+
import type { Query } from './state';
|
|
17
|
+
|
|
18
|
+
export interface Stash {
|
|
19
|
+
v: 1;
|
|
20
|
+
/** Date.now() when written. First in the JSON: pruning reads it without parsing the rest. */
|
|
21
|
+
at: number;
|
|
22
|
+
kind: 'spec' | 'back';
|
|
23
|
+
/** ms it is good for (default: its kind's MAX_AGE). */
|
|
24
|
+
ttl?: number;
|
|
25
|
+
/** The listing route it is for (search, plp). */
|
|
26
|
+
route: string;
|
|
27
|
+
query: Query;
|
|
28
|
+
result: ListingResult;
|
|
29
|
+
/** back: the link the shopper followed (its href, its top in the viewport) and scrollY where it is gone. */
|
|
30
|
+
scroll?: { y: number; href?: string; top?: number };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MAX_CHARS = 400_000;
|
|
34
|
+
const KEEP = 3;
|
|
35
|
+
const MAX_AGE = { spec: 2 * 60e3, back: 30 * 60e3 };
|
|
36
|
+
|
|
37
|
+
const atOf = (raw: string | null) => Number(/"at":(\d+)/.exec(raw?.slice(0, 40) ?? '')?.[1]) || 0;
|
|
38
|
+
|
|
39
|
+
/** Ours, oldest first (without `except`). */
|
|
40
|
+
const entries = (except?: string) => {
|
|
41
|
+
const out: { k: string; at: number; spec: boolean }[] = [];
|
|
42
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
43
|
+
const k = sessionStorage.key(i);
|
|
44
|
+
if (!k?.startsWith(STASH) || k === except) continue;
|
|
45
|
+
const raw = sessionStorage.getItem(k);
|
|
46
|
+
out.push({ k, at: atOf(raw), spec: !!raw?.slice(0, 60).includes('"kind":"spec"') });
|
|
47
|
+
}
|
|
48
|
+
return out.sort((a, b) => a.at - b.at);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The stashed result this page load may render from, or null. */
|
|
52
|
+
export const readStash = (route: string, href: string): Stash | null => {
|
|
53
|
+
try {
|
|
54
|
+
const key = stashKey(href);
|
|
55
|
+
const raw = sessionStorage.getItem(key);
|
|
56
|
+
if (!raw) return null;
|
|
57
|
+
const s = JSON.parse(raw) as Stash;
|
|
58
|
+
const age = Date.now() - s.at;
|
|
59
|
+
const nav = navigation();
|
|
60
|
+
const fresh = s.v === 1 && age >= 0 && age < (s.ttl ?? MAX_AGE[s.kind] ?? 0);
|
|
61
|
+
if (!fresh || s.kind === 'spec') sessionStorage.removeItem(key); // a speculation is used once
|
|
62
|
+
if (!fresh || s.route !== route || pageKey(nav.url) !== pageKey(href)) return null;
|
|
63
|
+
return stashFits(s.kind) ? s : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** A page the browser restored itself (the back/forward cache): its stash is not needed. */
|
|
70
|
+
export const dropStash = (href: string) => {
|
|
71
|
+
try {
|
|
72
|
+
sessionStorage.removeItem(stashKey(href));
|
|
73
|
+
} catch {}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** false: too big, or storage refused it. */
|
|
77
|
+
export const writeStash = (href: string, s: Omit<Stash, 'v' | 'at'>) => {
|
|
78
|
+
try {
|
|
79
|
+
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 });
|
|
80
|
+
if (json.length > MAX_CHARS) return false;
|
|
81
|
+
const key = stashKey(href);
|
|
82
|
+
const others = entries(key);
|
|
83
|
+
// One speculation at a time; the newest few overall.
|
|
84
|
+
const drop = [...others.filter((e) => s.kind === 'spec' && e.spec), ...others.filter((e) => !(s.kind === 'spec' && e.spec)).slice(0, -(KEEP - 1) || undefined)];
|
|
85
|
+
for (const e of drop) sessionStorage.removeItem(e.k);
|
|
86
|
+
try {
|
|
87
|
+
sessionStorage.setItem(key, json);
|
|
88
|
+
} catch {
|
|
89
|
+
// Full (the host's own entries): ours go first, then one more try.
|
|
90
|
+
for (const e of entries(key)) sessionStorage.removeItem(e.k);
|
|
91
|
+
sessionStorage.setItem(key, json);
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Listing state shared by search results and category pages.
|
|
2
|
+
|
|
3
|
+
import type { FilterNode } from '../../api';
|
|
4
|
+
import { P } from '../../core/routes';
|
|
5
|
+
|
|
6
|
+
export interface Query {
|
|
7
|
+
/** Search: the query. */
|
|
8
|
+
q?: string;
|
|
9
|
+
/** Category page: the page's scope (collection_filters). */
|
|
10
|
+
scope?: FilterNode;
|
|
11
|
+
/** Facet selections (OR within a facet); a range is one `min|max` value. */
|
|
12
|
+
filters: Record<string, string[]>;
|
|
13
|
+
/** Active on/off flags, by flag id. */
|
|
14
|
+
flags: string[];
|
|
15
|
+
sort: string;
|
|
16
|
+
/** The query, or the category name. */
|
|
17
|
+
title: string;
|
|
18
|
+
/** Faceted fields of this page, in order (plp `facetsFor`); default: the listing's `facets`. */
|
|
19
|
+
facets?: string[];
|
|
20
|
+
/** The sidebar's group order for templates (fields and the integration's own group keys); default: the facets. */
|
|
21
|
+
layout?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What the URL holds and the shopper changes: filters, flags, sort. No page:
|
|
26
|
+
* a listing shows its first page and appends the next one on "show more"
|
|
27
|
+
* (`listingMore`, ./query.ts), which is not a state of the URL.
|
|
28
|
+
*/
|
|
29
|
+
export type UiState = Pick<Query, 'filters' | 'flags' | 'sort'>;
|
|
30
|
+
|
|
31
|
+
/** What differs between search and category pages: URL <-> state. */
|
|
32
|
+
export interface Route {
|
|
33
|
+
id: string;
|
|
34
|
+
tag: string;
|
|
35
|
+
/** search: a query on a search surface; collections: a scope on a collection surface. */
|
|
36
|
+
endpoint: 'search' | 'collections';
|
|
37
|
+
/** On/off filters shown as checkboxes; ids are what Query.flags holds. */
|
|
38
|
+
flagDefs: { id: string; filter: FilterNode }[];
|
|
39
|
+
accepts: (url: URL) => boolean;
|
|
40
|
+
/** URL -> query. Sync when known (memoised); null = leave this page to the host. */
|
|
41
|
+
resolve: (url: URL) => Query | null | Promise<Query | null>;
|
|
42
|
+
/** A UI change -> the URL to navigate to. */
|
|
43
|
+
write: (url: URL, next: UiState) => URL;
|
|
44
|
+
/** An empty first result means the catalog cannot populate this page: hand it back. */
|
|
45
|
+
releaseOnEmpty?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Nothing to show for the page itself (not for the shopper's filters): a route that releases on empty hands it back. The bundle and the server renderer both use it. */
|
|
49
|
+
export const handsBack = (route: Route, q: Query, total: number) => !!route.releaseOnEmpty && !total && !Object.keys(q.filters).length && !q.flags.length;
|
|
50
|
+
|
|
51
|
+
/** The page's faceted fields: its own list (or the listing's), then any other field with a selection. */
|
|
52
|
+
export const facetFields = (cfg: { facets: string[] }, q: Pick<Query, 'facets' | 'filters'>) => {
|
|
53
|
+
const list = q.facets ?? cfg.facets;
|
|
54
|
+
return [...list, ...Object.keys(q.filters).filter((f) => q.filters[f]?.length && !list.includes(f))];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const stateOf = (q: Pick<Query, 'filters' | 'flags' | 'sort'>): UiState => ({ filters: q.filters, flags: q.flags, sort: q.sort });
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Our params that are not a facet. `lbx.page` is no longer written (pages are
|
|
61
|
+
* appended, never opened by URL): an old link carrying it shows the first
|
|
62
|
+
* page, and it is never read as a facet named "page".
|
|
63
|
+
*/
|
|
64
|
+
const RESERVED = new Set([P + 'page', P + 'sort', P + 'flag']);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Facets and sort from our namespaced params. Every other `lbx.<field>`
|
|
68
|
+
* is a facet selection: which facets a page shows may depend on the page
|
|
69
|
+
* (a category's own parameters), and only our UI writes them.
|
|
70
|
+
*/
|
|
71
|
+
export const readOwn = (url: URL): UiState => {
|
|
72
|
+
const filters: Record<string, string[]> = {};
|
|
73
|
+
for (const k of new Set(url.searchParams.keys())) {
|
|
74
|
+
if (!k.startsWith(P) || RESERVED.has(k) || k.length === P.length) continue;
|
|
75
|
+
const v = url.searchParams.getAll(k).filter(Boolean);
|
|
76
|
+
if (v.length) filters[k.slice(P.length)] = v;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
filters,
|
|
80
|
+
flags: [],
|
|
81
|
+
sort: url.searchParams.get(P + 'sort') || '',
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const clearOwn = (url: URL) => {
|
|
86
|
+
for (const k of [...url.searchParams.keys()]) if (k.startsWith(P)) url.searchParams.delete(k);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The values a click changes (against the render it was built at), each
|
|
91
|
+
* toggled in the latest requested state. A value the click adds that is
|
|
92
|
+
* requested already, or removes that is removed already, is the same control
|
|
93
|
+
* clicked again before the first click rendered (a double click): it flips
|
|
94
|
+
* back. Two clicks cancel out, as the shopper's native checkbox does.
|
|
95
|
+
*/
|
|
96
|
+
const addRemove = (base: string[], shown: string[], patched: string[]) => {
|
|
97
|
+
const removed = shown.filter((v) => !patched.includes(v));
|
|
98
|
+
const added = patched.filter((v) => !shown.includes(v));
|
|
99
|
+
const flipped = [...removed, ...added];
|
|
100
|
+
return [...base.filter((v) => !flipped.includes(v)), ...flipped.filter((v) => !base.includes(v))];
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A link's patch is a whole state built at the last render. Clicked while an
|
|
105
|
+
* earlier click is still loading, it would undo that one (two quick brand
|
|
106
|
+
* ticks kept only the second). So it is applied as the change it makes to the
|
|
107
|
+
* rendered state (values added and removed), on top of the latest requested
|
|
108
|
+
* state. "Clear all" (no filters and no flags) and ranges (one min|max value)
|
|
109
|
+
* replace. Removing the only active filter is a change like any other: it
|
|
110
|
+
* keeps a tick still loading. The same value clicked twice before a render
|
|
111
|
+
* (a double click) is ticked, then unticked (`addRemove`).
|
|
112
|
+
*/
|
|
113
|
+
export const nextState = (base: UiState, shown: UiState, patch: Partial<UiState>): UiState => {
|
|
114
|
+
let filters = base.filters;
|
|
115
|
+
if (patch.filters) {
|
|
116
|
+
if (!Object.keys(patch.filters).length && patch.flags && !patch.flags.length) filters = {};
|
|
117
|
+
else {
|
|
118
|
+
filters = { ...base.filters };
|
|
119
|
+
for (const k of new Set([...Object.keys(shown.filters), ...Object.keys(patch.filters)])) {
|
|
120
|
+
const s = shown.filters[k] ?? [];
|
|
121
|
+
const p = patch.filters[k] ?? [];
|
|
122
|
+
if (s.join('\u0000') === p.join('\u0000')) continue; // untouched by this click
|
|
123
|
+
const vs = [...s, ...p].some((v) => v.includes('|')) ? p : addRemove(base.filters[k] ?? [], s, p);
|
|
124
|
+
if (vs.length) filters[k] = vs;
|
|
125
|
+
else delete filters[k];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const flags = patch.flags ? addRemove(base.flags, shown.flags, patch.flags) : base.flags;
|
|
130
|
+
return { filters, flags, sort: patch.sort ?? base.sort };
|
|
131
|
+
};
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// A listing's template scope, from (route, query, result) and nothing else:
|
|
2
|
+
// no DOM, no location. The bundle renders the live page with it,
|
|
3
|
+
// and the edge render service renders the server-side HTML with
|
|
4
|
+
// it, so the two agree and the bundle can hydrate the server's markup.
|
|
5
|
+
//
|
|
6
|
+
// Every UI action is a patch of the listing state; the scope carries the URL
|
|
7
|
+
// each patch leads to (`href`), so filters and sort are real links:
|
|
8
|
+
// crawlable, and working without JavaScript on a server-rendered page. The
|
|
9
|
+
// bundle turns a click on one into a client-side update (`go(patch)`).
|
|
10
|
+
|
|
11
|
+
import type { WidgetContext } from '../../main';
|
|
12
|
+
import type { Helpers, SearchScope } from '../../types';
|
|
13
|
+
import { fold } from '../../core/fold';
|
|
14
|
+
import { navigable } from '../../core/result';
|
|
15
|
+
import { hasMore, parseRange, type ListingConfig, type ListingResult } from './query';
|
|
16
|
+
import { facetFields, stateOf, type Query, type Route, type UiState } from './state';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A config value per viewport min-width (`{ "0": a, "768": b }`, or one value
|
|
20
|
+
* for every width): the one for the widest min-width `media` says matches.
|
|
21
|
+
* Without `media` (a server, which cannot know the width): the narrowest's.
|
|
22
|
+
*/
|
|
23
|
+
export const atWidth = <T>(v: T | Record<string, T> | undefined, fallback: T, media?: (query: string) => boolean): T => {
|
|
24
|
+
if (v == null || typeof v !== 'object' || Array.isArray(v)) return (v ?? fallback) as T;
|
|
25
|
+
const bp = Object.entries(v as Record<string, T>)
|
|
26
|
+
.map(([w, x]) => [Number(w) || 0, x] as [number, T])
|
|
27
|
+
.sort((a, b) => a[0] - b[0]);
|
|
28
|
+
if (!bp.length) return fallback;
|
|
29
|
+
let out = bp[0][1];
|
|
30
|
+
if (media) for (const [w, x] of bp) if (!w || media(`(min-width: ${w}px)`)) out = x;
|
|
31
|
+
return out;
|
|
32
|
+
};
|
|
33
|
+
/** The viewport's media queries (matchMedia: no layout read), for atWidth in the bundle (not in the entry: the listing's chunk and the server renderer only). */
|
|
34
|
+
export { media as viewport } from '../../core/images';
|
|
35
|
+
|
|
36
|
+
export type ListingData = Omit<SearchScope, keyof Helpers | 'go' | 'toggleFacet' | 'toggleFlag' | 'setSort' | 'showMore' | 'setRange' | 'ui' | 'setUi'>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The listing's view state that is not in the URL, as the bundle holds it
|
|
40
|
+
* (widgets/listing/element.ts): the groups the shopper opened or closed, the
|
|
41
|
+
* text typed into a group's search box, the drawer's mode and whether it
|
|
42
|
+
* holds changes, and the actions that change them. Without it (a server, a
|
|
43
|
+
* hydration's first pass): every group and the drawer's mode as configured
|
|
44
|
+
* for the narrowest width, no search text, nothing pending.
|
|
45
|
+
*/
|
|
46
|
+
export interface ListingView {
|
|
47
|
+
/** Groups opened or closed by the shopper, by facet name. */
|
|
48
|
+
open?: Record<string, boolean>;
|
|
49
|
+
/** Groups open by default (`facetGroups.open` at this width): all, none, or the first N of the sidebar. */
|
|
50
|
+
openDefault?: boolean | number;
|
|
51
|
+
/** A group's search text, by facet name. */
|
|
52
|
+
text?: Record<string, string>;
|
|
53
|
+
/** The drawer's mode at this width (`facetsApply`). */
|
|
54
|
+
apply?: 'instant' | 'staged';
|
|
55
|
+
/** The drawer holds changes not applied yet. */
|
|
56
|
+
pending?: boolean;
|
|
57
|
+
toggleGroup?: (name: string) => void;
|
|
58
|
+
searchGroup?: (name: string, text: string) => void;
|
|
59
|
+
/** A range committed (`setRange` of the scope): from–to, `null` an open end. */
|
|
60
|
+
setRange?: (name: string, from: number | null, to: number | null) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A DOM id for a facet group of a route: `lbx-plp-g-lbx_price` (names may hold ':' or spaces). */
|
|
64
|
+
export const groupId = (route: string, name: string) => `lbx-${route}-g-${name.replace(/[^\w-]/g, '_')}`;
|
|
65
|
+
/**
|
|
66
|
+
* A hit's Result, the same object for the same hit across renders (a
|
|
67
|
+
* result is derived from its hit alone): the templates' cache of a tile
|
|
68
|
+
* (core/templates.ts `include`) is keyed by it.
|
|
69
|
+
*/
|
|
70
|
+
const resultsBy = new WeakMap<(h: any) => any, WeakMap<object, any>>();
|
|
71
|
+
const resultOf = <R>(make: (h: any) => R) => {
|
|
72
|
+
let m = resultsBy.get(make);
|
|
73
|
+
if (!m) resultsBy.set(make, (m = new WeakMap()));
|
|
74
|
+
const cache = m;
|
|
75
|
+
return (h: object): R => {
|
|
76
|
+
let r = cache.get(h);
|
|
77
|
+
if (!r) cache.set(h, (r = make(h)));
|
|
78
|
+
return r;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** A group's search: the text folded (case, diacritics: "size" finds "Size", "creme" "Crème"), as a substring. */
|
|
83
|
+
const matcher = (text: string) => {
|
|
84
|
+
const t = fold(text.trim());
|
|
85
|
+
return (label: string, value: string) => !t || fold(label).includes(t) || fold(value).includes(t);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Template data for (query, result) at `url`. `url` is the page's URL: the
|
|
90
|
+
* hrefs are built from it with the route's own write(), so they are the same
|
|
91
|
+
* URLs the bundle would navigate to.
|
|
92
|
+
*/
|
|
93
|
+
export const listingData = (ctx: Pick<WidgetContext<ListingConfig>, 'config' | 't' | 'result'>, route: Route, url: URL, q: Query, res: ListingResult, message: string | null, view: ListingView = {}): ListingData => {
|
|
94
|
+
const cfg = ctx.config;
|
|
95
|
+
const groups = cfg.facetGroups ?? {};
|
|
96
|
+
const openDefault = view.openDefault ?? atWidth<boolean | number>(groups.open, true);
|
|
97
|
+
const searchable = new Set(groups.search ?? []);
|
|
98
|
+
const searchMin = groups.searchMin ?? 10;
|
|
99
|
+
const t = ctx.t;
|
|
100
|
+
const label = (key: string, fallback: string) => {
|
|
101
|
+
const s = t(key);
|
|
102
|
+
return s === key ? fallback : s;
|
|
103
|
+
};
|
|
104
|
+
const current = stateOf(q);
|
|
105
|
+
const href = (patch: Partial<UiState>) => {
|
|
106
|
+
const u = route.write(new URL(url.href), { ...current, ...patch });
|
|
107
|
+
// A path starting with // would be read as another host's URL.
|
|
108
|
+
return u.pathname.startsWith('//') ? u.href : u.pathname + u.search;
|
|
109
|
+
};
|
|
110
|
+
const link = (patch: Partial<UiState>) => ({ patch, href: href(patch) });
|
|
111
|
+
const without = (name: string, value?: string) => {
|
|
112
|
+
const filters = { ...q.filters };
|
|
113
|
+
filters[name] = value === undefined ? [] : (filters[name] || []).filter((v) => v !== value);
|
|
114
|
+
if (!filters[name].length) delete filters[name];
|
|
115
|
+
return filters;
|
|
116
|
+
};
|
|
117
|
+
const chips: SearchScope['chips'] = [];
|
|
118
|
+
|
|
119
|
+
const facets: SearchScope['facets'] = [];
|
|
120
|
+
const ranges: SearchScope['ranges'] = [];
|
|
121
|
+
const names = facetFields(cfg, q);
|
|
122
|
+
const layout = q.layout ? [...q.layout, ...names.filter((n) => !q.layout!.includes(n))] : names;
|
|
123
|
+
/** A group's accordion state: the shopper's toggle, else open with a selection, else the default (all, none, the first N of the sidebar). */
|
|
124
|
+
const group = (name: string, selected: boolean) => {
|
|
125
|
+
const at = layout.indexOf(name);
|
|
126
|
+
const byDefault = typeof openDefault === 'number' ? at >= 0 && at < openDefault : !!openDefault;
|
|
127
|
+
return { id: groupId(route.id, name), open: view.open?.[name] ?? (selected || byDefault), toggle: () => view.toggleGroup?.(name) };
|
|
128
|
+
};
|
|
129
|
+
for (const name of names) {
|
|
130
|
+
const data = res.facets[name];
|
|
131
|
+
const sel = q.filters[name] || [];
|
|
132
|
+
const flabel = label(`facets.${name}`, name);
|
|
133
|
+
// A range selected over a terms field (numbers as text): a range too, its bounds from the values.
|
|
134
|
+
const nums = data?.type === 'range' ? [] : (data?.values ?? []).map((v) => parseFloat(String(v))).filter(Number.isFinite);
|
|
135
|
+
if (data?.type === 'range' || (sel.length === 1 && sel[0].includes('|') && (nums.length || !data))) {
|
|
136
|
+
const picked = parseRange(sel[0]);
|
|
137
|
+
// No values yet (the products shown before their facets): the selection's
|
|
138
|
+
// ends, one open end the other's. A max of 0 under a site's own price param had the
|
|
139
|
+
// browser clamp the thumbs, and lit never set them back once the facets came.
|
|
140
|
+
const min = Math.floor(data?.type === 'range' ? data.min ?? 0 : nums.length ? Math.min(...nums) : picked.from ?? picked.to ?? 0);
|
|
141
|
+
const max = Math.ceil(data?.type === 'range' ? data.max ?? 0 : nums.length ? Math.max(...nums) : picked.to ?? picked.from ?? 0);
|
|
142
|
+
if (min >= max && !sel.length) continue;
|
|
143
|
+
const unit = label(`units.${name}`, '');
|
|
144
|
+
const step = cfg.rangeSteps?.[name] ?? 1;
|
|
145
|
+
// An end at its bound is open; both at their bounds clears it.
|
|
146
|
+
const set = (from: number | null, to: number | null) => view.setRange?.(name, from == null || from <= min ? null : from, to == null || to >= max ? null : to);
|
|
147
|
+
const cur = { from: picked.from ?? null, to: picked.to ?? null };
|
|
148
|
+
ranges.push({
|
|
149
|
+
name,
|
|
150
|
+
label: flabel,
|
|
151
|
+
min,
|
|
152
|
+
max,
|
|
153
|
+
from: picked.from ?? min,
|
|
154
|
+
to: picked.to ?? max,
|
|
155
|
+
active: !!sel.length,
|
|
156
|
+
unit,
|
|
157
|
+
step: step > 0 ? step : 1,
|
|
158
|
+
clear: link({ filters: without(name) }),
|
|
159
|
+
set,
|
|
160
|
+
setFrom: (from) => set(from, cur.to),
|
|
161
|
+
setTo: (to) => set(cur.from, to),
|
|
162
|
+
...group(name, !!sel.length),
|
|
163
|
+
});
|
|
164
|
+
if (sel.length) {
|
|
165
|
+
const value = `${picked.from ?? min}–${picked.to ?? max}${unit ? ` ${unit}` : ''}`;
|
|
166
|
+
chips.push({ kind: 'range', name, title: flabel, value, label: `${flabel}: ${value}`, ...link({ filters: without(name) }) });
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (data && data.type !== 'terms') continue; // hierarchy facets: not rendered yet
|
|
171
|
+
const vals: string[] = (data?.values ?? []).map(String);
|
|
172
|
+
const present = new Set(vals);
|
|
173
|
+
// Keep selected values visible even if the API stops returning them.
|
|
174
|
+
for (const v of sel) if (!vals.includes(v)) vals.push(v);
|
|
175
|
+
// The page's other values (emptyValues): none of the filtered results has them.
|
|
176
|
+
const all = res.facetsAll?.[name];
|
|
177
|
+
if (all?.type === 'terms') for (const v of (all.values ?? []).map(String)) if (!vals.includes(v)) vals.push(v);
|
|
178
|
+
if (!vals.length) continue;
|
|
179
|
+
const vlabel = (v: string) => label(`values.${name}.${v}`, v);
|
|
180
|
+
// A search box over the group's values (`facetGroups.search`), from `searchMin` values; client-side, as typed.
|
|
181
|
+
const text = view.text?.[name] ?? '';
|
|
182
|
+
const match = matcher(text);
|
|
183
|
+
const values = vals.map((v, rank) => {
|
|
184
|
+
const checked = sel.includes(v);
|
|
185
|
+
const filters = checked ? without(name, v) : { ...q.filters, [name]: [...sel, v] };
|
|
186
|
+
const vl = vlabel(v);
|
|
187
|
+
return { value: v, label: vl, checked, rank, match: match(vl, v), ...(present.has(v) || checked ? {} : { empty: true }), ...link({ filters }) };
|
|
188
|
+
});
|
|
189
|
+
facets.push({
|
|
190
|
+
name,
|
|
191
|
+
label: flabel,
|
|
192
|
+
selected: sel.length,
|
|
193
|
+
values,
|
|
194
|
+
search: searchable.has(name) && vals.length > searchMin ? { text, matches: values.filter((v) => v.match).length, set: (t: string) => view.searchGroup?.(name, t) } : null,
|
|
195
|
+
...group(name, !!sel.length),
|
|
196
|
+
});
|
|
197
|
+
for (const v of sel) chips.push({ kind: 'facet', name, title: flabel, value: vlabel(v), label: vlabel(v), ...link({ filters: without(name, v) }) });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const flags = route.flagDefs.map((f) => {
|
|
201
|
+
const checked = q.flags.includes(f.id);
|
|
202
|
+
return { id: f.id, label: label(`flags.${f.id}`, f.id), checked, ...link({ flags: checked ? q.flags.filter((x) => x !== f.id) : [...q.flags, f.id] }) };
|
|
203
|
+
});
|
|
204
|
+
// A flag is its own title ("In stock"): no value.
|
|
205
|
+
for (const f of flags) if (f.checked) chips.unshift({ kind: 'flag', name: f.id, title: f.label, value: '', label: f.label, ...link({ flags: q.flags.filter((x) => x !== f.id) }) });
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
message: message ?? (res.total ? null : t('empty', { query: q.title })),
|
|
209
|
+
route: route.id,
|
|
210
|
+
query: q.title,
|
|
211
|
+
title: q.title,
|
|
212
|
+
showTitle: !!cfg.showTitle,
|
|
213
|
+
total: res.total,
|
|
214
|
+
results: res.hits.map(resultOf(ctx.result)),
|
|
215
|
+
quick: Object.fromEntries(Object.entries(res.quick ?? {}).map(([k, hs]) => [k, hs.map(ctx.result).filter(navigable)])),
|
|
216
|
+
facets,
|
|
217
|
+
ranges,
|
|
218
|
+
layout,
|
|
219
|
+
flags,
|
|
220
|
+
chips,
|
|
221
|
+
clear: chips.length ? link({ filters: {}, flags: [] }) : null,
|
|
222
|
+
sorts: cfg.sorts.map((value) => ({ value, label: label(`sorts.${value}`, value || '–'), selected: value === q.sort, ...link({ sort: value }) })),
|
|
223
|
+
sort: q.sort,
|
|
224
|
+
// As far as the API can page: a set that cannot page ('unsupported') or has ended has no more, whatever its total says.
|
|
225
|
+
more: hasMore(res),
|
|
226
|
+
path: url.pathname,
|
|
227
|
+
facetsOpen: true,
|
|
228
|
+
facetsPending: !!res.pending,
|
|
229
|
+
quickPending: !!res.quickPending,
|
|
230
|
+
facetsApply: view.apply ?? atWidth(cfg.facetsApply, 'instant'),
|
|
231
|
+
pending: !!view.pending,
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** Server-rendered state, embedded before the listing: the bundle adopts it. */
|
|
236
|
+
export interface SsrState {
|
|
237
|
+
href: string;
|
|
238
|
+
query: Query;
|
|
239
|
+
result: ListingResult;
|
|
240
|
+
}
|
|
241
|
+
export const ssrStateId = (routeId: string) => `lbx-ssr-${routeId}`;
|
|
242
|
+
|
|
243
|
+
/** A wait for a re-render's results this long (ms) dims the list (./element.ts loading()); a quicker answer shows no dim. */
|
|
244
|
+
export const DIM_MS = 100;
|
|
245
|
+
/**
|
|
246
|
+
* The list dimmed while another state's results are awaited, once that
|
|
247
|
+
* takes DIM_MS (`data-lbx-loading`, set by ./element.ts loading()), and
|
|
248
|
+
* faded back when they are drawn. `--lbx-c-dim` the opacity. Reduced motion:
|
|
249
|
+
* no fade. Contrast themes: dimmed less (colours are the system's).
|
|
250
|
+
*/
|
|
251
|
+
export const LOADING_CSS =
|
|
252
|
+
':is(lbx-search,lbx-plp) [data-lbx-list]{transition:opacity .15s}' +
|
|
253
|
+
':is(lbx-search,lbx-plp)[data-lbx-loading] [data-lbx-list]{opacity:var(--lbx-c-dim,.45)}' +
|
|
254
|
+
'@media (prefers-reduced-motion:reduce){:is(lbx-search,lbx-plp) [data-lbx-list]{transition:none}}' +
|
|
255
|
+
'@media (forced-colors:active){:is(lbx-search,lbx-plp)[data-lbx-loading] [data-lbx-list]{opacity:.6}}';
|
|
256
|
+
/** sizeTiles(): out of view, a tile is skipped at the size the first one measures (Baseline Newly available: under @supports, checked by Luma's Baseline tests). */
|
|
257
|
+
export const CV_CSS = '@supports (content-visibility:auto){[data-lbx-cv] [data-lbx-list]>*{content-visibility:auto;contain-intrinsic-size:auto var(--lbx-tile-w) auto var(--lbx-tile-h)}}';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Category listing (route: ./route.ts).
|
|
2
|
+
|
|
3
|
+
import type { Takeover } from '../../core/takeover';
|
|
4
|
+
import type { WidgetContext } from '../../main';
|
|
5
|
+
import type { PlpConfigType } from '../../types';
|
|
6
|
+
import { startListing } from '../listing/element';
|
|
7
|
+
import { plpRoute } from './route';
|
|
8
|
+
|
|
9
|
+
export { templates, css } from '../search/assets';
|
|
10
|
+
|
|
11
|
+
export const start = (ctx: WidgetContext<PlpConfigType>, tk: Takeover) => startListing(ctx as any, tk, plpRoute(ctx.config, ctx.api, ctx.global));
|