@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
package/src/types.ts
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
// Integration config types. `_config.ts` holds JSON-safe
|
|
2
|
+
// values only (it is parsed as JSON), `_init.ts` sets anything that needs code
|
|
3
|
+
// (functions, computed attributes) and may mutate the config it is given.
|
|
4
|
+
|
|
5
|
+
import type { FilterNode, Hit } from './api';
|
|
6
|
+
import type { Position } from './core/reconciler';
|
|
7
|
+
import type { ImageInfo, ImageOptions } from './core/images';
|
|
8
|
+
|
|
9
|
+
export type { FilterNode, Hit, ImageInfo, ImageOptions, Position };
|
|
10
|
+
|
|
11
|
+
/** A result as templates see it: `a` = the hit's fields (flat, as discovery returns them), `c` = computed attributes. */
|
|
12
|
+
export interface Result {
|
|
13
|
+
/** `@id`, e.g. "product/<id>". */
|
|
14
|
+
id: string;
|
|
15
|
+
/** `@type`: product, category, brand, … */
|
|
16
|
+
type: string;
|
|
17
|
+
a: Hit;
|
|
18
|
+
c: Record<string, any>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Nested dictionary; `{name}` placeholders. A plural is an object of
|
|
23
|
+
* `Intl.PluralRules` categories (`zero`, `one`, `two`, `few`, `many`,
|
|
24
|
+
* `other`; `other` required), picked by the `count` param in the locale:
|
|
25
|
+
* `{ one: '{count} produkt', few: '{count} produkty', many: '{count} produktów', other: '{count} produktu' }`.
|
|
26
|
+
*/
|
|
27
|
+
export interface Dictionary {
|
|
28
|
+
[key: string]: string | Dictionary;
|
|
29
|
+
}
|
|
30
|
+
/** Keyed by locale: `{ de: { search: { sort: 'Sortieren' } } }`. */
|
|
31
|
+
export type Translations = Record<string, Dictionary>;
|
|
32
|
+
|
|
33
|
+
export interface GlobalConfigType {
|
|
34
|
+
/** Luigi's Box AI publishable key (pub_<env>_<region>_…), bound to the channel and the site's origins. */
|
|
35
|
+
publishableKey: string;
|
|
36
|
+
/** Channel (serving destination), lbn_… */
|
|
37
|
+
channelId: string;
|
|
38
|
+
/** API region, from the key: 'eu1'. */
|
|
39
|
+
region?: string;
|
|
40
|
+
/** Dictionary + number formatting locale ('de', 'cs', 'en'). Default 'en'. */
|
|
41
|
+
locale?: string;
|
|
42
|
+
/** ISO 4217, for price(). */
|
|
43
|
+
currency?: string;
|
|
44
|
+
/** Intl.NumberFormat options for price(), e.g. { trailingZeroDisplay: 'stripIfInteger' }. */
|
|
45
|
+
priceFormat?: Intl.NumberFormatOptions & { trailingZeroDisplay?: 'auto' | 'stripIfInteger' };
|
|
46
|
+
/** Catalog type of products (default 'product'). */
|
|
47
|
+
itemType?: string;
|
|
48
|
+
/** The site's search input(s); autocomplete binds to whatever matches. */
|
|
49
|
+
inputSelector?: string;
|
|
50
|
+
disabled?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Speculation Rules (Chrome, Edge): our product links (listings,
|
|
53
|
+
* recommenders, the autocomplete's links) are prerendered, or prefetched,
|
|
54
|
+
* when the shopper shows intent (`moderate`: ~200 ms of hover, pointerdown on
|
|
55
|
+
* touch; the browser keeps two at a time). The product page is then shown at
|
|
56
|
+
* once on the click. It costs the site's server a page render per intent
|
|
57
|
+
* that may not be followed, and the product page's scripts run in it before
|
|
58
|
+
* it is shown. Default 'prerender'; `false` turns it off (e.g. a site whose
|
|
59
|
+
* servers or product-page scripts can't take speculative views), 'prefetch'
|
|
60
|
+
* fetches the HTML only.
|
|
61
|
+
*/
|
|
62
|
+
speculation?: 'prerender' | 'prefetch' | false;
|
|
63
|
+
/** Links speculation applies to (CSS). Default: links in our result lists (`[data-lbx-list]`), recommenders and the autocomplete dropdown. */
|
|
64
|
+
speculationSelector?: string;
|
|
65
|
+
/** `moderate` (default: hover, pointerdown) or `conservative` (pointerdown / touchstart only). */
|
|
66
|
+
speculationEagerness?: 'moderate' | 'conservative';
|
|
67
|
+
/** Preconnect to the product image host (learnt from the first results) on later page loads. Default true. */
|
|
68
|
+
preconnectImages?: boolean;
|
|
69
|
+
/** Product images: srcset, the eager first row, the box's size, placeholders, preloads (`image()` / `img()` in templates). */
|
|
70
|
+
images?: ImagesConfig;
|
|
71
|
+
/**
|
|
72
|
+
* Filter clicks that render at once (search and category listings): when
|
|
73
|
+
* the shopper shows intent on one of our controls leading to another state
|
|
74
|
+
* of the listing (a facet value, a flag, a sort, a chip, "clear all", the
|
|
75
|
+
* next page: the pointer resting on it ~100 ms, pointerdown on touch,
|
|
76
|
+
* keyboard focus), page 1 of that state's products is fetched. The click
|
|
77
|
+
* renders it in the next frame (the facets of the new result follow). The
|
|
78
|
+
* control's state is its URL: a link's `href`, or `data-lbx-href` (the
|
|
79
|
+
* scope's `link.href`) on any other element, e.g. a checkbox's `<label>`.
|
|
80
|
+
* Two in flight, the latest intent wins; 20 per category or query; none
|
|
81
|
+
* offline, with Save-Data or on 2G; range sliders never. A hover that is
|
|
82
|
+
* not followed costs one search request (no render, no hook, no analytics
|
|
83
|
+
* event). Default true.
|
|
84
|
+
*/
|
|
85
|
+
prefetchFilters?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* The high-contrast mode's switch (the previous-generation Luma's "High-contrast mode"
|
|
88
|
+
* button) at the start of every listing: visually hidden until it has focus,
|
|
89
|
+
* unless the visitor's OS asks for a contrast preference or the mode is on.
|
|
90
|
+
* The mode itself follows `prefers-contrast: more` without it (pure CSS,
|
|
91
|
+
* docs/ACCESSIBILITY.md). Default false.
|
|
92
|
+
*/
|
|
93
|
+
contrastToggle?: boolean;
|
|
94
|
+
/** Set in _init.ts: templates read them as r.c.<name>. Defaults: title, url, image, price. */
|
|
95
|
+
computedAttributes?: Record<string, (r: Result) => unknown>;
|
|
96
|
+
translations?: Translations;
|
|
97
|
+
/**
|
|
98
|
+
* `false`: no analytics at all, whatever `_analytics.ts` says (README
|
|
99
|
+
* "Analytics": what is sent is that file's).
|
|
100
|
+
*/
|
|
101
|
+
analytics?: false;
|
|
102
|
+
/**
|
|
103
|
+
* The site's dataLayer, for analytics' `dl()` (default `dataLayer`): here,
|
|
104
|
+
* not in `_analytics.ts`, because the entry keeps its pushes for the next
|
|
105
|
+
* page when the visitor leaves before analytics has run.
|
|
106
|
+
*/
|
|
107
|
+
dataLayer?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Puts a product in the site's cart (`_init.ts`, or `_global/_widget.ts`:
|
|
110
|
+
* only widgets call it): what a click on `[data-lbx-cart]` in one of our
|
|
111
|
+
* tiles runs (announced to analytics first: `lumaCarts()`). `count`: the
|
|
112
|
+
* attribute's value (default 1).
|
|
113
|
+
*/
|
|
114
|
+
addToCart?: (r: Result, o: { count: number; el: HTMLElement; event: MouseEvent }) => unknown;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Product images, JSON. The template
|
|
119
|
+
* helpers `img(r, i)` / `image(r, i)` build every attribute from it.
|
|
120
|
+
*/
|
|
121
|
+
export interface ImagesConfig {
|
|
122
|
+
/**
|
|
123
|
+
* srcset candidates built from the image URL (`r.c.image`): `match` is a
|
|
124
|
+
* regex on it (default: the whole URL); each candidate is the URL with
|
|
125
|
+
* `match` replaced (`$1`… are its groups). Either named sizes, descriptor ->
|
|
126
|
+
* replacement (`{ "240w": "/thumb_base_$1", "480w": "/thumb_$1" }`, or `1x` /
|
|
127
|
+
* `2x`), or a resizer: `pattern` with `{w}` for each of `widths`
|
|
128
|
+
* (`"$1?width={w}"`, `[200, 400, 800]`). A URL it does not match gets no srcset.
|
|
129
|
+
*/
|
|
130
|
+
srcset?: { match?: string; candidates?: Record<string, string>; pattern?: string; widths?: number[] };
|
|
131
|
+
/** The `sizes` attribute for w descriptors: the tile image's rendered width per viewport (`(min-width: 992px) 232px, 50vw`). */
|
|
132
|
+
sizes?: string;
|
|
133
|
+
/** The image box's intrinsic size, as width/height attributes (its aspect ratio before the image arrives: no layout shift). Default 400 x 400. */
|
|
134
|
+
width?: number;
|
|
135
|
+
height?: number;
|
|
136
|
+
/**
|
|
137
|
+
* The first row of a listing: eager, fetchpriority=high, preloaded. A
|
|
138
|
+
* number (the first N), or columns per viewport min-width
|
|
139
|
+
* (`{ "0": 2, "576": 3, "1400": 4 }`, read with matchMedia). Default
|
|
140
|
+
* `{ "0": 2, "768": 3, "1200": 4 }`. The rest is lazy.
|
|
141
|
+
*/
|
|
142
|
+
firstRow?: number | Record<string, number>;
|
|
143
|
+
/** Catalog fields (or computed attributes) holding a placeholder: a dominant colour (CSS hex / rgb / hsl), a tiny image URL. Default: the neutral skeleton. */
|
|
144
|
+
placeholder?: { color?: string; image?: string } | false;
|
|
145
|
+
/** Preload the first row (`<link rel=preload as=image>`): from the server's fragment, the bundle when it has the result, a Back restore. Default true. */
|
|
146
|
+
preload?: boolean;
|
|
147
|
+
/** Lazy images fade in on load (not under reduced motion; never the first row). Default true. */
|
|
148
|
+
fade?: boolean;
|
|
149
|
+
/** Results fetched ahead (typing, a hovered filter): warm the first N of their images at low priority. Default 0 (off). */
|
|
150
|
+
warm?: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface AppConfig<Self> {
|
|
154
|
+
disabled?: boolean;
|
|
155
|
+
translations?: Translations;
|
|
156
|
+
/** Set in the app's _init.ts: overrides of _global.computedAttributes for this app only. */
|
|
157
|
+
computedAttributes?: Record<string, (r: Result) => unknown>;
|
|
158
|
+
/** The app's _init.ts. Return false to not start this app. */
|
|
159
|
+
initialize?: (config: Self, Luma: LumaApi) => void | boolean | Promise<void | boolean>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface AutocompleteConfigType extends AppConfig<AutocompleteConfigType> {
|
|
163
|
+
/** Search surface (lbs_search_…); autocomplete is the same surface with a partial query. */
|
|
164
|
+
surface: string;
|
|
165
|
+
/** One request per type, in parallel; groups render in this order. */
|
|
166
|
+
types: { type: string; size: number; fields?: string[]; filters?: FilterNode }[];
|
|
167
|
+
/**
|
|
168
|
+
* The shortest phrase that asks anything (default 0: the first letter asks,
|
|
169
|
+
* on the keystroke; one-letter phrases are cached). An empty phrase never asks.
|
|
170
|
+
*/
|
|
171
|
+
minChars?: number;
|
|
172
|
+
/**
|
|
173
|
+
* Request pacing. A phrase of up to `instantChars`
|
|
174
|
+
* characters (default 3) asks on the keystroke. A longer one asks once
|
|
175
|
+
* typing pauses for `debounceMs` (default 300: longer than the 150-250 ms
|
|
176
|
+
* between a steady typist's keys, so a word typed in one go is one request,
|
|
177
|
+
* not one per key), but at least every `maxWaitMs` (default 800) while the
|
|
178
|
+
* keys keep coming, so the dropdown follows a fast typist at a calm cadence.
|
|
179
|
+
* A phrase already answered (backspacing, retyping) shows at once.
|
|
180
|
+
*/
|
|
181
|
+
instantChars?: number;
|
|
182
|
+
debounceMs?: number;
|
|
183
|
+
maxWaitMs?: number;
|
|
184
|
+
/** A host element whose colour the dropdown uses as accent (its background, else its text colour). */
|
|
185
|
+
accentFrom?: string;
|
|
186
|
+
/**
|
|
187
|
+
* The house design's colours, font and corners (`dom: 'shadow'`). Five are
|
|
188
|
+
* sampled from the
|
|
189
|
+
* page (font, fg, bg, accent, radius); the rest are derived from them. A
|
|
190
|
+
* value here replaces the sampled or derived one (`--lbx-<name>` in the
|
|
191
|
+
* dropdown's shadow root: an integration's `style-autocomplete.css` can set
|
|
192
|
+
* the same properties on `:host`). `onAccent` follows `accent` (white, or
|
|
193
|
+
* near-black on a light accent) unless set.
|
|
194
|
+
* `{ "accent": "#1a73e8", "font": "Inter, sans-serif", "line": "#d0d0d0" }`
|
|
195
|
+
*/
|
|
196
|
+
tokens?: Partial<Record<'font' | 'fg' | 'bg' | 'accent' | 'radius' | 'muted' | 'line' | 'soft' | 'tint' | 'tintStrong' | 'onAccent' | 'ok' | 'radiusRow' | 'radiusTile', string>>;
|
|
197
|
+
/**
|
|
198
|
+
* The house design's top product (the first product result, its own
|
|
199
|
+
* column). `variant`: 'image' (default, a large picture) or 'spec'
|
|
200
|
+
* (technical catalogues: a small picture, then up to 4 of `specFields` as
|
|
201
|
+
* label / value rows; a label is the translation `autocomplete.specs.<field>`,
|
|
202
|
+
* else the field's name). `badgeField`: a result field shown as a pill on
|
|
203
|
+
* the picture (e.g. a catalog label: "New", "Sale"); none by default.
|
|
204
|
+
*/
|
|
205
|
+
hero?: { variant?: 'image' | 'spec'; specFields?: string[]; badgeField?: string };
|
|
206
|
+
/** The products beside the top product: 'grid' (default, 2 columns of cards) or 'list' (rows: picture, title over meta, stock, price). */
|
|
207
|
+
productLayout?: 'grid' | 'list';
|
|
208
|
+
/**
|
|
209
|
+
* Result fields (or computed attributes, `r.c`) the house design's product
|
|
210
|
+
* cards show; each optional, a missing value is not drawn. `availability`:
|
|
211
|
+
* a text, or true / a positive number for "In stock" (`autocomplete.inStock`);
|
|
212
|
+
* `category`: the text after the stock; `oldPrice`: struck through beside
|
|
213
|
+
* the price; `meta`: the line under the title in the list layout (a part
|
|
214
|
+
* number); `price`: the price instead of `r.c.price`. A price that is a
|
|
215
|
+
* number is formatted (`price()`); a text (a computed attribute in the
|
|
216
|
+
* shop's own format, "1.099,-") is shown as it is. None by default.
|
|
217
|
+
*/
|
|
218
|
+
productFields?: { availability?: string; category?: string; oldPrice?: string; meta?: string; price?: string };
|
|
219
|
+
/**
|
|
220
|
+
* "No results" in the house design (its own layout): `tryQueries`, phrases offered as chips (handed to the site's
|
|
221
|
+
* search); `browse`, links (`{ title, url }`); `fallback`, products from
|
|
222
|
+
* the whole shop in a strip apart, labelled as not results: `null`
|
|
223
|
+
* (default: no strip) or `{ query, size }` (a fixed product search, asked
|
|
224
|
+
* once per page). The help box shows when the translation
|
|
225
|
+
* `autocomplete.help.phone` is set.
|
|
226
|
+
*/
|
|
227
|
+
emptyState?: { tryQueries?: string[]; browse?: { title: string; url: string }[]; fallback?: { query: string; size?: number } | null };
|
|
228
|
+
/**
|
|
229
|
+
* 'shadow' (default): our own markup and CSS in a shadow root.
|
|
230
|
+
* 'light': the dropdown is a light-DOM element, so the site's own CSS styles
|
|
231
|
+
* it (a 1:1 port of an existing design); `hostClass` / `hostId` go on it and
|
|
232
|
+
* the integration's autocomplete styles go into the document.
|
|
233
|
+
*/
|
|
234
|
+
dom?: 'shadow' | 'light';
|
|
235
|
+
hostClass?: string;
|
|
236
|
+
hostId?: string;
|
|
237
|
+
/**
|
|
238
|
+
* What the dropdown lines up with (its left edge and width, just below it).
|
|
239
|
+
* By default the visible search field: the input when it draws its own
|
|
240
|
+
* border or shadow, else its nearest ancestor up to its form (at most 4
|
|
241
|
+
* levels) that draws one (a border, a shadow or a background) and is the
|
|
242
|
+
* field rather than the bar around it (at least the input's width, not much
|
|
243
|
+
* taller, not as wide as the page); else the input. A selector: the input's
|
|
244
|
+
* nearest ancestor matching it (across shadow roots), else the input.
|
|
245
|
+
* `'input'`: always the input. At least 360 px wide; on a phone (below
|
|
246
|
+
* 480 px) a field spanning most of the screen exactly, else the screen's
|
|
247
|
+
* width.
|
|
248
|
+
*/
|
|
249
|
+
alignTo?: 'input' | string;
|
|
250
|
+
/** _init.ts hooks, for sites whose own code reacts to their dropdown opening, closing or a pick. */
|
|
251
|
+
onOpen?: () => void;
|
|
252
|
+
/** Why it closed: a pick, Enter / Escape in the input, focus leaving, a press outside, a route change, … */
|
|
253
|
+
onClose?: (reason: 'select' | 'enter' | 'escape' | 'back' | 'blur' | 'outside' | 'navigate' | 'empty' | 'hidden' | 'api') => void;
|
|
254
|
+
onSelect?: (r: Result | null) => void;
|
|
255
|
+
/** _init.ts: receives the dropdown's controls once it has started (e.g. for the site's own code that opens or closes it). */
|
|
256
|
+
onReady?: (controls: { close: () => void; reposition: () => void }) => void;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Where a listing lives, and what it shows. */
|
|
260
|
+
export interface ListingConfigType {
|
|
261
|
+
/** Anchor candidates, most specific first. */
|
|
262
|
+
anchor: string | string[];
|
|
263
|
+
position: Position;
|
|
264
|
+
/** Wait until the anchor is stable this long (frameworks that re-render after load). */
|
|
265
|
+
settleMs?: number;
|
|
266
|
+
/** Element that reserves our remembered height (default: the anchor). */
|
|
267
|
+
reserveSelector?: string;
|
|
268
|
+
/** Fail-safe: show the host's page if ours has not rendered after this long (default 4000). */
|
|
269
|
+
holdTimeoutMs?: number;
|
|
270
|
+
/** Discovery surface: lbs_search_… for search, lbs_collection_… for category pages. */
|
|
271
|
+
surface: string;
|
|
272
|
+
/** Hits per page: the first page, and each "show more". */
|
|
273
|
+
size: number;
|
|
274
|
+
/** Faceted fields, in sidebar order. Range facets (numbers) are detected from the facets response. */
|
|
275
|
+
facets: string[];
|
|
276
|
+
/** Values per terms facet (default 50). */
|
|
277
|
+
facetSize?: number;
|
|
278
|
+
/**
|
|
279
|
+
* With a filter on, also list each facet's values that the page has without
|
|
280
|
+
* the shopper's filters, marked `empty` (sites that grey them out rather than
|
|
281
|
+
* hide them). One more facets request per scope or query (and its fields).
|
|
282
|
+
*/
|
|
283
|
+
emptyValues?: boolean;
|
|
284
|
+
/** Sorts as field:direction ('lbx:price:asc'); '' = relevance. Labels: translations <app>.sorts. */
|
|
285
|
+
sorts: string[];
|
|
286
|
+
/** return_fields for the tiles (default: the surface's). */
|
|
287
|
+
fields?: string[];
|
|
288
|
+
/** Always-on filters (e.g. what this storefront may show), AND-ed with the shopper's. */
|
|
289
|
+
baseFilters?: FilterNode;
|
|
290
|
+
/**
|
|
291
|
+
* _init.ts: after each render (e.g. to keep the site's document title in step).
|
|
292
|
+
* `shown`: the results loaded; `page`: the pages loaded (1, plus one per "show more"). */
|
|
293
|
+
onRender?: (info: { route: string; query: string; total: number; shown: number; page: number; url: URL }) => void;
|
|
294
|
+
/** Host classes for our element, so it sits in the site's own container/grid. */
|
|
295
|
+
rootClass?: string;
|
|
296
|
+
/** Show the query / category name as a heading. */
|
|
297
|
+
showTitle?: boolean;
|
|
298
|
+
/** On/off filters of our own (in the URL as lbx.flag=<id>); labels: translations <app>.flags.<id>. */
|
|
299
|
+
flags?: { id: string; filter: FilterNode }[];
|
|
300
|
+
/**
|
|
301
|
+
* A click on a link of ours to another page (a product) remembers the
|
|
302
|
+
* listing (every page loaded, trimmed to `fields`) and where the shopper was, in
|
|
303
|
+
* sessionStorage; Back to that URL then renders it at once, at the same
|
|
304
|
+
* tile, and refreshes it in the background. Default true.
|
|
305
|
+
*/
|
|
306
|
+
restoreOnBack?: boolean;
|
|
307
|
+
/** Near the end of the loaded tiles (~70%), the next page is fetched from the cursor, so "show more" appends at once. Default true. */
|
|
308
|
+
prefetchNextPage?: boolean;
|
|
309
|
+
/**
|
|
310
|
+
* Tiles out of view skip their style, layout and paint (`content-visibility:
|
|
311
|
+
* auto`), each at its own measured size: a long list's re-render about
|
|
312
|
+
* halves (a production integration's unstyled stub page, a sort after 136 tiles: style,
|
|
313
|
+
* layout and paint 243 → 144 ms, longest task 124 -> 85 ms at 4x CPU).
|
|
314
|
+
* Opt-in: every tile is then contained, always (paint: nothing of it shows
|
|
315
|
+
* outside its box, a hover panel or a badge hanging out is cut; its own
|
|
316
|
+
* stacking context; a `position: fixed` inside it is fixed to the tile;
|
|
317
|
+
* margins no longer collapse through it), and tiles a "show more" added
|
|
318
|
+
* out of view stand in at the first tile's size until they come near.
|
|
319
|
+
* Default false.
|
|
320
|
+
*/
|
|
321
|
+
contentVisibility?: boolean;
|
|
322
|
+
/**
|
|
323
|
+
* How a change inside the open filter drawer (`[data-lbx-drawer-panel]`,
|
|
324
|
+
* opened by `[data-lbx-drawer="open"]`) applies. 'instant' (default): at
|
|
325
|
+
* once, as in a sidebar. 'staged': held in the drawer (it shows the pending
|
|
326
|
+
* selection, the scope's `pending` is true, nothing is fetched) until its
|
|
327
|
+
* apply button (`[data-lbx-drawer="apply"]`) commits them in one request
|
|
328
|
+
* and one history entry; closing it otherwise (`[data-lbx-drawer="close"]`,
|
|
329
|
+
* Escape) discards them. Per viewport min-width, e.g. `{ "0": "staged",
|
|
330
|
+
* "768": "instant" }` (staged on phones). Outside an open drawer every
|
|
331
|
+
* change is instant.
|
|
332
|
+
*/
|
|
333
|
+
facetsApply?: 'instant' | 'staged' | Record<string, 'instant' | 'staged'>;
|
|
334
|
+
/** Facet groups: collapsible (accordion) and searchable. */
|
|
335
|
+
facetGroups?: FacetGroupsConfig;
|
|
336
|
+
/**
|
|
337
|
+
* The step of a range facet's slider and inputs, by facet name (default 1):
|
|
338
|
+
* `{ "lbx:price": 10 }`. An arrow key moves a thumb by one step, Page Up /
|
|
339
|
+
* Page Down by a tenth of the range (whole steps).
|
|
340
|
+
*/
|
|
341
|
+
rangeSteps?: Record<string, number>;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** `facetGroups` of a listing (JSON). */
|
|
345
|
+
export interface FacetGroupsConfig {
|
|
346
|
+
/**
|
|
347
|
+
* Which groups start open: `true` (all, the default), `false` (none), or
|
|
348
|
+
* the first N of the sidebar; per viewport min-width (`{ "0": false,
|
|
349
|
+
* "1024": 3 }`: closed on phones, the first three open on desktops). A
|
|
350
|
+
* group with a selection starts open; the shopper's toggles are kept for
|
|
351
|
+
* the visit (another category or query starts anew). Templates get each
|
|
352
|
+
* group's `open`, `toggle` and `id`.
|
|
353
|
+
*/
|
|
354
|
+
open?: boolean | number | Record<string, boolean | number>;
|
|
355
|
+
/** Facet fields whose group gets a search box over its values (client-side, as typed; case and diacritics ignored). */
|
|
356
|
+
search?: string[];
|
|
357
|
+
/** A group's search box only from this many values (default 10). */
|
|
358
|
+
searchMin?: number;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface SearchConfigType extends AppConfig<SearchConfigType>, ListingConfigType {
|
|
362
|
+
/** Pathname regex of search pages; omitted = any page with the query param. */
|
|
363
|
+
urlMatch?: string;
|
|
364
|
+
/** Query param holding the query ('q'). Without it, capture group 1 of urlMatch is the query. */
|
|
365
|
+
queryParam?: string;
|
|
366
|
+
/** _init.ts overrides for URLs a regex cannot describe. */
|
|
367
|
+
active?: (url: URL) => boolean;
|
|
368
|
+
readQuery?: (url: URL) => string;
|
|
369
|
+
/** Extra result types shown above the results (category and brand tiles), one request each. */
|
|
370
|
+
quick?: { type: string; size: number; fields?: string[] }[];
|
|
371
|
+
/**
|
|
372
|
+
* On the search page, a new query from the site's search form (the form
|
|
373
|
+
* holding `_global.inputSelector`: Enter, its button, and the autocomplete's
|
|
374
|
+
* Enter / "show all" / query suggestions, which submit it) is shown in
|
|
375
|
+
* place: a new history entry and our listing re-rendered, no page load.
|
|
376
|
+
* Default true. false: every search is a page load, e.g. where the site's
|
|
377
|
+
* search page has server-rendered parts outside our listing that must follow
|
|
378
|
+
* the query, or page-view analytics that must fire per search.
|
|
379
|
+
*/
|
|
380
|
+
inPlaceQuery?: boolean;
|
|
381
|
+
/**
|
|
382
|
+
* While the shopper types in `_global.inputSelector` (on any page), once the
|
|
383
|
+
* phrase settles (`prefetchDelayMs`, default 300) the search page's result
|
|
384
|
+
* for the URL their form would submit to is fetched: Enter on the search page
|
|
385
|
+
* renders it without a request, and a page load of it (from another page)
|
|
386
|
+
* within `prefetchMaxAgeMs` renders it from sessionStorage. One in flight,
|
|
387
|
+
* the latest two kept; not offline, with Save-Data or on 2G. Default true.
|
|
388
|
+
*/
|
|
389
|
+
prefetchWhileTyping?: boolean;
|
|
390
|
+
prefetchDelayMs?: number;
|
|
391
|
+
/** How long a result fetched while typing is used (ms; older: asked anew). Default 120000. */
|
|
392
|
+
prefetchMaxAgeMs?: number;
|
|
393
|
+
/**
|
|
394
|
+
* Also prerender the search page (Speculation Rules: Chrome, Edge) of the
|
|
395
|
+
* phrase whose result was just fetched while typing, if it is still the one
|
|
396
|
+
* typed, so Enter from another page activates a page already rendered. One
|
|
397
|
+
* at a time (a newer phrase replaces it). It costs a host page render on
|
|
398
|
+
* the site's server per settled phrase, and runs the site's scripts in it:
|
|
399
|
+
* analytics that do not wait for `prerenderingchange` count a page view the
|
|
400
|
+
* shopper may never see. Not on the search page when `inPlaceQuery` is on.
|
|
401
|
+
* Default false.
|
|
402
|
+
*/
|
|
403
|
+
prerenderWhileTyping?: boolean;
|
|
404
|
+
/** Speculation Rules eagerness of that list rule: 'immediate' (default) or 'eager'. */
|
|
405
|
+
prerenderEagerness?: 'immediate' | 'eager';
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* A page's own facets (plp `facetsFor`): the fields to facet, in order, and
|
|
410
|
+
* optionally the sidebar's group order for templates (`layout`: fields plus
|
|
411
|
+
* the integration's own group keys, e.g. a group of flags).
|
|
412
|
+
*/
|
|
413
|
+
export interface FacetLayout {
|
|
414
|
+
facets: string[];
|
|
415
|
+
layout?: string[];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** A category page's scope (collection_filters) and title; null = not ours. */
|
|
419
|
+
export type CategoryFilter = { scope: FilterNode; title: string } | null;
|
|
420
|
+
|
|
421
|
+
/** How the host's own URL parameters map onto listing state (JSON-safe). */
|
|
422
|
+
export interface ParamMap {
|
|
423
|
+
/**
|
|
424
|
+
* The host's page param. Luma opens every listing on its first page and
|
|
425
|
+
* appends the next ones ("show more"; cursors cannot jump to page N): a URL
|
|
426
|
+
* with their page 1 is ours, a later page of theirs is left to their own
|
|
427
|
+
* listing. Our UI never writes it (it drops it with any change).
|
|
428
|
+
*/
|
|
429
|
+
page?: string;
|
|
430
|
+
sort?: { param: string; values: Record<string, string> };
|
|
431
|
+
/** On/off host params with a fixed value; labels: translations plp.flags["param=value"]. */
|
|
432
|
+
flags?: { param: string; value: string; filter: FilterNode }[];
|
|
433
|
+
/**
|
|
434
|
+
* Facet field -> the host param carrying it. `values`: their value -> ours
|
|
435
|
+
* (a value mapped to '' is one we cannot express: such a URL stays theirs);
|
|
436
|
+
* `slug: false`: values as they are (with `values`: the unmapped ones);
|
|
437
|
+
* neither: slugs of our values (looked up in the category's facet values).
|
|
438
|
+
*/
|
|
439
|
+
facetParams?: Record<string, { param: string; slug?: boolean; values?: Record<string, string> }>;
|
|
440
|
+
separator?: string;
|
|
441
|
+
/** Read-only host params turned into a range filter, passed through unchanged. */
|
|
442
|
+
ranges?: { min: string; max: string; field: string }[];
|
|
443
|
+
/** Regex of params that never block a takeover (tracking). */
|
|
444
|
+
ignore?: string;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export interface PlpConfigType extends AppConfig<PlpConfigType>, Partial<ListingConfigType> {
|
|
448
|
+
/** Pathname regex of category pages. */
|
|
449
|
+
urlMatch: string;
|
|
450
|
+
/**
|
|
451
|
+
* _init.ts: narrows urlMatch for URLs a regex cannot describe (the bundle
|
|
452
|
+
* only: the snippet's rules are JSON, so they match on urlMatch alone, and
|
|
453
|
+
* the bundle drops a hold on a page `active` rejects at boot).
|
|
454
|
+
*/
|
|
455
|
+
active?: (url: URL) => boolean;
|
|
456
|
+
/**
|
|
457
|
+
* false: no early-hold rule for category pages. Needed only when urlMatch
|
|
458
|
+
* over-matches (`active` narrows it) and style-host.css would hide something
|
|
459
|
+
* on the pages it over-matches (it must hit only elements of the category
|
|
460
|
+
* page). Default: a rule.
|
|
461
|
+
*/
|
|
462
|
+
earlyHold?: boolean;
|
|
463
|
+
params?: ParamMap;
|
|
464
|
+
/**
|
|
465
|
+
* Category record lookup: the record of `type` (default 'category') whose
|
|
466
|
+
* `field` equals the page URL's path (no trailing slash; `absolute`: origin + path),
|
|
467
|
+
* through the listing's own collection surface.
|
|
468
|
+
*/
|
|
469
|
+
lookup?: { type?: string; field: string; absolute?: boolean };
|
|
470
|
+
/** _init.ts: category record -> filter. */
|
|
471
|
+
toFilter?: (category: Hit) => CategoryFilter;
|
|
472
|
+
/** _init.ts: filter straight from the URL (no lookup). */
|
|
473
|
+
fromUrl?: (url: URL) => CategoryFilter;
|
|
474
|
+
/**
|
|
475
|
+
* _init.ts: this page's facets, when they depend on the category (its own
|
|
476
|
+
* technical parameters), instead of `facets`. It runs in the server
|
|
477
|
+
* renderer too (no DOM there): what it returns must not depend on the
|
|
478
|
+
* browser, or a server-rendered page's markup changes on its first client
|
|
479
|
+
* render. May return a promise (e.g. waiting for the host's page); null or
|
|
480
|
+
* undefined = `facets`. Selected fields not in the list still get their group.
|
|
481
|
+
*/
|
|
482
|
+
facetsFor?: (url: URL, category: { scope?: FilterNode; title: string; filters: Record<string, string[]> }) => FacetLayout | null | undefined | Promise<FacetLayout | null | undefined>;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export interface RecommenderConfigType extends AppConfig<RecommenderConfigType> {
|
|
486
|
+
/** Recommender surface (lbs_recommender_…): it decides the model. */
|
|
487
|
+
surface: string;
|
|
488
|
+
/** Object type recommended (default: _global.itemType). */
|
|
489
|
+
type?: string;
|
|
490
|
+
size: number;
|
|
491
|
+
/** return_fields the tiles need (keeps the response small). */
|
|
492
|
+
fields?: string[];
|
|
493
|
+
/** Inline widget: pathname regex, anchor, placement. */
|
|
494
|
+
urlMatch?: string;
|
|
495
|
+
anchor?: string | string[];
|
|
496
|
+
position?: Position;
|
|
497
|
+
settleMs?: number;
|
|
498
|
+
/** Height reserved while loading. */
|
|
499
|
+
reserve?: number;
|
|
500
|
+
/** Host classes for the widget's element, so it sits in the site's grid (e.g. 'col-12'). */
|
|
501
|
+
rootClass?: string;
|
|
502
|
+
/** Popup widget, opened on "added to cart": the host's success notice selector. */
|
|
503
|
+
confirm?: string;
|
|
504
|
+
/** Max time from the add-to-cart click to the notice (default 10000). */
|
|
505
|
+
windowMs?: number;
|
|
506
|
+
/** _init.ts overrides. */
|
|
507
|
+
active?: (url: URL) => boolean;
|
|
508
|
+
/** _init.ts: anchors read from the page, as typed identities ("product/<id>"); [] = no widget. */
|
|
509
|
+
items?: () => string[];
|
|
510
|
+
/** Anchors are also excluded (on top of the API's own exclusion); default false. */
|
|
511
|
+
excludeItems?: boolean;
|
|
512
|
+
/** _init.ts, popup: the anchor (typed identity) being added when this click target is an add-to-cart control. */
|
|
513
|
+
intent?: (target: Element) => string | null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export interface LumaConfigType {
|
|
517
|
+
_global: GlobalConfigType;
|
|
518
|
+
autocomplete?: AutocompleteConfigType;
|
|
519
|
+
search?: SearchConfigType;
|
|
520
|
+
plp?: PlpConfigType;
|
|
521
|
+
recommenders?: Record<string, RecommenderConfigType>;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** What inits receive as their second argument. */
|
|
525
|
+
export interface LumaApi {
|
|
526
|
+
version: string;
|
|
527
|
+
config: LumaConfigType;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export type LumaInitType = (config: LumaConfigType, Luma: LumaApi) => void | boolean | Promise<void | boolean>;
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* `_widget.ts` of a folder: loaded with the widget that uses the folder (its
|
|
534
|
+
* chunk), and run once, before that widget's context is made: the place for
|
|
535
|
+
* hooks only a widget calls (computedAttributes, toFilter, onRender, onOpen…),
|
|
536
|
+
* which in `_init.ts` ship and run on every page. `_global/_widget.ts` gets
|
|
537
|
+
* the whole config, a widget folder's its own. Its return value is ignored
|
|
538
|
+
* (a widget that loads runs; disable one in `_init.ts`).
|
|
539
|
+
*/
|
|
540
|
+
export type LumaWidgetInitType<C = LumaConfigType> = (config: C, Luma: LumaApi) => void | Promise<void>;
|
|
541
|
+
|
|
542
|
+
// --- Template scopes: what a template-*.ts function receives. -------------
|
|
543
|
+
|
|
544
|
+
/** Available in every template. */
|
|
545
|
+
export interface Helpers {
|
|
546
|
+
/** Translate: the widget's keys first (search.sort, …), then the bare key. */
|
|
547
|
+
t: (key: string, params?: Record<string, unknown>) => string;
|
|
548
|
+
/** A number as a price in the site's locale and currency. */
|
|
549
|
+
price: (n: unknown) => string;
|
|
550
|
+
/** A field's text (its first value when it is a list), for attributes and alt text. */
|
|
551
|
+
plain: (s: unknown) => string;
|
|
552
|
+
first: (v: unknown) => any;
|
|
553
|
+
/** Another template of the widget, with this scope plus props. */
|
|
554
|
+
include: (name: string, props?: Record<string, unknown>) => unknown;
|
|
555
|
+
/** A result's image attributes (`_global.images`): src, srcset, sizes, width/height, loading, fetchpriority, `ph` (its box's placeholder style). */
|
|
556
|
+
image: (r: Result, i: number, opts?: ImageOptions) => ImageInfo;
|
|
557
|
+
/** A complete `<img>` in its placeholder box (`<span class="lbx-img" data-lbx-ph>`); nothing without an image. */
|
|
558
|
+
img: (r: Result, i: number, opts?: ImageOptions) => unknown;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* A UI action: `go(link)` applies it; `href` is the URL it leads to. Put it on
|
|
563
|
+
* the control as a link's `href`, or as `data-lbx-href` on any other element
|
|
564
|
+
* (a checkbox's `<label>`, a button): intent on it then prefetches that state
|
|
565
|
+
* (`_global.prefetchFilters`).
|
|
566
|
+
*/
|
|
567
|
+
export interface Link {
|
|
568
|
+
patch: Record<string, unknown>;
|
|
569
|
+
href: string;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export interface SearchScope extends Helpers {
|
|
573
|
+
message: string | null;
|
|
574
|
+
query: string;
|
|
575
|
+
title: string;
|
|
576
|
+
showTitle: boolean;
|
|
577
|
+
/** 'search' or 'plp'. */
|
|
578
|
+
route: string;
|
|
579
|
+
total: number;
|
|
580
|
+
/**
|
|
581
|
+
* What is loaded, in order: the first page, then every page "show more"
|
|
582
|
+
* appended (`showMore`). There is no numbered page: a listing opens on its
|
|
583
|
+
* first page and grows.
|
|
584
|
+
*/
|
|
585
|
+
results: Result[];
|
|
586
|
+
/** Search pages: results of the `quick` types (category, brand tiles), by type. */
|
|
587
|
+
quick: Record<string, Result[]>;
|
|
588
|
+
/**
|
|
589
|
+
* Text facets (range facets are in `ranges`). Values are links: href = the
|
|
590
|
+
* URL with the value toggled. No counts: the API returns none; `rank` is the
|
|
591
|
+
* value's place in the API's order (most frequent first); `empty`
|
|
592
|
+
* (`emptyValues`): none of the filtered results has it.
|
|
593
|
+
*/
|
|
594
|
+
facets: ({
|
|
595
|
+
name: string;
|
|
596
|
+
label: string;
|
|
597
|
+
selected: number;
|
|
598
|
+
/** `match`: the group's search text finds it (always true without text). */
|
|
599
|
+
values: ({ value: string; label: string; checked: boolean; rank: number; match: boolean; empty?: boolean } & Link)[];
|
|
600
|
+
/**
|
|
601
|
+
* The group's search box (`facetGroups.search`, from `searchMin` values),
|
|
602
|
+
* else null: its `text`, how many values it `matches`, and `set(text)`
|
|
603
|
+
* (re-renders, no request; the count is announced once typing pauses).
|
|
604
|
+
* Label the input (`t('facetSearch', { facet })`).
|
|
605
|
+
*/
|
|
606
|
+
search: { text: string; matches: number; set: (text: string) => void } | null;
|
|
607
|
+
} & FacetGroup)[];
|
|
608
|
+
/** The sidebar's groups in order: facet names (text and range) plus any group keys of the page's `layout`. */
|
|
609
|
+
layout: string[];
|
|
610
|
+
ranges: ({
|
|
611
|
+
name: string;
|
|
612
|
+
label: string;
|
|
613
|
+
min: number;
|
|
614
|
+
max: number;
|
|
615
|
+
from: number;
|
|
616
|
+
to: number;
|
|
617
|
+
active: boolean;
|
|
618
|
+
/** Unit shown next to the values (translations: units.<facet>). */
|
|
619
|
+
unit: string;
|
|
620
|
+
/** The slider's and the inputs' step (`rangeSteps`, default 1). */
|
|
621
|
+
step: number;
|
|
622
|
+
clear: Link;
|
|
623
|
+
/**
|
|
624
|
+
* Commit a range: one request (or, in a staged drawer, held until its
|
|
625
|
+
* Apply). An end at its bound is open (`null`); both at their bounds
|
|
626
|
+
* clears the filter. Call it when the shopper lets go (a slider's
|
|
627
|
+
* `change`, an input's `change` or Enter), never on every move.
|
|
628
|
+
*/
|
|
629
|
+
set: (from: number | null, to: number | null) => void;
|
|
630
|
+
setFrom: (from: number | null) => void;
|
|
631
|
+
setTo: (to: number | null) => void;
|
|
632
|
+
} & FacetGroup)[];
|
|
633
|
+
flags: ({ id: string; label: string; checked: boolean } & Link)[];
|
|
634
|
+
/**
|
|
635
|
+
* Active filters, each with the link that removes it: its facet's `title`
|
|
636
|
+
* and its `value` ("Colour" and "White": render "Title: value"; a flag is its
|
|
637
|
+
* own title, `value` ''). `label` is the value alone for a facet value, and
|
|
638
|
+
* "Title: from–to" for a range.
|
|
639
|
+
*/
|
|
640
|
+
chips: ({ kind: 'facet' | 'range' | 'flag'; name: string; title: string; value: string; label: string } & Link)[];
|
|
641
|
+
/** Removes every filter; null when none is active. */
|
|
642
|
+
clear: Link | null;
|
|
643
|
+
sorts: ({ value: string; label: string; selected: boolean } & Link)[];
|
|
644
|
+
sort: string;
|
|
645
|
+
/** Another page can be appended (`showMore`): the result set goes on. */
|
|
646
|
+
more: boolean;
|
|
647
|
+
/** Path of the page (for host links such as a login redirect). */
|
|
648
|
+
path: string;
|
|
649
|
+
facetsOpen: boolean;
|
|
650
|
+
/** The products are shown, their facets still on the way (a render follows): a template may hold their place. */
|
|
651
|
+
facetsPending: boolean;
|
|
652
|
+
/** The quick types are on their way, not waited for (they do not sit above the products here): a render follows with them. */
|
|
653
|
+
quickPending: boolean;
|
|
654
|
+
/** How the open filter drawer applies changes at this width (`facetsApply`). */
|
|
655
|
+
facetsApply: 'instant' | 'staged';
|
|
656
|
+
/** The open drawer holds changes not applied yet (staged): its apply button commits them. */
|
|
657
|
+
pending: boolean;
|
|
658
|
+
/**
|
|
659
|
+
* View state of this listing that is not in the URL (a group's "show more",
|
|
660
|
+
* a facet's search text), by the template's own keys. Empty on the server
|
|
661
|
+
* and on a new visit (another category or query); `setUi` re-renders.
|
|
662
|
+
*/
|
|
663
|
+
ui: Record<string, unknown>;
|
|
664
|
+
setUi: (key: string, value: unknown) => void;
|
|
665
|
+
/** Click handler for a link: applies its patch client-side (in the server render: a no-op). */
|
|
666
|
+
go: (link: Link | null) => (e: Event) => void;
|
|
667
|
+
toggleFacet: (name: string, value: string) => void;
|
|
668
|
+
toggleFlag: (id: string) => void;
|
|
669
|
+
setSort: (sort: string) => void;
|
|
670
|
+
/**
|
|
671
|
+
* "Show more": the next page appended to `results`, from the cursor (one
|
|
672
|
+
* request; prefetched as the shopper nears the end, `prefetchNextPage`).
|
|
673
|
+
* No URL change and no history entry. Bind it as `@click=${s.showMore}` on
|
|
674
|
+
* a `<button data-lbx-more>` (`data-lbx-more`: intent on it prefetches the
|
|
675
|
+
* page too): from the keyboard, focus moves on to the first new tile.
|
|
676
|
+
*/
|
|
677
|
+
showMore: (e?: Event) => void;
|
|
678
|
+
/** Range facet: set from–to (null = open end); both null clears it. */
|
|
679
|
+
setRange: (name: string, from: number | null, to: number | null) => void;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* A facet group's accordion state (`facetGroups.open`): open or not, the
|
|
683
|
+
* toggle, and an `id` for the region it shows (put `aria-controls=${id}` on
|
|
684
|
+
* a `<button aria-expanded=${open}>` and `id=${id}` on the region).
|
|
685
|
+
*/
|
|
686
|
+
export interface FacetGroup {
|
|
687
|
+
id: string;
|
|
688
|
+
open: boolean;
|
|
689
|
+
toggle: () => void;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* search-range-default: one range facet's dual-thumb slider and its two
|
|
694
|
+
* number inputs (`include('search-range-default', { range })`), kept in sync
|
|
695
|
+
* as the shopper moves them and committed on release (`range.set`).
|
|
696
|
+
*/
|
|
697
|
+
export interface SearchRangeScope extends SearchScope {
|
|
698
|
+
range: SearchScope['ranges'][number];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** search-result-default: one result, `i` its position (0 = first). */
|
|
702
|
+
export interface SearchResultScope extends SearchScope {
|
|
703
|
+
r: Result;
|
|
704
|
+
i: number;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export interface AutocompleteScope extends Helpers {
|
|
708
|
+
query: string;
|
|
709
|
+
/** One group per configured type that has results, in config order; `kind` is the type. */
|
|
710
|
+
groups: { kind: string; label: string; rows: { r: Result; i: number }[] }[];
|
|
711
|
+
/** Rows of one type ([] when none). */
|
|
712
|
+
byType: (type: string) => { r: Result; i: number }[];
|
|
713
|
+
count: number;
|
|
714
|
+
/** Index of the keyboard-selected row, -1 = none. */
|
|
715
|
+
active: number;
|
|
716
|
+
choose: (i: number) => void;
|
|
717
|
+
submitAll: () => void;
|
|
718
|
+
/**
|
|
719
|
+
* The search page of what is typed: the URL the site's search form would
|
|
720
|
+
* submit to ('' without a GET form). "Show all products" is a link to it
|
|
721
|
+
* (`href`), so it is a link to assistive technology, opens in a new tab, and
|
|
722
|
+
* is the dropdown's one tab stop: mark it `data-lbx-all` and Tab from the
|
|
723
|
+
* input reaches it (the options stay out of the Tab order).
|
|
724
|
+
*/
|
|
725
|
+
allHref: string;
|
|
726
|
+
// --- The house design (dom: 'shadow'). -------
|
|
727
|
+
/** 'idle' (the phone sheet before a phrase is long enough), 'results', 'empty' (every type answered nothing). */
|
|
728
|
+
state: 'idle' | 'results' | 'empty';
|
|
729
|
+
/** The phone sheet (full screen, viewport up to 600 px) rather than the panel under the input. */
|
|
730
|
+
sheet: boolean;
|
|
731
|
+
/** The panel is at least 1000 px wide: the top product has its own column. */
|
|
732
|
+
wide: boolean;
|
|
733
|
+
/** Query suggestions (pills). */
|
|
734
|
+
phrases: { r: Result; i: number }[];
|
|
735
|
+
/** One group per other non-product type with results, in config order (the list column; on a phone, tabs). */
|
|
736
|
+
lists: { kind: string; label: string; rows: { r: Result; i: number }[] }[];
|
|
737
|
+
/** The first product (the top product), or null. */
|
|
738
|
+
hero: { r: Result; i: number } | null;
|
|
739
|
+
/** The products after it (at most 6 on the panel). */
|
|
740
|
+
products: { r: Result; i: number }[];
|
|
741
|
+
/** The phone sheet's tabs: one per type with results (products first); `select()` shows it. */
|
|
742
|
+
tabs: { kind: string; label: string; selected: boolean; select: () => void }[];
|
|
743
|
+
/** "No results": phrases to try (options), links to browse (options), fallback products (options), the help box. */
|
|
744
|
+
empty: { tries: { r: Result; i: number }[]; browse: { r: Result; i: number }[]; fallback: { r: Result; i: number }[]; help: { title: string; phone: string; tel: string; hours: string } | null };
|
|
745
|
+
/** Pointer over an option: it becomes the selection (the keyboard's too). */
|
|
746
|
+
hover: (i: number, e?: PointerEvent) => void;
|
|
747
|
+
/** Pointer off that option: a selection the pointer made is cleared (a keyboard one stays). */
|
|
748
|
+
unhover: (i: number, e?: PointerEvent) => void;
|
|
749
|
+
/** The house design's config: `hero`, `productLayout`, and a product's optional fields. */
|
|
750
|
+
heroVariant: 'image' | 'spec';
|
|
751
|
+
productLayout: 'grid' | 'list';
|
|
752
|
+
/** A result's optional fields (`productFields`, `hero`): '' when it has none. */
|
|
753
|
+
field: (r: Result, name: 'availability' | 'category' | 'oldPrice' | 'meta' | 'price' | 'badge') => string;
|
|
754
|
+
/** The top product's spec rows (`hero.specFields`, at most 4). */
|
|
755
|
+
specs: (r: Result) => { label: string; value: string }[];
|
|
756
|
+
/** The phone sheet: its input's value, placeholder and name (the site's input's), clearing it, and its back button. */
|
|
757
|
+
value: string;
|
|
758
|
+
placeholder: string;
|
|
759
|
+
inputLabel: string;
|
|
760
|
+
clear: () => void;
|
|
761
|
+
back: () => void;
|
|
762
|
+
}
|
|
763
|
+
export interface AutocompleteResultScope extends AutocompleteScope {
|
|
764
|
+
r: Result;
|
|
765
|
+
i: number;
|
|
766
|
+
/** How the house design draws the option: a pill, a list row, the top product, a product card or row, a phone row, a compact fallback card, a chip, a browse link. */
|
|
767
|
+
as?: 'pill' | 'row' | 'hero' | 'card' | 'line' | 'mhero' | 'mproduct' | 'mrow' | 'compact' | 'try' | 'browse';
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export interface RecommenderScope extends Helpers {
|
|
771
|
+
results: Result[];
|
|
772
|
+
/** Popup only. */
|
|
773
|
+
close?: () => void;
|
|
774
|
+
}
|
|
775
|
+
export interface RecommenderResultScope extends RecommenderScope {
|
|
776
|
+
r: Result;
|
|
777
|
+
i: number;
|
|
778
|
+
}
|