@luigisbox/luma 0.5.0 → 0.7.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 +67 -0
- package/README.md +60 -4
- package/build/vite.mjs +6 -1
- package/docs/ACCESSIBILITY.md +52 -6
- package/package.json +1 -1
- package/src/api/ai.ts +12 -3
- package/src/api/index.ts +11 -1
- package/src/core/features.ts +7 -0
- package/src/core/integration.ts +7 -2
- package/src/locales/cs.json +1 -0
- package/src/locales/de.json +1 -0
- package/src/locales/en.json +1 -0
- package/src/types.ts +76 -17
- package/src/widgets/autocomplete/index.ts +180 -33
- package/src/widgets/autocomplete/style-autocomplete.css +16 -8
- package/src/widgets/autocomplete/template-autocomplete-box.ts +1 -1
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +4 -4
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +14 -10
- package/src/widgets/listing/applied.ts +87 -0
- package/src/widgets/listing/element.ts +261 -26
- package/src/widgets/listing/intent.ts +15 -0
- package/src/widgets/listing/query.ts +7 -3
- package/src/widgets/listing/stash.ts +11 -2
- package/src/widgets/listing/variants.ts +111 -0
- package/src/widgets/listing/view.ts +34 -3
- package/src/widgets/search/first.ts +10 -2
|
@@ -10,7 +10,7 @@ import { facetFields, type Query, type Route } from './state';
|
|
|
10
10
|
import { lru } from '../../core/storage';
|
|
11
11
|
import { reportError } from '../../core/health';
|
|
12
12
|
import { MAX_PAGE } from '../../core/routes';
|
|
13
|
-
import { EMPTY_VALUES } from '../../core/features';
|
|
13
|
+
import { EMPTY_VALUES, VARIANTS } from '../../core/features';
|
|
14
14
|
|
|
15
15
|
const num = (s: string | undefined) => (s === undefined || s === '' ? null : Number(s));
|
|
16
16
|
/** A range filter value `min|max` (either end may be empty). */
|
|
@@ -156,9 +156,11 @@ export const firstRequest = (g: GlobalConfigType, cfg: ListingConfig, route: Rou
|
|
|
156
156
|
* need their result set's guid and so a request after them. `onQuick`: the
|
|
157
157
|
* quick types never wait for (where they do not sit above the products):
|
|
158
158
|
* the result comes without them if they are not here yet (`quickPending`),
|
|
159
|
-
* and they land in it later, `onQuick` called with it then.
|
|
159
|
+
* and they land in it later, `onQuick` called with it then. `onFirst`: the
|
|
160
|
+
* first page as soon as it is here, with the facets' request (`variants`:
|
|
161
|
+
* its products' groups are asked for then, not after the facets).
|
|
160
162
|
*/
|
|
161
|
-
export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingConfig, route: Route, q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void, onQuick?: (r: ListingResult) => void): Promise<ListingResult> => {
|
|
163
|
+
export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingConfig, route: Route, q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void, onQuick?: (r: ListingResult) => void, onFirst?: (p: Page) => void): Promise<ListingResult> => {
|
|
162
164
|
// A range selection over a field that may not be a numeric one (a page's
|
|
163
165
|
// own facet, e.g. a parameter holding its numbers as text): if the API facets
|
|
164
166
|
// it as terms, the range is the values within it, from the values of the
|
|
@@ -259,6 +261,8 @@ export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingCo
|
|
|
259
261
|
: {};
|
|
260
262
|
// Optional too: without them no value shows as empty.
|
|
261
263
|
const all = EMPTY_VALUES && cfg.emptyValues && narrowed ? allOf().catch(() => undefined) : undefined;
|
|
264
|
+
// Extras after what the listing waits for (its facets' requests are out).
|
|
265
|
+
if (VARIANTS) onFirst?.(first);
|
|
262
266
|
// The products now, the facets when they come: a request (theirs needs
|
|
263
267
|
// this result set's guid) the shopper would otherwise wait for.
|
|
264
268
|
const early: ListingResult | null =
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import { navigation, specKey, STASH, stashFits, stashKey } from '../../core/stash';
|
|
22
22
|
import { pageKey } from '../../core/page';
|
|
23
|
+
import { VARIANTS } from '../../core/features';
|
|
23
24
|
import type { ListingResult } from './query';
|
|
24
25
|
import { queryKey, type Query } from './state';
|
|
25
26
|
import type { Spot } from './instant';
|
|
@@ -39,6 +40,8 @@ export interface Stash {
|
|
|
39
40
|
scroll?: Spot;
|
|
40
41
|
/** back: Luma turned the browser's own scroll restoration off for the entry left (`history.scrollRestoration`): back on once restored. */
|
|
41
42
|
sr?: 1;
|
|
43
|
+
/** back, `variants`: the groups of the products shown (./variants.ts), drawn with them. Storage: checked when read. */
|
|
44
|
+
groups?: unknown;
|
|
42
45
|
/** Read only: too old to render from, where the shopper was is all that is used. */
|
|
43
46
|
stale?: true;
|
|
44
47
|
}
|
|
@@ -114,9 +117,15 @@ export const removeStash = (key: string) => {
|
|
|
114
117
|
};
|
|
115
118
|
|
|
116
119
|
/** Under the page's URL (`back`) or the query (`spec`). false: too big, or storage refused it. */
|
|
117
|
-
export const writeStash = (href: string, s: Omit<Stash, 'v' | 'at'>) =>
|
|
120
|
+
export const writeStash = (href: string, s: Omit<Stash, 'v' | 'at'>) =>
|
|
121
|
+
// Too big with the products' variant groups (extras): stashed without them rather than not at all.
|
|
122
|
+
put(href, s) || (VARIANTS ? !!s.groups && put(href, { ...s, groups: undefined }) : false);
|
|
123
|
+
|
|
124
|
+
const put = (href: string, s: Omit<Stash, 'v' | 'at'>) => {
|
|
118
125
|
try {
|
|
119
|
-
|
|
126
|
+
// `at` and `kind` first: pruning reads them without parsing the rest.
|
|
127
|
+
const { kind, ...rest } = s;
|
|
128
|
+
const json = JSON.stringify({ at: Date.now(), v: 1, kind, ...rest });
|
|
120
129
|
if (json.length > MAX_CHARS) return false;
|
|
121
130
|
const key = s.kind === 'spec' ? specKey(s.route, s.query.q ?? '') : stashKey(href);
|
|
122
131
|
const others = entries(key);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// `variants` (a listing's config): the variant groups of the products a
|
|
2
|
+
// listing shows, for a surface that collapses each group into one result
|
|
3
|
+
// (/variants). Asked per result set (its request_key, `guid`) for the
|
|
4
|
+
// products not asked yet: a page's as soon as it is here (the first, each
|
|
5
|
+
// "show more", the pages of a Back restore without a stash of them at
|
|
6
|
+
// once), at most MAX_IDS a request. Never waited for: the listing draws its
|
|
7
|
+
// tiles, and again when the groups land (widgets/listing/element.ts).
|
|
8
|
+
|
|
9
|
+
import type { Api, VariantGroup } from '../../api';
|
|
10
|
+
import { reportError } from '../../core/health';
|
|
11
|
+
import { lru } from '../../core/storage';
|
|
12
|
+
import type { ListingConfig, ListingResult } from './query';
|
|
13
|
+
|
|
14
|
+
/** /variants takes at most this many ids. */
|
|
15
|
+
const MAX_IDS = 200;
|
|
16
|
+
/** A failed request's products are asked again by a render this much later, not by every render in an outage. */
|
|
17
|
+
const RETRY_MS = 5000;
|
|
18
|
+
|
|
19
|
+
/** What a request needs of a result: its result set and its products (a page, or the pages loaded). */
|
|
20
|
+
type Products = Pick<ListingResult, 'guid' | 'hits'>;
|
|
21
|
+
|
|
22
|
+
interface Groups {
|
|
23
|
+
/** Ids asked for: answered, in flight, or failed less than RETRY_MS ago. */
|
|
24
|
+
asked: Set<string>;
|
|
25
|
+
/** The groups answered, by id: a new map whenever some land (a render compares it). */
|
|
26
|
+
got: ReadonlyMap<string, VariantGroup>;
|
|
27
|
+
/** Requests in flight. */
|
|
28
|
+
out: Set<Promise<void>>;
|
|
29
|
+
}
|
|
30
|
+
/** By result set (a request_key names one listing's request: its fields too), for the page's life (as the listing's result cache). */
|
|
31
|
+
const sets = lru<Groups>(30, Infinity);
|
|
32
|
+
const setOf = (guid: string) => {
|
|
33
|
+
let s = sets.get(guid);
|
|
34
|
+
if (!s) sets.set(guid, (s = { asked: new Set(), got: new Map(), out: new Set() }));
|
|
35
|
+
return s;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* A group's shape, from the API or from storage: nothing else is kept, nothing
|
|
39
|
+
* coerced. Each sibling becomes a result once a template reads it (a hit's
|
|
40
|
+
* `@id` at least); a total `Number()` would throw on (`{ toString: null }`) is
|
|
41
|
+
* none. Anything else: no group, the tile as without one.
|
|
42
|
+
*/
|
|
43
|
+
const group = (g: any): VariantGroup | null =>
|
|
44
|
+
Array.isArray(g?.siblings) && Number.isFinite(g.total) && g.siblings.every((s: any) => typeof s?.['@id'] === 'string') ? { siblings: g.siblings, total: g.total } : null;
|
|
45
|
+
|
|
46
|
+
/** The groups of `res`'s products answered so far: the same map until more land. */
|
|
47
|
+
export const groupsOf = (res: Products) => sets.get(res.guid)?.got;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Asks for the groups of `res`'s products not asked yet (all asked: no
|
|
51
|
+
* request). Settles once every request of its result set in flight has, and
|
|
52
|
+
* never rejects: what landed is in groupsOf(). An aborted request's
|
|
53
|
+
* products can be asked again at once (a host re-creating the listing
|
|
54
|
+
* renders it again in the same task), a failed one's (reported) after
|
|
55
|
+
* RETRY_MS.
|
|
56
|
+
*/
|
|
57
|
+
export const askVariants = (api: Api, cfg: ListingConfig, res: Products, signal?: AbortSignal): Promise<unknown> => {
|
|
58
|
+
if (!cfg.variants || !res.guid) return Promise.resolve();
|
|
59
|
+
const s = setOf(res.guid);
|
|
60
|
+
const ids = signal?.aborted ? [] : res.hits.map((h) => h['@id']).filter((id) => !s.asked.has(id));
|
|
61
|
+
for (let i = 0; i < ids.length; i += MAX_IDS) {
|
|
62
|
+
const chunk = ids.slice(i, i + MAX_IDS);
|
|
63
|
+
for (const id of chunk) s.asked.add(id);
|
|
64
|
+
const forget = () => chunk.forEach((id) => s.asked.delete(id));
|
|
65
|
+
signal?.addEventListener('abort', forget, { once: true });
|
|
66
|
+
const p: Promise<void> = api
|
|
67
|
+
.variants!(chunk, res.guid, cfg.variants.fields, signal)
|
|
68
|
+
.then(
|
|
69
|
+
(got) => {
|
|
70
|
+
const next = new Map(s.got);
|
|
71
|
+
for (const id of chunk) {
|
|
72
|
+
const g = group(got[id]);
|
|
73
|
+
if (g) next.set(id, g);
|
|
74
|
+
}
|
|
75
|
+
s.got = next;
|
|
76
|
+
},
|
|
77
|
+
(e) => {
|
|
78
|
+
if ((e as Error)?.name === 'AbortError') return;
|
|
79
|
+
reportError('variants', e);
|
|
80
|
+
setTimeout(forget, RETRY_MS);
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
.finally(() => (s.out.delete(p), signal?.removeEventListener('abort', forget)));
|
|
84
|
+
s.out.add(p);
|
|
85
|
+
}
|
|
86
|
+
return Promise.all(s.out);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** The groups landed of `res`'s products, for its Back stash: the restored page draws them at once. */
|
|
90
|
+
export const stashGroups = (res: Products) => {
|
|
91
|
+
const got = groupsOf(res);
|
|
92
|
+
return res.hits.flatMap((h): [string, VariantGroup][] => {
|
|
93
|
+
const g = got?.get(h['@id']);
|
|
94
|
+
return g ? [[h['@id'], g]] : [];
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** A Back stash's groups (storage: each checked) as answered for `res`'s products. */
|
|
99
|
+
export const seedGroups = (res: Products, stashed: unknown) => {
|
|
100
|
+
if (!res.guid || !Array.isArray(stashed)) return;
|
|
101
|
+
const s = setOf(res.guid);
|
|
102
|
+
const ids = new Set(res.hits.map((h) => h['@id']));
|
|
103
|
+
const next = new Map(s.got);
|
|
104
|
+
for (const e of stashed) {
|
|
105
|
+
const g = ids.has(e?.[0]) ? group(e[1]) : null;
|
|
106
|
+
if (!g) continue;
|
|
107
|
+
next.set(e[0], g);
|
|
108
|
+
s.asked.add(e[0]);
|
|
109
|
+
}
|
|
110
|
+
s.got = next;
|
|
111
|
+
};
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
// bundle turns a click on one into a client-side update (`go(patch)`).
|
|
10
10
|
|
|
11
11
|
import type { WidgetContext } from '../../main';
|
|
12
|
-
import type {
|
|
13
|
-
import {
|
|
12
|
+
import type { VariantGroup } from '../../api';
|
|
13
|
+
import type { Helpers, Result, SearchScope } from '../../types';
|
|
14
|
+
import { SEARCH, VARIANTS } from '../../core/features';
|
|
14
15
|
import { fold } from '../../core/fold';
|
|
15
16
|
import { navigable } from '../../core/result';
|
|
16
17
|
import { hasMore, parseRange, type ListingConfig, type ListingResult } from './query';
|
|
@@ -62,6 +63,8 @@ export interface ListingView {
|
|
|
62
63
|
searchGroup?: (name: string, text: string) => void;
|
|
63
64
|
/** A range committed (`setRange` of the scope): from–to, `null` an open end. */
|
|
64
65
|
setRange?: (name: string, from: number | null, to: number | null) => void;
|
|
66
|
+
/** `variants`: the products' variant groups landed so far, by `@id` (./variants.ts). */
|
|
67
|
+
variants?: ReadonlyMap<string, VariantGroup>;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
/** A DOM id for a facet group of a route: `lbx-plp-g-lbx_price` (names may hold ':' or spaces). */
|
|
@@ -83,6 +86,34 @@ const resultOf = <R>(make: (h: any) => R) => {
|
|
|
83
86
|
};
|
|
84
87
|
};
|
|
85
88
|
|
|
89
|
+
/**
|
|
90
|
+
* `variants`: a hit's Result with its variant group (`r.variants`), the same
|
|
91
|
+
* object for the same hit and group: a tile is drawn again when its group
|
|
92
|
+
* lands, and only then. Its own computed attributes (`r.c`), which may read
|
|
93
|
+
* the group.
|
|
94
|
+
*/
|
|
95
|
+
const grouped = VARIANTS ? new WeakMap<VariantGroup, { h: object; r: Result }>() : null;
|
|
96
|
+
export const withGroups = (make: (h: any) => Result, groups: ReadonlyMap<string, VariantGroup>) => {
|
|
97
|
+
const plain = resultOf(make);
|
|
98
|
+
return (h: Record<string, any>): Result => {
|
|
99
|
+
const g = groups.get(h['@id']);
|
|
100
|
+
if (!g) return plain(h);
|
|
101
|
+
const was = grouped!.get(g);
|
|
102
|
+
if (was?.h === h) return was.r;
|
|
103
|
+
const r = make(h);
|
|
104
|
+
// Its siblings as results once a template reads them (up to 100 a tile, computed attributes each).
|
|
105
|
+
let siblings: Result[] | undefined;
|
|
106
|
+
r.variants = {
|
|
107
|
+
get siblings() {
|
|
108
|
+
return (siblings ??= g.siblings.map(make));
|
|
109
|
+
},
|
|
110
|
+
total: g.total,
|
|
111
|
+
};
|
|
112
|
+
grouped!.set(g, { h, r });
|
|
113
|
+
return r;
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
86
117
|
/** A group's search: the text folded (case, diacritics: "size" finds "Size", "creme" "Crème"), as a substring. */
|
|
87
118
|
const matcher = (text: string) => {
|
|
88
119
|
const t = fold(text.trim());
|
|
@@ -222,7 +253,7 @@ export const listingData = (ctx: Pick<WidgetContext<ListingConfig>, 'config' | '
|
|
|
222
253
|
title: q.title,
|
|
223
254
|
showTitle: !!cfg.showTitle,
|
|
224
255
|
total: res.total,
|
|
225
|
-
results: res.hits.map(resultOf(ctx.result)),
|
|
256
|
+
results: res.hits.map(VARIANTS && view.variants ? withGroups(ctx.result, view.variants) : resultOf(ctx.result)),
|
|
226
257
|
quick: SEARCH ? Object.fromEntries(Object.entries(res.quick ?? {}).map(([k, hs]) => [k, hs.map(ctx.result).filter(navigable)])) : {},
|
|
227
258
|
facets,
|
|
228
259
|
ranges,
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// entry: the query code (route, query building, the answer's shaping) is in
|
|
4
4
|
// lbx-nav, which the listing imports anyway, and not on pages without a listing
|
|
5
5
|
// (the API client's requests are in lbx-core, which the listing loads too).
|
|
6
|
-
import { getApi } from '../../api';
|
|
6
|
+
import { getApi, type Page } from '../../api';
|
|
7
|
+
import { VARIANTS } from '../../core/features';
|
|
7
8
|
import type { GlobalConfigType, SearchConfigType } from '../../types';
|
|
8
9
|
import { quickChannel } from '../listing/flow';
|
|
9
10
|
import { listingQuery, type ListingResult } from '../listing/query';
|
|
@@ -11,5 +12,12 @@ import { searchRoute } from './route';
|
|
|
11
12
|
|
|
12
13
|
export const searchFirst = (g: GlobalConfigType, cfg: SearchConfigType, url: URL, onProducts?: (r: ListingResult) => void) => {
|
|
13
14
|
const route = searchRoute(cfg);
|
|
14
|
-
|
|
15
|
+
const api = getApi(g);
|
|
16
|
+
// `variants`: the first page's groups with its facets, as the listing's own
|
|
17
|
+
// request asks them (the listing finds them asked). Imported here, not
|
|
18
|
+
// statically: its module is lbx-listing's, which lbx-nav (search while
|
|
19
|
+
// typing on any page, this boot request) must not wait for. Its chunk
|
|
20
|
+
// failing is the listing's to report, not an unhandled rejection on the site.
|
|
21
|
+
const ask = VARIANTS && cfg.variants ? (f: Page) => void import('../listing/variants').then((m) => m.askVariants(api, cfg as any, f), () => {}) : undefined;
|
|
22
|
+
return listingQuery(api, g, cfg as any, route, route.resolve(url) as any, undefined, undefined, onProducts, quickChannel(route.id), ask);
|
|
15
23
|
};
|