@luigisbox/luma 0.6.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 +33 -0
- package/README.md +59 -3
- package/build/vite.mjs +4 -1
- package/docs/ACCESSIBILITY.md +30 -3
- 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/types.ts +57 -12
- package/src/widgets/autocomplete/index.ts +78 -25
- package/src/widgets/autocomplete/template-autocomplete-box.ts +1 -1
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +3 -3
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
- package/src/widgets/listing/element.ts +63 -6
- 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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,39 @@ before 1.0, a minor version may break the integration API.
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.7.0 — 2026-10-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `variants: { fields }` on a listing (search, plp; plp inherits it): each
|
|
13
|
+
result gets its variant group as `r.variants` (`siblings`, `total`) from
|
|
14
|
+
Luigi's Box. One request per page of results, never waited for: the
|
|
15
|
+
tiles are drawn again when it arrives. Off by default, built only where
|
|
16
|
+
a folder's `_config.ts` sets it.
|
|
17
|
+
- Light-DOM autocomplete full screen on a phone: `autocomplete.fullScreen`
|
|
18
|
+
(`true`, or a media query such as `'(max-width: 767px)'`). A tap on the
|
|
19
|
+
site's input opens it over the whole screen, with the integration's own
|
|
20
|
+
markup and CSS. Its template renders its own input, marked
|
|
21
|
+
`data-lbx-full-screen-input`, and gets `fullScreen`, `state`, `tabs`,
|
|
22
|
+
`value`, `back()` and `clear()`. Its CSS places it, keyed on
|
|
23
|
+
`[data-lbx-full-screen]`, with `--lbx-top` and `--lbx-cover` from Luma.
|
|
24
|
+
Off by default.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- The listing reads the result set's key from `request_key` (`guid` is its
|
|
29
|
+
deprecated name).
|
|
30
|
+
- Autocomplete templates: `sheet` is now `fullScreen`, and the full-screen
|
|
31
|
+
input's marker `data-lbx-sheet-input` (in an `autocomplete-sheet`
|
|
32
|
+
override) is now `data-lbx-full-screen-input`. Rename both in templates
|
|
33
|
+
copied from 0.6.0.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Full-screen autocomplete on a phone: focus leaving it for the page behind
|
|
38
|
+
(Tab past its last control, iOS's next-field arrow) closes it. Focus was
|
|
39
|
+
left on a control it hid.
|
|
40
|
+
|
|
8
41
|
## 0.6.0 — 2026-10-20
|
|
9
42
|
|
|
10
43
|
### Changed
|
package/README.md
CHANGED
|
@@ -10,9 +10,9 @@ This package is what an integration is built with. Each integration lives in
|
|
|
10
10
|
its own repository and depends on `@luigisbox/luma`
|
|
11
11
|
([Integration repository](#integration-repository)).
|
|
12
12
|
|
|
13
|
-
> **Pre-release.** Until 1.0, a minor version (0.
|
|
13
|
+
> **Pre-release.** Until 1.0, a minor version (0.8.0) may change the
|
|
14
14
|
> integration API (config keys, template scope, the build); a patch version
|
|
15
|
-
> (0.
|
|
15
|
+
> (0.7.1) only fixes. Depend on `^0.7.0` to take fixes without breaking
|
|
16
16
|
> changes. [`CHANGELOG.md`](CHANGELOG.md) says what each version changed.
|
|
17
17
|
|
|
18
18
|
## Core principles
|
|
@@ -391,6 +391,59 @@ export default ({ r, i, img, price }: SearchResultScope) => html`
|
|
|
391
391
|
and twice.
|
|
392
392
|
- **Pagination** is "show more" only: templates get `results`, `more` and
|
|
393
393
|
`showMore`; there are no numbered pages.
|
|
394
|
+
- **Variant groups** (`variants`, a listing's config, off by default): where
|
|
395
|
+
the surface collapses a product's variants into one result (one tile for a
|
|
396
|
+
shoe in eight sizes), `r.variants` holds the group's other products
|
|
397
|
+
(`siblings`, as results, at most 100) and its size with this one
|
|
398
|
+
(`total`). Luma asks Luigi's Box for them once per page of results, as
|
|
399
|
+
soon as the page is here, and draws the tiles again when they arrive: a
|
|
400
|
+
tile shows without them first (`r.variants` is undefined), and stays so if
|
|
401
|
+
the request fails; a listing restored on Back has them at once. Keep the
|
|
402
|
+
tile's box the same with and without them (a reserved row, an overlay, or
|
|
403
|
+
shown on hover and focus, `:hover, :focus-within`), or the rows below move
|
|
404
|
+
when they arrive; draw them inside the tile's own markup, not a different
|
|
405
|
+
template branch. `fields` names what each sibling carries; keep it to what
|
|
406
|
+
the tile shows.
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
// integration/search/_config.ts (plp inherits it): the product's own size in `fields` too
|
|
410
|
+
export default { /* … */ fields: ['@title', 'web_url', 'size'], variants: { fields: ['size'] } };
|
|
411
|
+
// integration/_global/_init.ts: the sizes in stock, the product's and its siblings'
|
|
412
|
+
export default (config) => {
|
|
413
|
+
config._global.computedAttributes = {
|
|
414
|
+
...config._global.computedAttributes,
|
|
415
|
+
sizes: (r) => [...new Set([r.a.size, ...(r.variants?.siblings ?? []).map((s) => s.a.size)].flat().filter(Boolean))],
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
```
|
|
419
|
+
- **A light-DOM autocomplete full screen on a phone**
|
|
420
|
+
(`autocomplete.dom: 'light'` with `fullScreen: true`, or a media query of
|
|
421
|
+
the site's own: `fullScreen: '(max-width: 767px)'`): a tap on the site's
|
|
422
|
+
input opens the autocomplete over the whole screen instead of a dropdown
|
|
423
|
+
under the input, and `autocomplete-box` gets `fullScreen: true`
|
|
424
|
+
(`state: 'idle'` until a phrase is long enough). The template renders its
|
|
425
|
+
own input, marked `data-lbx-full-screen-input` (`.value=${s.value}`): Luma
|
|
426
|
+
moves the focus into it and makes what is typed there the query and the
|
|
427
|
+
site's input's value. `back()` closes it (as Escape and the browser's
|
|
428
|
+
Back do) and `clear()` empties its input; `tabs` are one per type with
|
|
429
|
+
results. Keep the input one element in every state (not drawn anew for
|
|
430
|
+
results), and the tabs `role="tab"` siblings in their `role="tablist"`:
|
|
431
|
+
the arrow keys move between them. The arrow keys go through the options
|
|
432
|
+
it shows (the open tab's, whether the others are not drawn or hidden by
|
|
433
|
+
its CSS). While it is open the dropdown carries `data-lbx-full-screen` (a
|
|
434
|
+
phone in landscape too), and the integration's CSS places it full screen
|
|
435
|
+
there: `[data-lbx-full-screen] { inset: 0; top: var(--lbx-top, 0px) }`
|
|
436
|
+
(Luma sets `--lbx-top` to the visual viewport's top, which iOS moves when
|
|
437
|
+
it pans the page for the keyboard, and `--lbx-cover` to what Safari's
|
|
438
|
+
bars and the keyboard cover of its bottom: end the scrolling list with
|
|
439
|
+
that much space and give it `scroll-padding-bottom: var(--lbx-cover, 0px)`,
|
|
440
|
+
so its last options are not hidden). Focus leaving it for the page it
|
|
441
|
+
covers closes it. Without `fullScreen`, a light-DOM dropdown on a phone
|
|
442
|
+
stays a dropdown. The build includes this only where a file of the
|
|
443
|
+
integration's repository (a script or `.html` file, not in `node_modules`,
|
|
444
|
+
`dist` or a hidden folder) has `data-lbx-full-screen-input`. The house
|
|
445
|
+
design is always full screen on a phone; its templates get the same
|
|
446
|
+
`fullScreen`.
|
|
394
447
|
- **No results under filters**: the scope still holds the selected groups,
|
|
395
448
|
their `chips` and `clear`. A template that leaves the facets out when
|
|
396
449
|
`total` is 0 must keep them (or at least `clear`) while `chips.length` is
|
|
@@ -411,7 +464,9 @@ On by default unless noted; each falls back to what the page would do anyway.
|
|
|
411
464
|
`emptyValues`, `prerenderWhileTyping` and `contentVisibility`, off by
|
|
412
465
|
default, are in the build only where a file of the integration's repository
|
|
413
466
|
(a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) names them, as `_config.ts` or `_init.ts` does when
|
|
414
|
-
it sets them;
|
|
467
|
+
it sets them; `variants` only where a folder's `_config.ts` sets it (set it
|
|
468
|
+
there, not in `_init.ts`); a switch on by default that no file names is
|
|
469
|
+
built as on.
|
|
415
470
|
|
|
416
471
|
| Config | What it does | Turn it off when |
|
|
417
472
|
|---|---|---|
|
|
@@ -422,6 +477,7 @@ it sets them; a switch on by default that no file names is built as on.
|
|
|
422
477
|
| `restoreOnBack` | Back from a product restores the listing (every page shown) and the followed tile's position, as does the page loaded again within a minute of following a link (a reload, a link whose server redirects back to it); Luma turns the browser's own scroll restoration (`history.scrollRestoration`) off for the listing's history entry until it has placed the page, unless the site set it | |
|
|
423
478
|
| `prefetchNextPage` | "show more" is fetched as the shopper nears the end | |
|
|
424
479
|
| `contentVisibility` (opt-in) | tiles out of view skip style, layout and paint, each at its own measured size: a long list's re-render about halves | keep it off where a tile draws outside its box (a hover panel, a badge hanging out) or holds a `position: fixed` element |
|
|
480
|
+
| `variants: { fields }` (opt-in) | each tile's result gets its variant group (`r.variants`, "Templates" above): one request to Luigi's Box per page of results, never waited for | the surface does not collapse variants (every group is a group of one) |
|
|
425
481
|
| `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus; in a drawer with `facetsApply: 'staged'`, the selection it holds is fetched once the changes pause, so its apply button shows the results without waiting | |
|
|
426
482
|
| `_global.speculation` | product pages are prerendered on hover (`'prerender'`, `'prefetch'`, `false`) | the site's server or its product-page scripts can't take a render per unfollowed hover |
|
|
427
483
|
|
package/build/vite.mjs
CHANGED
|
@@ -562,11 +562,12 @@ export const featureDefines = (integrationDir) => {
|
|
|
562
562
|
__LBX_HOST_STYLES__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, 'style-host.css')))),
|
|
563
563
|
__LBX_TRANSLATIONS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_translations.ts')))),
|
|
564
564
|
__LBX_INITS__: has((d) => d.some((f) => existsSync(resolve(integrationDir, f, '_init.ts')))),
|
|
565
|
-
// Only an integration's templates make a cart button, a filter drawer or a
|
|
565
|
+
// Only an integration's templates make a cart button, a filter drawer, a menu or a light-DOM phone sheet's input (Luma's own have none; its house sheet is not light DOM).
|
|
566
566
|
__LBX_CART__: has(() => inFiles('data-lbx-cart')),
|
|
567
567
|
__LBX_DRAWER__: has(() => inFiles('data-lbx-drawer')),
|
|
568
568
|
__LBX_MENU__: has(() => inFiles('data-lbx-menu')),
|
|
569
569
|
__LBX_DROPDOWN__: has(() => inFiles('data-lbx-dropdown')),
|
|
570
|
+
__LBX_LIGHT_FULL_SCREEN__: has(() => inFiles('data-lbx-full-screen-input')),
|
|
570
571
|
// Hooks only the integration's code sets (_init.ts, _widget.ts: functions, never in _config.ts).
|
|
571
572
|
__LBX_AC_HOOKS__: has(() => inFiles('onOpen', 'onClose', 'onSelect', 'onReady')),
|
|
572
573
|
__LBX_ON_RENDER__: has(() => inFiles('onRender')),
|
|
@@ -577,6 +578,8 @@ export const featureDefines = (integrationDir) => {
|
|
|
577
578
|
__LBX_EMPTY_VALUES__: has(() => inFiles('emptyValues')),
|
|
578
579
|
__LBX_PRERENDER_TYPING__: has(() => inFiles('prerenderWhileTyping', 'pageWhileTyping')),
|
|
579
580
|
__LBX_CONTENT_VISIBILITY__: has(() => inFiles('contentVisibility')),
|
|
581
|
+
// A listing's variant groups: the key in a folder's _config.ts (the word alone is common: a catalog field, a comment).
|
|
582
|
+
__LBX_VARIANTS__: has((d) => d.some((f) => /\bvariants\b["']?\s*[:,}]/.test(existsSync(resolve(integrationDir, f, '_config.ts')) ? readFileSync(resolve(integrationDir, f, '_config.ts'), 'utf8') : ''))),
|
|
580
583
|
// Options on by default: one no file names is on.
|
|
581
584
|
...Object.fromEntries(ON_BY_DEFAULT.map((k) => [`__LBX_NAMED_${k.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}__`, has(() => inFiles(k))])),
|
|
582
585
|
};
|
package/docs/ACCESSIBILITY.md
CHANGED
|
@@ -139,7 +139,13 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
139
139
|
through the list puts the on-screen keyboard away: focus moves from the
|
|
140
140
|
input to the sheet (`tabindex="-1"`), Escape still closes it, a tap on the
|
|
141
141
|
input brings the keyboard back; Tab is the browser's; targets are 44 px; Escape and the
|
|
142
|
-
browser's Back close it
|
|
142
|
+
browser's Back close it, and so does focus leaving it for the page it
|
|
143
|
+
covers (Tab past its last control, Shift+Tab from its first, iOS's
|
|
144
|
+
next-field arrow): it is never left on a control the sheet hides (2.4.11).
|
|
145
|
+
A light-DOM integration's full-screen autocomplete (`fullScreen`) gets the same focus, keys,
|
|
146
|
+
`aria-activedescendant` on its input and Back; a swipe moves the focus to
|
|
147
|
+
the host (`tabindex="-1"`, `role="group"` named as the input); its roles,
|
|
148
|
+
names and targets are its template's (§5).
|
|
143
149
|
- **Forced colours**: the selection in `Highlight`/`HighlightText` with an
|
|
144
150
|
outline, the panel (and the sheet) keep a `CanvasText` border, pills,
|
|
145
151
|
chips and "Show all products" an outline.
|
|
@@ -164,6 +170,10 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
164
170
|
link marked `tabindex="-1"`); a render that removed the focused control (a
|
|
165
171
|
chip, the last "show more") moves it to the region, not `<body>`. A mouse
|
|
166
172
|
click keeps focus where it is.
|
|
173
|
+
- **Variant groups landing** (`variants`): the tiles are drawn again, in
|
|
174
|
+
steps, with nothing announced and `aria-busy` untouched (the results did
|
|
175
|
+
not change); focus stays where it is, and in a tile whose template drew
|
|
176
|
+
other markup with its group, goes to the region, not `<body>`.
|
|
167
177
|
- **Menus** (`[data-lbx-menu]` with `[data-lbx-toggle]`, the integration's
|
|
168
178
|
markup): `aria-expanded` on the menu's button (the toggle with
|
|
169
179
|
`aria-haspopup`/`aria-expanded`, else the first toggle) in every state;
|
|
@@ -330,7 +340,20 @@ drawer. The markup is the integration's, so:
|
|
|
330
340
|
`aria-selected`, `tabindex="-1"`; sections as `role="group"` named by their
|
|
331
341
|
heading; wrappers in between `role="presentation"`; nothing but options and
|
|
332
342
|
groups inside the listbox (a pointer-only "show all" duplicate of Enter:
|
|
333
|
-
`aria-hidden="true"`, no tab stop). Decorative pictures `alt=""`.
|
|
343
|
+
`aria-hidden="true"`, no tab stop). Decorative pictures `alt=""`. Full screen on a
|
|
344
|
+
phone (`fullScreen`): the input marked `data-lbx-full-screen-input` is the combobox
|
|
345
|
+
(`role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`,
|
|
346
|
+
`aria-controls` naming the listbox, a name: `inputLabel`); its back and
|
|
347
|
+
clear controls are buttons with names (`t('closeSearch')`, `t('clear')`)
|
|
348
|
+
calling `back()` and `clear()`, 44 px; type tabs a `tablist` of
|
|
349
|
+
`role="tab"` buttons with `aria-selected` (Luma moves between them with the
|
|
350
|
+
arrow keys: siblings in the tablist; the arrows go through the options
|
|
351
|
+
it shows, the open tab's); the input one element in every state
|
|
352
|
+
(drawn anew, it loses the focus and the keyboard); the input's font at
|
|
353
|
+
least 16 px (iOS zooms into a smaller one); the scrolling list ends with
|
|
354
|
+
`var(--lbx-cover)` of space and has it as `scroll-padding-bottom`, so an
|
|
355
|
+
option the keys bring into view is not under the keyboard or Safari's
|
|
356
|
+
bars (2.4.11).
|
|
334
357
|
- **Listings**: filter groups as `fieldset` + `legend` (or `role="group"`
|
|
335
358
|
with a name); native checkboxes, or links with `role="checkbox"` and
|
|
336
359
|
`aria-checked` (never `aria-checked` on a plain link); chips named by what
|
|
@@ -345,7 +368,11 @@ drawer. The markup is the integration's, so:
|
|
|
345
368
|
a `role="button"` div (Luma adds the keys) but a `<button>` is better.
|
|
346
369
|
- **Tiles**: one link name (the picture `alt=""` when the title is in the same
|
|
347
370
|
link); a struck-through old price says so (`<span class="lbx-sr-only">Was
|
|
348
|
-
</span>`), and the current one "Now" next to it.
|
|
371
|
+
</span>`), and the current one "Now" next to it. A variant group
|
|
372
|
+
(`r.variants`) is drawn inside the tile's own markup, not a different
|
|
373
|
+
template branch (focus in the tile stays on its link); shown on hover, it
|
|
374
|
+
shows on focus too (`:hover, :focus-within`), and sibling links have a
|
|
375
|
+
name and 24 px targets.
|
|
349
376
|
- **Colour**: text at 4.5:1, states not by colour alone, 24 px targets; adopt
|
|
350
377
|
the high-contrast mode (§4) for the colours your CSS sets.
|
|
351
378
|
- **Don't** add `aria-live` to counts (Luma announces), set `tabindex="0"` on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luigisbox/luma",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Luma: Luigi's Box frontend integrations (autocomplete, search, category listings, recommenders) on the Luigi's Box AI API. Pre-release.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
package/src/api/ai.ts
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// Also runs in Node (the edge render service): there the
|
|
9
9
|
// storefront origin is sent explicitly and nothing touches the DOM.
|
|
10
10
|
|
|
11
|
-
import { PLP, RECOMMENDER, SEARCH } from '../core/features';
|
|
11
|
+
import { PLP, RECOMMENDER, SEARCH, VARIANTS } from '../core/features';
|
|
12
12
|
import { health } from '../core/health';
|
|
13
13
|
import type { GlobalConfigType } from '../types';
|
|
14
|
-
import type { Api, FacetData, Hit, Page } from '.';
|
|
14
|
+
import type { Api, FacetData, Hit, Page, VariantGroup } from '.';
|
|
15
15
|
import { fetchJson, TIMEOUT_MS, tokensFor, untilAborted, visitorId } from './token';
|
|
16
16
|
|
|
17
17
|
export { untilAborted };
|
|
@@ -78,7 +78,8 @@ export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
|
|
|
78
78
|
return_fields: r.fields?.length ? r.fields : undefined,
|
|
79
79
|
});
|
|
80
80
|
const j = await post(r.endpoint, { channel_id: g.channelId, surface_id: r.surface }, body, signal);
|
|
81
|
-
|
|
81
|
+
// `guid` is the deprecated name of `request_key` (it will change meaning).
|
|
82
|
+
return { hits: j.hits ?? [], total: j.total ?? j.total_approx ?? 0, guid: j.request_key ?? j.guid, cursor: j.next_page_cursor ?? null, status: j.pagination_status ?? 'exhausted' };
|
|
82
83
|
},
|
|
83
84
|
|
|
84
85
|
// A listing's and a recommender's (core/features.ts): not in a build without one.
|
|
@@ -106,5 +107,13 @@ export const aiApi = (g: GlobalConfigType, origin?: string): Api => {
|
|
|
106
107
|
)
|
|
107
108
|
).hits ?? []
|
|
108
109
|
: unbuilt,
|
|
110
|
+
|
|
111
|
+
// A listing's `variants` (core/features.ts): not in a build whose integration names none.
|
|
112
|
+
...(VARIANTS
|
|
113
|
+
? {
|
|
114
|
+
variants: async (ids: string[], key: string, fields: string[] | undefined, signal?: AbortSignal): Promise<Record<string, VariantGroup>> =>
|
|
115
|
+
(await post('variants', { channel_id: g.channelId }, compact({ ids, request_key: key, return_fields: fields?.length ? fields : undefined }), signal)).variants ?? {},
|
|
116
|
+
}
|
|
117
|
+
: {}),
|
|
109
118
|
};
|
|
110
119
|
};
|
package/src/api/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface Page {
|
|
|
26
26
|
hits: Hit[];
|
|
27
27
|
/** Exact when known, else the API's approximation. */
|
|
28
28
|
total: number;
|
|
29
|
-
/** Identifies the result set; /facets
|
|
29
|
+
/** Identifies the result set (the API's `request_key`); /facets and /variants take it. */
|
|
30
30
|
guid: string;
|
|
31
31
|
/** Cursor of the next page; null when there is none (see status). */
|
|
32
32
|
cursor: string | null;
|
|
@@ -68,11 +68,21 @@ export interface RecoRequest {
|
|
|
68
68
|
fields?: string[];
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** A product's variant group (/variants): the other members, and the group's size with the product. */
|
|
72
|
+
export interface VariantGroup {
|
|
73
|
+
/** The other members of the group, by `@id`; at most 100. */
|
|
74
|
+
siblings: Hit[];
|
|
75
|
+
/** The group's size, the product included (0: not in the catalog). */
|
|
76
|
+
total: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
71
79
|
export interface Api {
|
|
72
80
|
search(r: ListingRequest, signal?: AbortSignal): Promise<Page>;
|
|
73
81
|
/** Facets of a result set (its guid): values, no counts. */
|
|
74
82
|
facets(guid: string, facets: { field: string; size?: number }[], signal?: AbortSignal): Promise<Record<string, FacetData>>;
|
|
75
83
|
recommend(r: RecoRequest, signal?: AbortSignal): Promise<Hit[]>;
|
|
84
|
+
/** The variant groups of products of a result set (its key: `guid`), by `@id`: at most 200 ids. Built only for a listing's `variants`. */
|
|
85
|
+
variants?(ids: string[], key: string, fields: string[] | undefined, signal?: AbortSignal): Promise<Record<string, VariantGroup>>;
|
|
76
86
|
/** Mint the browser token now, so the first request does not wait for it. */
|
|
77
87
|
warm(): void;
|
|
78
88
|
}
|
package/src/core/features.ts
CHANGED
|
@@ -18,6 +18,7 @@ declare const __LBX_CART__: boolean | undefined;
|
|
|
18
18
|
declare const __LBX_DRAWER__: boolean | undefined;
|
|
19
19
|
declare const __LBX_MENU__: boolean | undefined;
|
|
20
20
|
declare const __LBX_DROPDOWN__: boolean | undefined;
|
|
21
|
+
declare const __LBX_LIGHT_FULL_SCREEN__: boolean | undefined;
|
|
21
22
|
declare const __LBX_AC_HOOKS__: boolean | undefined;
|
|
22
23
|
declare const __LBX_ON_RENDER__: boolean | undefined;
|
|
23
24
|
declare const __LBX_READ_QUERY__: boolean | undefined;
|
|
@@ -29,6 +30,7 @@ declare const __LBX_INITS__: boolean | undefined;
|
|
|
29
30
|
declare const __LBX_EMPTY_VALUES__: boolean | undefined;
|
|
30
31
|
declare const __LBX_PRERENDER_TYPING__: boolean | undefined;
|
|
31
32
|
declare const __LBX_CONTENT_VISIBILITY__: boolean | undefined;
|
|
33
|
+
declare const __LBX_VARIANTS__: boolean | undefined;
|
|
32
34
|
declare const __LBX_NAMED_PRECONNECT_IMAGES__: boolean | undefined;
|
|
33
35
|
declare const __LBX_NAMED_PREFETCH_FILTERS__: boolean | undefined;
|
|
34
36
|
declare const __LBX_NAMED_PRELOAD__: boolean | undefined;
|
|
@@ -60,6 +62,8 @@ export const DRAWER = typeof __LBX_DRAWER__ === 'boolean' ? __LBX_DRAWER__ : tru
|
|
|
60
62
|
export const MENU = typeof __LBX_MENU__ === 'boolean' ? __LBX_MENU__ : true;
|
|
61
63
|
/** `data-lbx-dropdown` in any file of the integration: `<details>` menus in the listing's markup, closed from outside. */
|
|
62
64
|
export const DROPDOWN = typeof __LBX_DROPDOWN__ === 'boolean' ? __LBX_DROPDOWN__ : true;
|
|
65
|
+
/** `data-lbx-full-screen-input` in any file of the integration: a light-DOM autocomplete's own phone sheet (widgets/autocomplete, `fullScreen`). */
|
|
66
|
+
export const LIGHT_FULL_SCREEN = typeof __LBX_LIGHT_FULL_SCREEN__ === 'boolean' ? __LBX_LIGHT_FULL_SCREEN__ : true;
|
|
63
67
|
/** The autocomplete's `onOpen`, `onClose`, `onSelect`, `onReady` named in the integration's code. */
|
|
64
68
|
export const AC_HOOKS = typeof __LBX_AC_HOOKS__ === 'boolean' ? __LBX_AC_HOOKS__ : true;
|
|
65
69
|
/** A listing's `onRender` named in the integration's code. */
|
|
@@ -81,10 +85,13 @@ export const INITS = typeof __LBX_INITS__ === 'boolean' ? __LBX_INITS__ : true;
|
|
|
81
85
|
* or code setting them): a key no file names keeps its default, as only
|
|
82
86
|
* code naming it can change it. `emptyValues`, `prerenderWhileTyping` (or
|
|
83
87
|
* `pageWhileTyping`: prerendering the search page typed for), `contentVisibility`.
|
|
88
|
+
* `variants` (a listing's products' variant groups, widgets/listing/variants.ts):
|
|
89
|
+
* the key in a folder's _config.ts only (the word alone is common).
|
|
84
90
|
*/
|
|
85
91
|
export const EMPTY_VALUES = typeof __LBX_EMPTY_VALUES__ === 'boolean' ? __LBX_EMPTY_VALUES__ : true;
|
|
86
92
|
export const PRERENDER_TYPING = typeof __LBX_PRERENDER_TYPING__ === 'boolean' ? __LBX_PRERENDER_TYPING__ : true;
|
|
87
93
|
export const CONTENT_VISIBILITY = typeof __LBX_CONTENT_VISIBILITY__ === 'boolean' ? __LBX_CONTENT_VISIBILITY__ : true;
|
|
94
|
+
export const VARIANTS = typeof __LBX_VARIANTS__ === 'boolean' ? __LBX_VARIANTS__ : true;
|
|
88
95
|
/**
|
|
89
96
|
* Server-rendered listings (Luigi's Box's server-side includes): adopting
|
|
90
97
|
* and hydrating the server's markup. Declared, not found in the folders:
|
package/src/core/integration.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// templates, translations and helpers the bundle would.
|
|
6
6
|
|
|
7
7
|
import type { LumaApi, LumaConfigType, LumaInitType } from '../types';
|
|
8
|
-
import { AUTOCOMPLETE, HOST_STYLES, INITS, PLP, RECOMMENDER, SEARCH, TRANSLATIONS } from './features';
|
|
8
|
+
import { AUTOCOMPLETE, HOST_STYLES, INITS, PLP, RECOMMENDER, SEARCH, TRANSLATIONS, VARIANTS } from './features';
|
|
9
9
|
|
|
10
10
|
export type Lazy<T> = Record<string, () => Promise<T>>;
|
|
11
11
|
|
|
@@ -106,9 +106,14 @@ export const loadConfig = async (files: Pick<IntegrationFiles, 'configs' | 'init
|
|
|
106
106
|
if (config._global.disabled || !config._global.publishableKey || !config._global.channelId) return null;
|
|
107
107
|
const apps = [AUTOCOMPLETE ? config.autocomplete : null, SEARCH ? config.search : null, PLP ? config.plp : null, ...(RECOMMENDER ? Object.values(config.recommenders ?? {}) : [])];
|
|
108
108
|
if (INITS) for (const app of apps) if (app && !app.disabled && app.initialize && (await (app.initialize as any)(app, Luma)) === false) app.disabled = true;
|
|
109
|
+
// `variants` comes into a build from a folder's _config.ts only (build/vite.mjs
|
|
110
|
+
// featureDefines): set by an _init.ts, a build would leave it out unsaid.
|
|
111
|
+
if (import.meta.env.DEV && (config.search?.variants || config.plp?.variants) && !Object.values(files.configs).some((v: any) => v && typeof v === 'object' && 'variants' in v))
|
|
112
|
+
console.error("[lbx] variants: set it in a folder's _config.ts; a build leaves it out otherwise");
|
|
109
113
|
// Anything a category page does not set is the search page's.
|
|
110
114
|
const plp = PLP ? config.plp : null;
|
|
111
|
-
|
|
115
|
+
// `variants` only where the integration names it (core/features.ts): not a key of every entry.
|
|
116
|
+
if (plp) for (const k of VARIANTS ? [...LISTING, 'variants' as const] : LISTING) if (plp[k] == null && config.search?.[k] != null) (plp as any)[k] = config.search[k];
|
|
112
117
|
if (SEARCH || PLP) for (const l of [config.search, config.plp]) if (l && l.reserveSelector == null && l.anchor) l.reserveSelector = [l.anchor].flat()[0];
|
|
113
118
|
return config;
|
|
114
119
|
};
|
package/src/types.ts
CHANGED
|
@@ -16,6 +16,14 @@ export interface Result {
|
|
|
16
16
|
type: string;
|
|
17
17
|
a: Hit;
|
|
18
18
|
c: Record<string, any>;
|
|
19
|
+
/**
|
|
20
|
+
* A listing's `variants`: the other products of this one's variant group
|
|
21
|
+
* (collapsed into it by the surface), as results (`siblings`, by `@id`, at
|
|
22
|
+
* most 100), and the group's size with this one (`total`). Undefined until
|
|
23
|
+
* Luigi's Box has answered (the tile is drawn again then), on the server,
|
|
24
|
+
* and outside listings.
|
|
25
|
+
*/
|
|
26
|
+
variants?: { siblings: Result[]; total: number };
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
/**
|
|
@@ -237,6 +245,23 @@ export interface AutocompleteConfigType extends AppConfig<AutocompleteConfigType
|
|
|
237
245
|
dom?: 'shadow' | 'light';
|
|
238
246
|
hostClass?: string;
|
|
239
247
|
hostId?: string;
|
|
248
|
+
/**
|
|
249
|
+
* `dom: 'light'`: the autocomplete full screen on a phone, with the
|
|
250
|
+
* integration's own markup. `true` where the house design is full screen
|
|
251
|
+
* (a viewport up to 600 px, or a short touch screen), or a media query of
|
|
252
|
+
* the site's own (`'(max-width: 767px)'`). There, focus or a tap on the
|
|
253
|
+
* site's input opens it with what the input holds (templates get
|
|
254
|
+
* `fullScreen: true`, `state: 'idle'` until a phrase is long enough); the
|
|
255
|
+
* template renders its own input, marked `data-lbx-full-screen-input`,
|
|
256
|
+
* which takes the focus and is the combobox there.
|
|
257
|
+
* The integration's CSS places it, keyed on `[data-lbx-full-screen]` while open
|
|
258
|
+
* (`inset: 0; top: var(--lbx-top, 0px)`): Luma sets no box on it, only `--lbx-top` (the
|
|
259
|
+
* visual viewport's top) and `--lbx-cover` (what Safari's bars and the
|
|
260
|
+
* keyboard cover of its bottom: the list's end space and
|
|
261
|
+
* `scroll-padding-bottom`). Off by default: a light-DOM dropdown
|
|
262
|
+
* on a phone stays a dropdown. The house design is always full screen there.
|
|
263
|
+
*/
|
|
264
|
+
fullScreen?: boolean | string;
|
|
240
265
|
/**
|
|
241
266
|
* What the dropdown lines up with (its left edge and width, just below it).
|
|
242
267
|
* By default the visible search field: the input when it draws its own
|
|
@@ -297,6 +322,21 @@ export interface ListingConfigType {
|
|
|
297
322
|
sorts: string[];
|
|
298
323
|
/** return_fields for the tiles (default: the surface's). */
|
|
299
324
|
fields?: string[];
|
|
325
|
+
/**
|
|
326
|
+
* The variant groups of the products shown, for a surface that collapses
|
|
327
|
+
* them (one result per group: a shoe in eight sizes is one tile): each
|
|
328
|
+
* tile's result gets the group's other products as `r.variants` (their
|
|
329
|
+
* `fields`, e.g. the in-stock sizes on hover or focus). Asked from Luigi's
|
|
330
|
+
* Box once per page of results, as soon as it is here (a page restored on
|
|
331
|
+
* Back has them with it): the tiles show without them first and are drawn
|
|
332
|
+
* again when they come, never waited for; without them (an error) they
|
|
333
|
+
* stay as they are. A tile keeps its box with and without them (a reserved
|
|
334
|
+
* row, an overlay, on hover or focus): a tile growing when they come moves
|
|
335
|
+
* the rows below. `fields`: return_fields of the group's products (keep it
|
|
336
|
+
* to what the tiles show). Off by default; set it in `_config.ts` (the
|
|
337
|
+
* build includes it only where a folder's `_config.ts` does).
|
|
338
|
+
*/
|
|
339
|
+
variants?: { fields: string[] };
|
|
300
340
|
/** Always-on filters (e.g. what this storefront may show), AND-ed with the shopper's. */
|
|
301
341
|
baseFilters?: FilterNode;
|
|
302
342
|
/**
|
|
@@ -766,11 +806,24 @@ export interface AutocompleteScope extends Helpers {
|
|
|
766
806
|
* input reaches it (the options stay out of the Tab order).
|
|
767
807
|
*/
|
|
768
808
|
allHref: string;
|
|
769
|
-
|
|
770
|
-
/** 'idle' (the phone sheet before a phrase is long enough), 'results', 'empty' (every type answered nothing). */
|
|
809
|
+
/** 'idle' (full screen, before a phrase is long enough), 'results', 'empty' (every type answered nothing). */
|
|
771
810
|
state: 'idle' | 'results' | 'empty';
|
|
772
|
-
/**
|
|
773
|
-
|
|
811
|
+
/** Full screen on a phone (a light-DOM integration's with the config's `fullScreen`) rather than the dropdown under the input. */
|
|
812
|
+
fullScreen: boolean;
|
|
813
|
+
/** Full screen: the tabs, one per type with results (products first); `select()` shows it. The arrow keys go through the open tab's options. */
|
|
814
|
+
tabs: { kind: string; label: string; selected: boolean; select: () => void }[];
|
|
815
|
+
/**
|
|
816
|
+
* Full screen: its own input (`data-lbx-full-screen-input`): its value, placeholder
|
|
817
|
+
* and name (the site's input's); `clear()` empties it (focus stays in it);
|
|
818
|
+
* `back()` closes it as Escape and the browser's Back do, the site's
|
|
819
|
+
* input back to what it held when it opened.
|
|
820
|
+
*/
|
|
821
|
+
value: string;
|
|
822
|
+
placeholder: string;
|
|
823
|
+
inputLabel: string;
|
|
824
|
+
clear: () => void;
|
|
825
|
+
back: () => void;
|
|
826
|
+
// --- The house design (dom: 'shadow'). -------
|
|
774
827
|
/** The panel is at least 1000 px wide: the top product has its own column. */
|
|
775
828
|
wide: boolean;
|
|
776
829
|
/** Query suggestions (pills). */
|
|
@@ -781,8 +834,6 @@ export interface AutocompleteScope extends Helpers {
|
|
|
781
834
|
hero: { r: Result; i: number } | null;
|
|
782
835
|
/** The products after it (at most 6 on the panel). */
|
|
783
836
|
products: { r: Result; i: number }[];
|
|
784
|
-
/** The phone sheet's tabs: one per type with results (products first); `select()` shows it. */
|
|
785
|
-
tabs: { kind: string; label: string; selected: boolean; select: () => void }[];
|
|
786
837
|
/** "No results": phrases to try (options), links to browse (options), fallback products (options), the help box. */
|
|
787
838
|
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 };
|
|
788
839
|
/** Pointer over an option: it becomes the selection (the keyboard's too). */
|
|
@@ -796,12 +847,6 @@ export interface AutocompleteScope extends Helpers {
|
|
|
796
847
|
field: (r: Result, name: 'availability' | 'category' | 'oldPrice' | 'meta' | 'price' | 'badge') => string;
|
|
797
848
|
/** The top product's spec rows (`hero.specFields`, at most 4). */
|
|
798
849
|
specs: (r: Result) => { label: string; value: string }[];
|
|
799
|
-
/** The phone sheet: its input's value, placeholder and name (the site's input's), clearing it, and its back button. */
|
|
800
|
-
value: string;
|
|
801
|
-
placeholder: string;
|
|
802
|
-
inputLabel: string;
|
|
803
|
-
clear: () => void;
|
|
804
|
-
back: () => void;
|
|
805
850
|
}
|
|
806
851
|
export interface AutocompleteResultScope extends AutocompleteScope {
|
|
807
852
|
r: Result;
|
|
@@ -18,7 +18,7 @@ import { borrow, liveRegion, useContrast } from '../../core/a11y';
|
|
|
18
18
|
import { luminanceOf, readable } from '../../core/color';
|
|
19
19
|
import { defaultSubmitter, formDataOf } from '../../core/default-button';
|
|
20
20
|
import { onOwnBack } from '../../core/entries';
|
|
21
|
-
import { AC_HOOKS, ANALYTICS, QUERY_URL } from '../../core/features';
|
|
21
|
+
import { AC_HOOKS, ANALYTICS, LIGHT_FULL_SCREEN, QUERY_URL } from '../../core/features';
|
|
22
22
|
import { mark, reportError } from '../../core/health';
|
|
23
23
|
import { modalOf } from '../../core/own';
|
|
24
24
|
import { byName, view } from '../../core/templates';
|
|
@@ -268,7 +268,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
268
268
|
const toTop = () => {
|
|
269
269
|
// Our scroll, not the finger's: a finger at rest on the list is no swipe, the same finger's drag
|
|
270
270
|
// after it still is. Review (PR 26): a new result set's reset put the keyboard away.
|
|
271
|
-
|
|
271
|
+
// A light-DOM sheet's list is the host: whichever of its elements scrolls.
|
|
272
|
+
if (swipe && (LIGHT_FULL_SCREEN && light ? [...scrolled].some((el) => swipe!.list.contains(el)) : scrolled.has(swipe.list))) swipe.ours = true;
|
|
272
273
|
for (const el of scrolled) if (el.isConnected) el.scrollTop = 0;
|
|
273
274
|
scrolled.clear();
|
|
274
275
|
};
|
|
@@ -303,8 +304,13 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
303
304
|
}
|
|
304
305
|
};
|
|
305
306
|
|
|
306
|
-
/**
|
|
307
|
-
|
|
307
|
+
/**
|
|
308
|
+
* Where the phone sheet applies (a media query): the house design's on a
|
|
309
|
+
* phone's viewport; a light-DOM integration's only with `fullScreen` (its own
|
|
310
|
+
* markup: `true` on a phone's viewport, or its own query), never without.
|
|
311
|
+
*/
|
|
312
|
+
const sheetMedia = !light ? SHEET_MEDIA : !LIGHT_FULL_SCREEN ? 'not all' : ac.fullScreen === true ? SHEET_MEDIA : ac.fullScreen || 'not all';
|
|
313
|
+
const phone = () => typeof matchMedia === 'function' && matchMedia(sheetMedia).matches;
|
|
308
314
|
|
|
309
315
|
/** Our shadow root on `el` with our CSS; the box templates render into. */
|
|
310
316
|
const shadowBox = (el: HTMLElement, ...before: Node[]) => {
|
|
@@ -334,7 +340,9 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
334
340
|
// The UA popover's height: fit-content makes a fixed panel the site pins
|
|
335
341
|
// with top + bottom (a full-height sheet on phones) grow past the screen
|
|
336
342
|
// instead of scrolling. Zero specificity: any site rule still wins.
|
|
337
|
-
|
|
343
|
+
// Its width and margin too: a phone sheet the site pins with `inset: 0`
|
|
344
|
+
// fills the screen, not fit-content and centred (the dropdown's width is inline).
|
|
345
|
+
setCss('ac:light', ':where([data-lbx="ac-overlay"]) { height: auto; width: auto; margin: 0; }');
|
|
338
346
|
ctx.styles.forEach((c, i) => setCss(`itg:autocomplete:${i}`, c));
|
|
339
347
|
box = host;
|
|
340
348
|
host.append(live.el); // the templates render after it
|
|
@@ -412,8 +420,10 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
412
420
|
if (!(el instanceof Element)) return;
|
|
413
421
|
if (el.scrollTop > 0) scrolled.add(el);
|
|
414
422
|
else scrolled.delete(el);
|
|
415
|
-
if (swipe
|
|
416
|
-
|
|
423
|
+
if (!swipe || !(LIGHT_FULL_SCREEN && light ? swipe.list.contains(el) : swipe.list === el)) return;
|
|
424
|
+
if (swipe.ours) swipe.ours = false;
|
|
425
|
+
// A light-DOM sheet's sideways band (its tabs) scrolls in it too: only a list that went down.
|
|
426
|
+
else if ((!LIGHT_FULL_SCREEN || !light || el.scrollTop > 0) && (!swipe.cancelled || performance.now() - swipe.cancelled < SWIPE_SCROLL_MS)) putKeyboardAway();
|
|
417
427
|
},
|
|
418
428
|
{ capture: true, passive: true },
|
|
419
429
|
);
|
|
@@ -424,11 +434,13 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
424
434
|
// pill band's sideways drag is not the list. Focus goes to the sheet
|
|
425
435
|
// (tabindex -1), not <body>: Escape and Tab still work from a hardware
|
|
426
436
|
// keyboard. Bug (iOS 26 Safari): with the keyboard up, its bar and the
|
|
427
|
-
// address bar above it, the sheet kept half the screen.
|
|
437
|
+
// address bar above it, the sheet kept half the screen. A light-DOM
|
|
438
|
+
// sheet: its list is the host (whichever of its elements scrolls), and
|
|
439
|
+
// focus goes to the host.
|
|
428
440
|
const putKeyboardAway = () => {
|
|
429
441
|
swipe = null;
|
|
430
442
|
if (!mirror || focused() !== mirror) return;
|
|
431
|
-
const panel = mirror.closest<HTMLElement>('.sheet[tabindex]');
|
|
443
|
+
const panel = mirror.closest<HTMLElement>('.sheet[tabindex]') ?? (LIGHT_FULL_SCREEN && light ? host : null);
|
|
432
444
|
if (panel) panel.focus({ preventScroll: true });
|
|
433
445
|
else mirror.blur(); // an integration's own sheet template
|
|
434
446
|
};
|
|
@@ -439,7 +451,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
439
451
|
const e = ev as PointerEvent;
|
|
440
452
|
swipe = null;
|
|
441
453
|
if (!sheet || !finger(e)) return;
|
|
442
|
-
const
|
|
454
|
+
const path = e.composedPath();
|
|
455
|
+
const list = LIGHT_FULL_SCREEN && light ? (mirror && path.includes(mirror) ? undefined : host!) : (path.find((x) => x instanceof Element && x.matches('.sh-list, .sheet .empty')) as Element | undefined);
|
|
443
456
|
if (list) swipe = { list, x: e.clientX, y: e.clientY, moved: false };
|
|
444
457
|
},
|
|
445
458
|
{ capture: true, passive: true },
|
|
@@ -534,6 +547,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
534
547
|
*/
|
|
535
548
|
/** What Safari's bars and the keyboard cover of the sheet (px, --lbx-cover), -1 while it is not open. */
|
|
536
549
|
let cover = -1;
|
|
550
|
+
/** A light-DOM sheet's top (px, --lbx-top: the visual viewport's), -1 while it is not open. */
|
|
551
|
+
let sheetTop = -1;
|
|
537
552
|
/** A clamp of the scrolled lists is queued (one for a keyboard going down, not one per frame of it). */
|
|
538
553
|
let clamping = false;
|
|
539
554
|
const position = (frame = false, m?: ReturnType<typeof measure>) => {
|
|
@@ -548,14 +563,19 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
548
563
|
// they and the keyboard cover (--lbx-cover) ends the list as space, so
|
|
549
564
|
// its last row and "Show all" scroll clear of them. Bug (iOS 26/27):
|
|
550
565
|
// the list stopped above the bars, a blank strip down to the keys.
|
|
566
|
+
// A light-DOM sheet is placed by the integration's CSS (`position:
|
|
567
|
+
// fixed; inset: 0; top: var(--lbx-top)`): only that top and what is
|
|
568
|
+
// covered (--lbx-cover) are ours.
|
|
551
569
|
const vv = window.visualViewport;
|
|
552
570
|
const top = Math.round(vv?.offsetTop ?? 0);
|
|
553
571
|
const h = Math.round(vv?.height ?? innerHeight);
|
|
554
572
|
const full = Math.max(h, innerHeight - top);
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
573
|
+
if (!LIGHT_FULL_SCREEN || !light) {
|
|
574
|
+
s.top = `${top}px`;
|
|
575
|
+
s.left = `${Math.round(vv?.offsetLeft ?? 0)}px`;
|
|
576
|
+
s.width = `${Math.round(vv?.width ?? innerWidth)}px`;
|
|
577
|
+
s.height = `${full}px`;
|
|
578
|
+
} else if (top !== sheetTop) s.setProperty('--lbx-top', `${(sheetTop = top)}px`);
|
|
559
579
|
if (full - h !== cover) {
|
|
560
580
|
// Less covered (the keyboard going down): the end space shrinks, and iOS WebKit kept a list
|
|
561
581
|
// scrolled into it there, its rows stuck above a blank strip, not scrollable back (iOS 27
|
|
@@ -630,6 +650,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
630
650
|
aria?.restore();
|
|
631
651
|
aria = null;
|
|
632
652
|
};
|
|
653
|
+
/** Option `i`'s element, in the root the options render into. */
|
|
654
|
+
const rowOf = (i: number) => (box.getRootNode() as Document | ShadowRoot).getElementById(idOf(i));
|
|
633
655
|
/**
|
|
634
656
|
* The selected row (the screen reader's focus stays on the input). An id
|
|
635
657
|
* reference resolves only in the input's own tree, never across a shadow
|
|
@@ -648,7 +670,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
648
670
|
const markActive = (say = true) => {
|
|
649
671
|
const all = active === ALL ? allLink() : null;
|
|
650
672
|
if (all && !all.id) all.id = idOf(ALL);
|
|
651
|
-
const row = all ?? (active >= 0 ? (
|
|
673
|
+
const row = all ?? (active >= 0 ? rowOf(active) : null);
|
|
652
674
|
if (sheet && mirror) {
|
|
653
675
|
if (row) mirror.setAttribute('aria-activedescendant', row.id);
|
|
654
676
|
else mirror.removeAttribute('aria-activedescendant');
|
|
@@ -811,7 +833,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
811
833
|
const idle = (f: () => void) => afterFrame(() => (typeof requestIdleCallback === 'function' ? requestIdleCallback(f, { timeout: 300 }) : setTimeout(f, 0)));
|
|
812
834
|
|
|
813
835
|
/**
|
|
814
|
-
* The phone sheet (house design
|
|
836
|
+
* The phone sheet (the house design's, a light-DOM integration's with
|
|
837
|
+
* `fullScreen`: its markup, placed by its CSS): full screen over the page,
|
|
815
838
|
* opened when the site's input gets focus. Its own input takes the focus
|
|
816
839
|
* at once (in the tap's task: iOS shows its keyboard only for a focus in
|
|
817
840
|
* the gesture) with what the site's input holds; it is the combobox there.
|
|
@@ -826,9 +849,20 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
826
849
|
tab = '';
|
|
827
850
|
before = i.value;
|
|
828
851
|
place();
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
852
|
+
if (LIGHT_FULL_SCREEN && light) {
|
|
853
|
+
// The dropdown's inline box (inset: auto, its top, left and width) would beat the integration's CSS.
|
|
854
|
+
// Focusable: a swipe's keyboard put away leaves the focus here (putKeyboardAway), named as the house sheet's panel.
|
|
855
|
+
host!.style.inset = host!.style.width = '';
|
|
856
|
+
host!.tabIndex = -1;
|
|
857
|
+
host!.setAttribute('role', 'group');
|
|
858
|
+
host!.setAttribute('aria-label', labelOf(i));
|
|
859
|
+
// What its CSS keys on (`[data-lbx-full-screen]`): where the sheet opens is Luma's media query, a phone in landscape too.
|
|
860
|
+
host!.setAttribute('data-lbx-full-screen', '');
|
|
861
|
+
} else {
|
|
862
|
+
sampleTokens(i, i);
|
|
863
|
+
// Below the visible part, its background (position).
|
|
864
|
+
host!.style.background = 'var(--lbx-c-bg, var(--lbx-bg))';
|
|
865
|
+
}
|
|
832
866
|
// What the site's input holds now, not what the sheet's held when it last
|
|
833
867
|
// closed (letters typed into the site's input before the hand-off, a
|
|
834
868
|
// value the site's page set since).
|
|
@@ -845,7 +879,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
845
879
|
cancelAnimationFrame(raf);
|
|
846
880
|
position();
|
|
847
881
|
render(); // with what the site's input holds
|
|
848
|
-
mirror =
|
|
882
|
+
mirror = box.querySelector<HTMLInputElement>('[data-lbx-full-screen-input]');
|
|
849
883
|
}
|
|
850
884
|
if (mirror) {
|
|
851
885
|
mirror.focus();
|
|
@@ -890,6 +924,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
890
924
|
then?.();
|
|
891
925
|
return;
|
|
892
926
|
}
|
|
927
|
+
// Our Back already on its way (a second focus outside, Escape twice): one Back. Review: two left the page in Chromium.
|
|
928
|
+
if (afterBack) return;
|
|
893
929
|
const done = then ?? (() => {});
|
|
894
930
|
afterBack = done;
|
|
895
931
|
pendingReason = reason;
|
|
@@ -928,7 +964,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
928
964
|
entry = false; // left in history (the same URL) unless Back took it
|
|
929
965
|
setCss('ac:sheet', null);
|
|
930
966
|
host!.style.height = '';
|
|
931
|
-
|
|
967
|
+
if (LIGHT_FULL_SCREEN && light) {
|
|
968
|
+
host!.style.inset = 'auto'; // the dropdown's again (position sets its top and left)
|
|
969
|
+
host!.style.removeProperty('--lbx-top');
|
|
970
|
+
sheetTop = -1;
|
|
971
|
+
for (const a of ['role', 'aria-label', 'data-lbx-full-screen']) host!.removeAttribute(a);
|
|
972
|
+
} else host!.style.background = 'transparent';
|
|
932
973
|
host!.style.removeProperty('--lbx-cover');
|
|
933
974
|
cover = -1;
|
|
934
975
|
host!.removeAttribute('data-lbx-short');
|
|
@@ -946,6 +987,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
946
987
|
if (AC_HOOKS) hook('onClose', ac.onClose, reason);
|
|
947
988
|
};
|
|
948
989
|
|
|
990
|
+
/** The site's input's name: the sheet's input's (and a light-DOM sheet's own, its panel's). */
|
|
991
|
+
const labelOf = (i: HTMLInputElement | null) => i?.getAttribute('aria-label') || i?.placeholder || t('label');
|
|
949
992
|
/** The sheet input's value the last render drew (its clear button). */
|
|
950
993
|
let drawnValue = '';
|
|
951
994
|
/** Rows as templates get them: the result and its index in `items`. */
|
|
@@ -972,7 +1015,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
972
1015
|
// The arrow keys' order: pills, the lists in config order, the
|
|
973
1016
|
// top product, the products in reading order; in the sheet, the pills and
|
|
974
1017
|
// the open tab's options; with no results, phrases to try, links, picks.
|
|
975
|
-
// A light-DOM integration's own markup keeps the config order
|
|
1018
|
+
// A light-DOM integration's own markup keeps the config order (its sheet:
|
|
1019
|
+
// of the options its markup shows, read at the key: step).
|
|
976
1020
|
const phrases = byType('query');
|
|
977
1021
|
order = light
|
|
978
1022
|
? rows.map((x) => x.i)
|
|
@@ -1000,7 +1044,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1000
1044
|
submitAll: () => submitAll(),
|
|
1001
1045
|
allHref: searchHref(input, sheet ? (mirror?.value ?? '') : undefined),
|
|
1002
1046
|
state,
|
|
1003
|
-
sheet,
|
|
1047
|
+
fullScreen: sheet,
|
|
1004
1048
|
wide,
|
|
1005
1049
|
phrases,
|
|
1006
1050
|
lists,
|
|
@@ -1030,7 +1074,7 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1030
1074
|
.slice(0, 4),
|
|
1031
1075
|
value,
|
|
1032
1076
|
placeholder: input?.placeholder ?? '',
|
|
1033
|
-
inputLabel:
|
|
1077
|
+
inputLabel: labelOf(input),
|
|
1034
1078
|
clear: () => {
|
|
1035
1079
|
if (!mirror) return;
|
|
1036
1080
|
mirror.value = '';
|
|
@@ -1419,7 +1463,11 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1419
1463
|
* other way round.
|
|
1420
1464
|
*/
|
|
1421
1465
|
const step = (by: 1 | -1) => {
|
|
1422
|
-
|
|
1466
|
+
let stops = !sheet && allLink() ? [...order, ALL] : order;
|
|
1467
|
+
// A light-DOM sheet: the options its markup shows (the open tab's, drawn alone or the others hidden by
|
|
1468
|
+
// its CSS; all of them without tabs), read in response to the key. Review: a template without tabs had
|
|
1469
|
+
// its other types' options out of the arrows' reach (the house sheet's open-tab order).
|
|
1470
|
+
if (LIGHT_FULL_SCREEN && light && sheet) stops = stops.filter((i) => rowOf(i)?.getClientRects().length);
|
|
1423
1471
|
const n = stops.length + 1; // positions: -1 (back in input) .. stops.length-1
|
|
1424
1472
|
const at = stops.indexOf(active);
|
|
1425
1473
|
const next = ((at + by + 1 + n) % n) - 1;
|
|
@@ -1539,6 +1587,11 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1539
1587
|
};
|
|
1540
1588
|
|
|
1541
1589
|
const onFocusIn = once<'focusin'>((_, target) => {
|
|
1590
|
+
// Focus gone from the open sheet to the page it covers (Tab past its last
|
|
1591
|
+
// control, Shift+Tab from its first, iOS's next-field arrow): closed, the
|
|
1592
|
+
// focus seen. Review: Tab left it on a hidden control, Escape not heard.
|
|
1593
|
+
// Not the site's input (the tap's hand-off), nor <body> (the keyboard put away).
|
|
1594
|
+
if (sheet && open && target instanceof Element && target !== input && !inOverlay(target)) return leave('blur');
|
|
1542
1595
|
if (!matches(target) || quietFocus) return;
|
|
1543
1596
|
engage(target);
|
|
1544
1597
|
});
|
|
@@ -27,7 +27,7 @@ export const showAll = (s: AutocompleteScope) =>
|
|
|
27
27
|
: html`<button type="button" class="all" data-lbx-part="ac-all" data-lbx-all @click=${() => s.submitAll()}>${s.t('showAllProducts')}${ARROW}</button>`;
|
|
28
28
|
|
|
29
29
|
export default (s: AutocompleteScope) => {
|
|
30
|
-
if (s.
|
|
30
|
+
if (s.fullScreen) return s.include('autocomplete-sheet');
|
|
31
31
|
const opt = (row: { r: any; i: number }, as: string) => s.include('autocomplete-result-default', { r: row.r, i: row.i, as });
|
|
32
32
|
const key = (row: { r: any; i: number }) => `${row.i}:${row.r.id}`;
|
|
33
33
|
const list = s.phrases.length > 0 || s.lists.length > 0;
|
|
@@ -16,7 +16,7 @@ export default (s: AutocompleteScope) => {
|
|
|
16
16
|
const opt = (row: { r: any; i: number }, as: string) => s.include('autocomplete-result-default', { r: row.r, i: row.i, as });
|
|
17
17
|
const helpBox = help
|
|
18
18
|
? html`<div class="help">
|
|
19
|
-
${s.
|
|
19
|
+
${s.fullScreen
|
|
20
20
|
? html`<span class="help-text"><span class="help-title">${help.title}</span>${help.hours ? html`<span class="help-hours">${help.hours}</span>` : nothing}</span>
|
|
21
21
|
<a class="call" href=${`tel:${help.tel}`}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="2.5"></rect><path d="M11 18h2"></path></svg>${s.t('call')}<span class="sr"> ${help.phone}</span></a>`
|
|
22
22
|
: html`<span class="phone-ico" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2" width="12" height="20" rx="2.5"></rect><path d="M11 18h2"></path></svg></span>
|
|
@@ -43,9 +43,9 @@ export default (s: AutocompleteScope) => {
|
|
|
43
43
|
</div>
|
|
44
44
|
${fallback.length
|
|
45
45
|
? html`<div class="strip">
|
|
46
|
-
<div class="strip-head"><span class="note-wrap"><span class="f-title" id="lbx-ac-g-fallback">${s.t('fallbackTitle')}</span><span class="f-note" id="lbx-ac-g-fallback-note">${s.t('fallbackNote')}</span></span>${s.
|
|
46
|
+
<div class="strip-head"><span class="note-wrap"><span class="f-title" id="lbx-ac-g-fallback">${s.t('fallbackTitle')}</span><span class="f-note" id="lbx-ac-g-fallback-note">${s.t('fallbackNote')}</span></span>${s.fullScreen ? nothing : POWERED(s.t)}</div>
|
|
47
47
|
<div class="fb" role="listbox" id="lbx-ac-fallback" aria-labelledby="lbx-ac-g-fallback lbx-ac-g-fallback-note">${fallback.map((x) => opt(x, 'compact'))}</div>
|
|
48
|
-
${s.
|
|
48
|
+
${s.fullScreen ? POWERED(s.t) : nothing}
|
|
49
49
|
</div>`
|
|
50
50
|
: html`<div class="e-foot">${POWERED(s.t)}</div>`}
|
|
51
51
|
</div>
|
|
@@ -38,7 +38,7 @@ export default (s: AutocompleteScope) => {
|
|
|
38
38
|
<button type="button" class="icon back" aria-label=${s.t('closeSearch')} @click=${() => s.back()}>
|
|
39
39
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6"></path></svg>
|
|
40
40
|
</button>
|
|
41
|
-
<input class="sh-input" data-lbx-
|
|
41
|
+
<input class="sh-input" data-lbx-full-screen-input type="text" enterkeyhint="search" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
|
|
42
42
|
role="combobox" aria-autocomplete="list" aria-expanded=${controls ? 'true' : 'false'} aria-controls=${controls || nothing}
|
|
43
43
|
aria-label=${s.inputLabel} placeholder=${s.placeholder} .value=${s.value} />
|
|
44
44
|
${s.value
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { borrow, liveRegion, useContrast } from '../../core/a11y';
|
|
17
17
|
import { activateByKey, byKeyboard, focusables, trapTab } from '../../core/a11y-keys';
|
|
18
|
-
import { CONTENT_VISIBILITY, DRAWER, DROPDOWN, MENU, NAMED_FADE, NAMED_PRECONNECT_IMAGES, NAMED_PREFETCH_FILTERS, NAMED_PREFETCH_NEXT_PAGE, NAMED_PRELOAD, NAMED_RESTORE_ON_BACK, ON_RENDER, SEARCH, SSR } from '../../core/features';
|
|
18
|
+
import { CONTENT_VISIBILITY, DRAWER, DROPDOWN, MENU, NAMED_FADE, NAMED_PRECONNECT_IMAGES, NAMED_PREFETCH_FILTERS, NAMED_PREFETCH_NEXT_PAGE, NAMED_PRELOAD, NAMED_RESTORE_ON_BACK, ON_RENDER, SEARCH, SSR, VARIANTS } from '../../core/features';
|
|
19
19
|
import { mark, reportError } from '../../core/health';
|
|
20
20
|
import { claim } from '../../core/reconciler';
|
|
21
21
|
import { afterLayout, rememberHeight } from '../../core/hold';
|
|
@@ -38,11 +38,12 @@ import { specKey } from '../../core/stash';
|
|
|
38
38
|
import { firstRequest, hasMore, listingMore, listingQuery, type ListingConfig, type ListingResult } from './query';
|
|
39
39
|
import { flowKey, notAbove, onQuickLanded, quickChannel } from './flow';
|
|
40
40
|
import { firstPages, SETTLE_MS, targetOf, thrifty, watchIntent } from './intent';
|
|
41
|
-
import type { Page } from '../../api';
|
|
42
|
-
import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, viewport, type ListingView, type SsrState } from './view';
|
|
41
|
+
import type { Page, VariantGroup } from '../../api';
|
|
42
|
+
import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, withGroups, viewport, type ListingView, type SsrState } from './view';
|
|
43
43
|
import { dropStash, peekStash, readStash, removeStash, writeStash, type Stash } from './stash';
|
|
44
44
|
import { adoptScroll, holdScroll, keepViewport, leavingLink, nearEnd, preconnectImages, releaseScroll, restoreScroll, scrollSpot } from './instant';
|
|
45
45
|
import { watch } from '../../core/tiles';
|
|
46
|
+
import { askVariants, groupsOf, seedGroups, stashGroups } from './variants';
|
|
46
47
|
|
|
47
48
|
/** A re-render answered within DIM_MS (no dim shown) fades its list in from this opacity, over FADE_MS. */
|
|
48
49
|
const FADE_FROM = 0.6;
|
|
@@ -108,7 +109,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
108
109
|
useContrast();
|
|
109
110
|
const query = (q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void) => {
|
|
110
111
|
const t0 = performance.now();
|
|
111
|
-
|
|
112
|
+
// `variants`: the first page's groups asked for as soon as it is here, with its facets' request.
|
|
113
|
+
const p = listingQuery(ctx.api, ctx.global, cfg, route, q, signal, pre, onProducts, SEARCH ? quickChannel(route.id) : undefined, VARIANTS && cfg.variants ? (f) => void askVariants(ctx.api, cfg, f, signal) : undefined);
|
|
112
114
|
// A whole listing (not a first page fetched already): how long a fresh one takes here.
|
|
113
115
|
if (!pre) p.then((r) => !r.partial && observe(performance.now() - t0), () => {});
|
|
114
116
|
return p;
|
|
@@ -233,6 +235,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
233
235
|
else if (stashed) {
|
|
234
236
|
seeded.set(pageKey(location.href), stashed.query);
|
|
235
237
|
cache.set(keyOf(stashed.query), stashed.result);
|
|
238
|
+
// `variants`: the groups it was left with, drawn with it (nothing asked).
|
|
239
|
+
if (VARIANTS) seedGroups(stashed.result, stashed.groups);
|
|
236
240
|
mark(`${route.id} from stash (${stashed.kind}, ${stashed.result.hits.length} hits)`);
|
|
237
241
|
// A prerendered page reads (and drops) it in a copy of sessionStorage; at
|
|
238
242
|
// its activation the opener's storage, still holding it, comes back: used once.
|
|
@@ -1187,7 +1191,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1187
1191
|
this.leftAt = Math.round(scrollY);
|
|
1188
1192
|
// The browser's own restoration off for this entry: Luma places the page after its render (holdScroll).
|
|
1189
1193
|
const sr = holdScroll();
|
|
1190
|
-
if (writeStash(location.href, { kind: 'back', route: route.id, query: this.lastQ, result: { ...this.last, quickPending: undefined }, scroll: scrollSpot(this, a), sr })) mark(`${route.id} stashed for back`);
|
|
1194
|
+
if (writeStash(location.href, { kind: 'back', route: route.id, query: this.lastQ, result: { ...this.last, quickPending: undefined }, scroll: scrollSpot(this, a), sr, groups: VARIANTS && cfg.variants ? stashGroups(this.last) : undefined })) mark(`${route.id} stashed for back`);
|
|
1191
1195
|
else releaseScroll();
|
|
1192
1196
|
}
|
|
1193
1197
|
|
|
@@ -1414,6 +1418,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1414
1418
|
if (res !== this.last || q !== this.lastQ || !hasMore(res) || nextOf.has(res)) return;
|
|
1415
1419
|
mark(`${route.id} prefetch page ${res.page + 1}`);
|
|
1416
1420
|
more(q, res).catch(() => {}); // a failed one: the click asks again
|
|
1421
|
+
// `variants`: its products' groups too (more() asks once), so "show more" draws them with its tiles.
|
|
1422
|
+
if (VARIANTS && cfg.variants) more(q, res).then((r) => askVariants(ctx.api, cfg, r, this.ctrl?.signal), () => {});
|
|
1417
1423
|
}
|
|
1418
1424
|
|
|
1419
1425
|
private render(q: Query, res: ListingResult, url: URL) {
|
|
@@ -1579,7 +1585,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1579
1585
|
const self = this;
|
|
1580
1586
|
return {
|
|
1581
1587
|
...ctx.helpers,
|
|
1582
|
-
...listingData(ctx, route, url, q, res, this.message, this.view()),
|
|
1588
|
+
...listingData(ctx, route, url, q, res, this.message, VARIANTS && cfg.variants ? { ...this.view(), variants: (this.groups$?.res === res ? this.groups$ : (this.groups$ = { res, map: groupsOf(res) })).map } : this.view()),
|
|
1583
1589
|
facetsOpen: matchMedia('(min-width: 768px)').matches,
|
|
1584
1590
|
// actions
|
|
1585
1591
|
go: (link: Link | null) => (e: Event) => {
|
|
@@ -1628,6 +1634,9 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1628
1634
|
}
|
|
1629
1635
|
|
|
1630
1636
|
private draw(q: Query, res: ListingResult, url: URL) {
|
|
1637
|
+
// `variants`: the groups of these products, asked for now (those not
|
|
1638
|
+
// asked yet), never waited for: finish() draws them when they land.
|
|
1639
|
+
if (VARIANTS && cfg.variants) askVariants(ctx.api, cfg, res, this.ctrl?.signal);
|
|
1631
1640
|
// A first render in steps of these same products (shown before their
|
|
1632
1641
|
// facets): it goes on with the facets, not a render of them all at once.
|
|
1633
1642
|
if (this.stepping?.hits === res.hits) return this.stepping.take(q, res, url);
|
|
@@ -1961,6 +1970,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1961
1970
|
}
|
|
1962
1971
|
/** Tiles in the viewport (an IntersectionObserver), whether the list's last one is above it, the grid's columns (read with layout clean). */
|
|
1963
1972
|
private seen = new Set<Element>();
|
|
1973
|
+
/** `variants`: the groups the last scope of a result was built with (its map grows in steps as they land: finish()). */
|
|
1974
|
+
declare private groups$: { res: ListingResult; map?: ReadonlyMap<string, VariantGroup> } | undefined;
|
|
1964
1975
|
private past = false;
|
|
1965
1976
|
private cols = 1;
|
|
1966
1977
|
private io: IntersectionObserver | null = null;
|
|
@@ -1992,6 +2003,52 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1992
2003
|
this.drawn$ = { hits: res.hits, n: res.hits.length };
|
|
1993
2004
|
this.lastQ = q;
|
|
1994
2005
|
this.afterA11y();
|
|
2006
|
+
// `variants`: drawn again once the groups of these products asked for
|
|
2007
|
+
// have landed (now: during a render in steps), if they are still shown
|
|
2008
|
+
// (a later render of other products draws its own).
|
|
2009
|
+
if (VARIANTS && cfg.variants)
|
|
2010
|
+
askVariants(ctx.api, cfg, res, this.ctrl?.signal).then(() => {
|
|
2011
|
+
const got = groupsOf(res);
|
|
2012
|
+
const had = this.groups$?.map;
|
|
2013
|
+
if (!got || had === got) return;
|
|
2014
|
+
// In steps, as a shown listing's re-render (each tile whose group
|
|
2015
|
+
// landed runs its template again): the tiles in view first, then
|
|
2016
|
+
// as many a frame as BUDGET holds by what one cost.
|
|
2017
|
+
const tiles = this.querySelector('[data-lbx-list]')?.children;
|
|
2018
|
+
const ids = res.hits.map((h, i) => [tiles && this.seen.has(tiles[i]) ? 0 : 1, h['@id']] as const).filter(([, id]) => got.has(id) && had?.get(id) !== got.get(id)).sort((a, b) => a[0] - b[0]).map(([, id]) => id);
|
|
2019
|
+
const N = res.hits.length;
|
|
2020
|
+
let n = 0;
|
|
2021
|
+
let per = this.tileMs || 3;
|
|
2022
|
+
let frames = 0;
|
|
2023
|
+
const step = () => {
|
|
2024
|
+
if (this.last !== res || this.drawn$?.hits !== res.hits || this.groups$?.res !== res || !this.drawn) return;
|
|
2025
|
+
// STEP tiles first, to measure one (a tile drawn anew with its group
|
|
2026
|
+
// costs what a new tile does: the tiles' cost learnt from a render
|
|
2027
|
+
// in steps was 0.3 ms for 2 ms tiles, one step of all of them); then
|
|
2028
|
+
// as a re-render in steps: as many as BUDGET holds with the tiles
|
|
2029
|
+
// there run again, not even one, a third of the rest.
|
|
2030
|
+
const room = Math.floor((BUDGET - RERUN * per * N) / per);
|
|
2031
|
+
const k = Math.min(ids.length - n, !frames ? STEP : room >= 1 ? room : Math.ceil((ids.length - n) / 3));
|
|
2032
|
+
const map = n + k >= ids.length ? got : new Map([...(this.groups$.map ?? []), ...ids.slice(n, n + k).map((id) => [id, got.get(id)!] as const)]);
|
|
2033
|
+
n += k;
|
|
2034
|
+
this.groups$ = { res, map };
|
|
2035
|
+
// A tile's template may draw other markup with its group: focus in one replaced goes to the region, not the page's start.
|
|
2036
|
+
const at = document.activeElement;
|
|
2037
|
+
this.focusWas = at && at !== this && this.contains(at) ? at : null;
|
|
2038
|
+
const t0 = performance.now();
|
|
2039
|
+
// Only the results change: the scope is not built again (as setUi). A
|
|
2040
|
+
// template throwing on the groups hands the page back, as a render does, and no step follows.
|
|
2041
|
+
this.guarded(() => {
|
|
2042
|
+
this.renderOrRebuild((this.drawn = { ...this.drawn!, results: res.hits.map(withGroups(ctx.result, map)) }));
|
|
2043
|
+
this.afterA11y();
|
|
2044
|
+
per = Math.max(0.5, (performance.now() - t0) / (k + RERUN * (N - k)));
|
|
2045
|
+
frames++;
|
|
2046
|
+
if (n < ids.length) afterFrame(step);
|
|
2047
|
+
else mark(`${route.id} variants in ${frames} frames`);
|
|
2048
|
+
});
|
|
2049
|
+
};
|
|
2050
|
+
step();
|
|
2051
|
+
});
|
|
1995
2052
|
const info = { route: route.id, query: q.title, total: res.total, shown: res.hits.length, page: res.page, url };
|
|
1996
2053
|
// A prerendered page (a search typed on the page before) is not seen
|
|
1997
2054
|
// yet: the hook (titles, and often the site's analytics) runs when it
|
|
@@ -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
|
};
|