@luigisbox/luma 0.5.0 → 0.6.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 +34 -0
- package/README.md +3 -3
- package/build/vite.mjs +2 -0
- package/docs/ACCESSIBILITY.md +22 -3
- package/package.json +1 -1
- package/src/locales/cs.json +1 -0
- package/src/locales/de.json +1 -0
- package/src/locales/en.json +1 -0
- package/src/types.ts +19 -5
- package/src/widgets/autocomplete/index.ts +109 -15
- package/src/widgets/autocomplete/style-autocomplete.css +16 -8
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +1 -1
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +13 -9
- package/src/widgets/listing/applied.ts +87 -0
- package/src/widgets/listing/element.ts +198 -20
- package/src/widgets/listing/intent.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,40 @@ before 1.0, a minor version may break the integration API.
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.6.0 — 2026-10-20
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Phone sheet: a swipe through the results puts the keyboard away. The sheet
|
|
13
|
+
stays open and grows to the full screen. A tap on its input brings the
|
|
14
|
+
keyboard back.
|
|
15
|
+
- Phone sheet: no tab bar when only one type has results.
|
|
16
|
+
- Phone sheet: "Show all products" is at the end of the list, no longer a bar
|
|
17
|
+
pinned above the keyboard.
|
|
18
|
+
- Phone sheet: the list (and the no-results column) runs on behind Safari's
|
|
19
|
+
floating bars and the keyboard, down to the bottom of the screen. Its end
|
|
20
|
+
scrolls clear of them.
|
|
21
|
+
- Staged filter drawer (`facetsApply: 'staged'`): the held selection is
|
|
22
|
+
fetched in the background once the changes pause, so the apply button
|
|
23
|
+
shows the results without waiting. The results still change only on
|
|
24
|
+
apply. Off with `_global.prefetchFilters: false`, Save-Data or 2G.
|
|
25
|
+
- Staged filter drawer: apply marks the swap. The old list fades out, the
|
|
26
|
+
new products fade in, and a status at the top of the listing says
|
|
27
|
+
"Filters applied · 9 products" for 2.5 s. Translation key
|
|
28
|
+
`search.applied`; CSS variables `--lbx-applied-bg`, `--lbx-applied-fg`,
|
|
29
|
+
`--lbx-applied-top`, `--lbx-applied-offset`, or
|
|
30
|
+
`[data-lbx-applied-cue]::part(pill)`;
|
|
31
|
+
`data-lbx-applied` on the listing meanwhile.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Autocomplete "No results": `emptyState` phrases and browse links keep their
|
|
36
|
+
config text and URL when `computedAttributes` override `title` or `url`.
|
|
37
|
+
Before, a browse link could have no URL, and a tap on it did nothing.
|
|
38
|
+
- Autocomplete, phone sheet, no results: each "browse" link is one row again,
|
|
39
|
+
its chevron at the right, not on a line under the title. The links' wrapper
|
|
40
|
+
is now `.e-links` (was `.browse`, the class each link also has).
|
|
41
|
+
|
|
8
42
|
## 0.5.0 — 2026-10-19
|
|
9
43
|
|
|
10
44
|
Upgrading from 0.4.0: read **Changed** first.
|
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.7.0) may change the
|
|
14
14
|
> integration API (config keys, template scope, the build); a patch version
|
|
15
|
-
> (0.
|
|
15
|
+
> (0.6.1) only fixes. Depend on `^0.6.0` to take fixes without breaking
|
|
16
16
|
> changes. [`CHANGELOG.md`](CHANGELOG.md) says what each version changed.
|
|
17
17
|
|
|
18
18
|
## Core principles
|
|
@@ -422,7 +422,7 @@ it sets them; a switch on by default that no file names is built as on.
|
|
|
422
422
|
| `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
423
|
| `prefetchNextPage` | "show more" is fetched as the shopper nears the end | |
|
|
424
424
|
| `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 |
|
|
425
|
-
| `_global.prefetchFilters` | a filter, sort or chip is fetched on hover, touch or focus | |
|
|
425
|
+
| `_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
426
|
| `_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
427
|
|
|
428
428
|
### Analytics
|
package/build/vite.mjs
CHANGED
|
@@ -91,6 +91,8 @@ export const chunkOf = (id) => {
|
|
|
91
91
|
if (/\/src\/core\/(reconciler|recommenders)\.ts$/.test(id)) return 'lbx-place';
|
|
92
92
|
// The listing's high-contrast toggle: only a site that turns it on loads it (widgets/listing/element.ts).
|
|
93
93
|
if (/\/src\/core\/contrast-toggle\.ts$/.test(id)) return 'lbx-contrast';
|
|
94
|
+
// An Apply's cue in a staged drawer: asked for when such a drawer opens (widgets/listing/element.ts).
|
|
95
|
+
if (/\/src\/widgets\/listing\/applied\.ts$/.test(id)) return 'lbx-applied';
|
|
94
96
|
// The search page's own: not in lbx-listing, which a category page loads too
|
|
95
97
|
// (2.1 kB gzip it never ran).
|
|
96
98
|
if (/\/src\/widgets\/search\/index\.ts$/.test(id)) return 'lbx-search';
|
package/docs/ACCESSIBILITY.md
CHANGED
|
@@ -134,7 +134,11 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
134
134
|
`aria-activedescendant` the selected option: the same shadow root); the
|
|
135
135
|
site's input is left as it was. The back button is named "Close search",
|
|
136
136
|
the clear button "Clear"; the type tabs are a `tablist` (arrow keys move
|
|
137
|
-
between them)
|
|
137
|
+
between them), shown for two types or more (one type: no tabs, its
|
|
138
|
+
listbox named by the type, "Products"); a finger's vertical swipe
|
|
139
|
+
through the list puts the on-screen keyboard away: focus moves from the
|
|
140
|
+
input to the sheet (`tabindex="-1"`), Escape still closes it, a tap on the
|
|
141
|
+
input brings the keyboard back; Tab is the browser's; targets are 44 px; Escape and the
|
|
138
142
|
browser's Back close it.
|
|
139
143
|
- **Forced colours**: the selection in `Highlight`/`HighlightText` with an
|
|
140
144
|
outline, the panel (and the sheet) keep a `CanvasText` border, pills,
|
|
@@ -180,6 +184,18 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
180
184
|
focus staying on the control. With `facetsApply: 'staged'` its
|
|
181
185
|
changes wait for `[data-lbx-drawer="apply"]`; closing it otherwise discards
|
|
182
186
|
them (the scope's `pending` says whether there is anything to apply).
|
|
187
|
+
Apply's result is said in Luma's status region as "Filters applied" and
|
|
188
|
+
the count (translation `search.applied`, WCAG 4.1.3), and shown for 2.5 s
|
|
189
|
+
in a pill at the listing's top with the same words (hidden from assistive
|
|
190
|
+
technology: it repeats the status region; white on dark, at least 10:1
|
|
191
|
+
over any page behind it; opaque in the high-contrast mode and in forced
|
|
192
|
+
colours, with a border there; taps go through it; it wraps rather than
|
|
193
|
+
cuts its words at 320 px or 200% text, and follows a larger default font
|
|
194
|
+
size; it moves no content). While the applied results load, the
|
|
195
|
+
old list is dimmed as a busy one is: not at all in the high-contrast
|
|
196
|
+
mode, to 60% in forced colours. The new tiles fade in; they also rise
|
|
197
|
+
unless reduced motion is asked for. The pill may overlap the top of the
|
|
198
|
+
drawer's opener, where focus returns, for its 2.5 s; it never hides it.
|
|
183
199
|
The build includes the drawer only where a file of the integration's
|
|
184
200
|
repository (a script or `.html` file, not in `node_modules`, `dist` or a hidden folder) has `data-lbx-drawer` (written
|
|
185
201
|
literally, as in a template).
|
|
@@ -213,7 +229,8 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
213
229
|
- Built-in templates: the pager is a native "Show more" button with a
|
|
214
230
|
"Showing N of M" line (no numbered pages); tile pictures are decorative
|
|
215
231
|
(`alt=""`: the link reads as its title and price once); targets of at least
|
|
216
|
-
24 px; no fade under reduced motion
|
|
232
|
+
24 px; no fade under reduced motion (except a staged drawer's Apply: its
|
|
233
|
+
new tiles fade in, without rising); tiles reflow to one column at 320 px.
|
|
217
234
|
|
|
218
235
|
### Recommenders (`widgets/recommender/`)
|
|
219
236
|
|
|
@@ -232,7 +249,9 @@ guessed alt text, and forced colours and the OS setting are honoured.
|
|
|
232
249
|
reset cannot remove it there).
|
|
233
250
|
- **Reduced motion**: the hold's loading bar stands still, the listing's
|
|
234
251
|
dim while a re-render loads comes without its fade and the new results
|
|
235
|
-
without their fade-in, no smooth scrolling of ours.
|
|
252
|
+
without their fade-in, no smooth scrolling of ours. One exception: after
|
|
253
|
+
a staged drawer's Apply the new tiles still fade in (opacity only, nothing
|
|
254
|
+
moves), so the swap the shopper confirmed is seen.
|
|
236
255
|
- `.lbx-sr-only`: a class for text that only screen readers read (e.g. "Was"
|
|
237
256
|
before an old price), defined for every widget and in server-rendered CSS.
|
|
238
257
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luigisbox/luma",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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/locales/cs.json
CHANGED
package/src/locales/de.json
CHANGED
package/src/locales/en.json
CHANGED
package/src/types.ts
CHANGED
|
@@ -80,7 +80,9 @@ export interface GlobalConfigType {
|
|
|
80
80
|
* Two in flight, the latest intent wins; 20 per category or query; none
|
|
81
81
|
* offline, with Save-Data or on 2G; range sliders never. A hover that is
|
|
82
82
|
* not followed costs one search request (no render, no hook, no analytics
|
|
83
|
-
* event).
|
|
83
|
+
* event). In a drawer that stages (`facetsApply`), the selection it holds
|
|
84
|
+
* is fetched the same way once the changes pause, for its apply button.
|
|
85
|
+
* Default true.
|
|
84
86
|
*/
|
|
85
87
|
prefetchFilters?: boolean;
|
|
86
88
|
/**
|
|
@@ -333,10 +335,22 @@ export interface ListingConfigType {
|
|
|
333
335
|
* How a change inside the open filter drawer (`[data-lbx-drawer-panel]`,
|
|
334
336
|
* opened by `[data-lbx-drawer="open"]`) applies. 'instant' (default): at
|
|
335
337
|
* once, as in a sidebar. 'staged': held in the drawer (it shows the pending
|
|
336
|
-
* selection, the scope's `pending` is true,
|
|
337
|
-
* apply button (`[data-lbx-drawer="apply"]`) commits them
|
|
338
|
-
* and one history entry; closing it otherwise
|
|
339
|
-
* Escape) discards them.
|
|
338
|
+
* selection, the scope's `pending` is true, the results behind it do not
|
|
339
|
+
* change) until its apply button (`[data-lbx-drawer="apply"]`) commits them
|
|
340
|
+
* in one request and one history entry; closing it otherwise
|
|
341
|
+
* (`[data-lbx-drawer="close"]`, Escape) discards them. Once the changes
|
|
342
|
+
* pause, the held selection's results are fetched in the background
|
|
343
|
+
* (unless `_global.prefetchFilters` is false, with Save-Data or on 2G),
|
|
344
|
+
* so Apply shows them without waiting; nothing is shown and `onRender`
|
|
345
|
+
* does not run before Apply. Apply's swap is marked: the old list fades
|
|
346
|
+
* out, the new tiles come in, and a status at the listing's top says what
|
|
347
|
+
* was applied ("Filters applied · 9 products", translation
|
|
348
|
+
* `search.applied`) for 2.5 s, in `[data-lbx-applied-cue]`'s shadow root
|
|
349
|
+
* (out of reach of the site's resets; themed with `--lbx-applied-bg`,
|
|
350
|
+
* `--lbx-applied-fg`, `--lbx-applied-top` for a sticky header's height,
|
|
351
|
+
* `--lbx-applied-offset` to sit below a heading at the listing's top, or
|
|
352
|
+
* restyled with `[data-lbx-applied-cue]::part(pill)`), with
|
|
353
|
+
* `data-lbx-applied` on the listing meanwhile for the site's own CSS. Per viewport min-width, e.g. `{ "0": "staged",
|
|
340
354
|
* "768": "instant" }` (staged on phones). Outside an open drawer every
|
|
341
355
|
* change is instant.
|
|
342
356
|
*/
|
|
@@ -66,6 +66,12 @@ const GRID_MAX = 6;
|
|
|
66
66
|
export const SHEET_MEDIA = '(max-width: 600px), (hover: none) and (max-height: 500px)';
|
|
67
67
|
/** The sheet's visible height below which its top product is drawn smaller (the keyboard up): more of the list shows. */
|
|
68
68
|
const SHEET_SHORT = 480;
|
|
69
|
+
/** How far (px, vertically) a finger moves in the sheet's list before it is a swipe, not a tap: the keyboard goes away. */
|
|
70
|
+
const SWIPE = 10;
|
|
71
|
+
/** A move this far (px), then the browser taking the gesture over (pointercancel), is a swipe too; less is a finger's jitter. */
|
|
72
|
+
const SWIPE_START = 4;
|
|
73
|
+
/** How long (ms) after the browser took a finger's gesture over a scroll of the list is still that finger's. */
|
|
74
|
+
const SWIPE_SCROLL_MS = 200;
|
|
69
75
|
|
|
70
76
|
/** A computed colour that paints nothing (`rgba(0, 0, 0, 0)`, `transparent`, `color(srgb 0 0 0 / 0)`). */
|
|
71
77
|
const clear = (c: string) => !c || c === 'transparent' || /(?:,|\/)\s*0(?:\.0*)?\)$/.test(c);
|
|
@@ -257,7 +263,12 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
257
263
|
let want = '';
|
|
258
264
|
/** Scrolled lists in the dropdown (the panel's columns, the sheet's list): back to their top for a new result set. */
|
|
259
265
|
const scrolled = new Set<Element>();
|
|
266
|
+
/** A finger down in the sheet's list (a swipe through it puts the keyboard away: ensureHost); `ours`: the list's next scroll is our reset. */
|
|
267
|
+
let swipe: { list: Element; x: number; y: number; moved: boolean; cancelled?: number; ours?: boolean } | null = null;
|
|
260
268
|
const toTop = () => {
|
|
269
|
+
// Our scroll, not the finger's: a finger at rest on the list is no swipe, the same finger's drag
|
|
270
|
+
// after it still is. Review (PR 26): a new result set's reset put the keyboard away.
|
|
271
|
+
if (swipe && scrolled.has(swipe.list)) swipe.ours = true;
|
|
261
272
|
for (const el of scrolled) if (el.isConnected) el.scrollTop = 0;
|
|
262
273
|
scrolled.clear();
|
|
263
274
|
};
|
|
@@ -363,8 +374,9 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
363
374
|
if (!inOverlay(el) || e.ctrlKey || e.metaKey) return;
|
|
364
375
|
if (sheet) {
|
|
365
376
|
if (e.key === 'Escape') {
|
|
366
|
-
|
|
367
|
-
|
|
377
|
+
// A whole gesture, as from the input: focus is on the sheet after a swipe (or on its buttons),
|
|
378
|
+
// and its keyup reached the host's handlers. Review (PR 26).
|
|
379
|
+
consume(e);
|
|
368
380
|
leave('back');
|
|
369
381
|
} else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && el.getAttribute?.('role') === 'tab') {
|
|
370
382
|
e.preventDefault();
|
|
@@ -400,6 +412,61 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
400
412
|
if (!(el instanceof Element)) return;
|
|
401
413
|
if (el.scrollTop > 0) scrolled.add(el);
|
|
402
414
|
else scrolled.delete(el);
|
|
415
|
+
if (swipe?.list === el && swipe.ours) swipe.ours = false;
|
|
416
|
+
else if (swipe?.list === el && (!swipe.cancelled || performance.now() - swipe.cancelled < SWIPE_SCROLL_MS)) putKeyboardAway();
|
|
417
|
+
},
|
|
418
|
+
{ capture: true, passive: true },
|
|
419
|
+
);
|
|
420
|
+
// A swipe through the sheet's list (or its no-results column) puts the
|
|
421
|
+
// keyboard away, the sheet stays: it grows to the whole screen (position),
|
|
422
|
+
// and a tap on its input brings the keyboard back. Only a drag: a tap on a
|
|
423
|
+
// row, a tab or "Show all" keeps the keyboard and is not swallowed; the
|
|
424
|
+
// pill band's sideways drag is not the list. Focus goes to the sheet
|
|
425
|
+
// (tabindex -1), not <body>: Escape and Tab still work from a hardware
|
|
426
|
+
// keyboard. Bug (iOS 26 Safari): with the keyboard up, its bar and the
|
|
427
|
+
// address bar above it, the sheet kept half the screen.
|
|
428
|
+
const putKeyboardAway = () => {
|
|
429
|
+
swipe = null;
|
|
430
|
+
if (!mirror || focused() !== mirror) return;
|
|
431
|
+
const panel = mirror.closest<HTMLElement>('.sheet[tabindex]');
|
|
432
|
+
if (panel) panel.focus({ preventScroll: true });
|
|
433
|
+
else mirror.blur(); // an integration's own sheet template
|
|
434
|
+
};
|
|
435
|
+
const finger = (e: PointerEvent) => e.pointerType === 'touch' && e.isPrimary;
|
|
436
|
+
box.addEventListener(
|
|
437
|
+
'pointerdown',
|
|
438
|
+
(ev) => {
|
|
439
|
+
const e = ev as PointerEvent;
|
|
440
|
+
swipe = null;
|
|
441
|
+
if (!sheet || !finger(e)) return;
|
|
442
|
+
const list = e.composedPath().find((x) => x instanceof Element && x.matches('.sh-list, .sheet .empty')) as Element | undefined;
|
|
443
|
+
if (list) swipe = { list, x: e.clientX, y: e.clientY, moved: false };
|
|
444
|
+
},
|
|
445
|
+
{ capture: true, passive: true },
|
|
446
|
+
);
|
|
447
|
+
box.addEventListener(
|
|
448
|
+
'pointermove',
|
|
449
|
+
(ev) => {
|
|
450
|
+
const e = ev as PointerEvent;
|
|
451
|
+
if (!swipe || !finger(e)) return;
|
|
452
|
+
const dy = Math.abs(e.clientY - swipe.y);
|
|
453
|
+
if (dy <= Math.abs(e.clientX - swipe.x)) return;
|
|
454
|
+
if (dy > SWIPE) putKeyboardAway();
|
|
455
|
+
else if (dy >= SWIPE_START) swipe.moved = true;
|
|
456
|
+
},
|
|
457
|
+
{ capture: true, passive: true },
|
|
458
|
+
);
|
|
459
|
+
box.addEventListener('pointerup', (e) => finger(e as PointerEvent) && (swipe = null), { capture: true, passive: true });
|
|
460
|
+
// The browser took the gesture over to scroll: after a first move (iOS
|
|
461
|
+
// Safari: a pull at the list's top, 10 px, then pointercancel), a swipe;
|
|
462
|
+
// without one, a swipe if the list scrolls right after (a flick), not a
|
|
463
|
+
// long press (a link's preview: no scroll; a later scroll is ours, toTop).
|
|
464
|
+
box.addEventListener(
|
|
465
|
+
'pointercancel',
|
|
466
|
+
(e) => {
|
|
467
|
+
if (!swipe || !finger(e as PointerEvent)) return;
|
|
468
|
+
if (swipe.moved) putKeyboardAway();
|
|
469
|
+
else swipe.cancelled = performance.now();
|
|
403
470
|
},
|
|
404
471
|
{ capture: true, passive: true },
|
|
405
472
|
);
|
|
@@ -465,24 +532,44 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
465
532
|
* before this task's writes (an opening: read after showing the popover,
|
|
466
533
|
* it forced a layout).
|
|
467
534
|
*/
|
|
535
|
+
/** What Safari's bars and the keyboard cover of the sheet (px, --lbx-cover), -1 while it is not open. */
|
|
536
|
+
let cover = -1;
|
|
537
|
+
/** A clamp of the scrolled lists is queued (one for a keyboard going down, not one per frame of it). */
|
|
538
|
+
let clamping = false;
|
|
468
539
|
const position = (frame = false, m?: ReturnType<typeof measure>) => {
|
|
469
540
|
if (!open || !input) return;
|
|
470
541
|
const s = host!.style;
|
|
471
542
|
if (sheet) {
|
|
472
|
-
// Full screen
|
|
473
|
-
// viewport
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
543
|
+
// Full screen from the top of what shows of the page (the visual
|
|
544
|
+
// viewport) down to the layout viewport's bottom: iOS 26 Safari ends
|
|
545
|
+
// the visual viewport above its floating bars (the address pill, with
|
|
546
|
+
// the keyboard up its form bar too), and the list runs on behind them,
|
|
547
|
+
// seen through their glass, as the legacy autocomplete's does. What
|
|
548
|
+
// they and the keyboard cover (--lbx-cover) ends the list as space, so
|
|
549
|
+
// its last row and "Show all" scroll clear of them. Bug (iOS 26/27):
|
|
550
|
+
// the list stopped above the bars, a blank strip down to the keys.
|
|
478
551
|
const vv = window.visualViewport;
|
|
479
552
|
const top = Math.round(vv?.offsetTop ?? 0);
|
|
480
553
|
const h = Math.round(vv?.height ?? innerHeight);
|
|
554
|
+
const full = Math.max(h, innerHeight - top);
|
|
481
555
|
s.top = `${top}px`;
|
|
482
556
|
s.left = `${Math.round(vv?.offsetLeft ?? 0)}px`;
|
|
483
557
|
s.width = `${Math.round(vv?.width ?? innerWidth)}px`;
|
|
484
|
-
s.height = `${
|
|
485
|
-
|
|
558
|
+
s.height = `${full}px`;
|
|
559
|
+
if (full - h !== cover) {
|
|
560
|
+
// Less covered (the keyboard going down): the end space shrinks, and iOS WebKit kept a list
|
|
561
|
+
// scrolled into it there, its rows stuck above a blank strip, not scrollable back (iOS 27
|
|
562
|
+
// Simulator). Its offset back within the list, read a frame later.
|
|
563
|
+
if (full - h < cover && !clamping) {
|
|
564
|
+
clamping = true;
|
|
565
|
+
afterFrame(() => {
|
|
566
|
+
clamping = false;
|
|
567
|
+
scrolled.forEach((el) => el.scrollTop > el.scrollHeight - el.clientHeight && (el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight)));
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
cover = full - h;
|
|
571
|
+
s.setProperty('--lbx-cover', `${cover}px`);
|
|
572
|
+
}
|
|
486
573
|
host!.toggleAttribute('data-lbx-short', h < SHEET_SHORT);
|
|
487
574
|
raf = requestAnimationFrame(() => position(true));
|
|
488
575
|
return;
|
|
@@ -842,7 +929,8 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
842
929
|
setCss('ac:sheet', null);
|
|
843
930
|
host!.style.height = '';
|
|
844
931
|
host!.style.background = 'transparent';
|
|
845
|
-
host!.style.removeProperty('--lbx-
|
|
932
|
+
host!.style.removeProperty('--lbx-cover');
|
|
933
|
+
cover = -1;
|
|
846
934
|
host!.removeAttribute('data-lbx-short');
|
|
847
935
|
mirror?.blur();
|
|
848
936
|
if (focused() === input) input?.blur();
|
|
@@ -1094,11 +1182,17 @@ export const start = (ctx: WidgetContext<AutocompleteConfigType>) => {
|
|
|
1094
1182
|
};
|
|
1095
1183
|
/** The "No results" layout's options: phrases to try, links to browse, fallback products. */
|
|
1096
1184
|
const emptyOptions = (fallback: Result[]) => {
|
|
1097
|
-
|
|
1185
|
+
// Luma's own options, not catalog hits: their text and link are the
|
|
1186
|
+
// config's, whatever the integration computes for its catalog's.
|
|
1187
|
+
const own = (hit: { '@id': string; '@type': string; '@title': string; url?: string }, kind: string) => {
|
|
1188
|
+
const r = ctx.result(hit);
|
|
1189
|
+
Object.defineProperties(r.c, { title: { value: hit['@title'] }, url: { value: safeUrl(hit.url) } });
|
|
1190
|
+
return Object.assign(r, { __empty: kind });
|
|
1191
|
+
};
|
|
1098
1192
|
return [
|
|
1099
|
-
...(empty.tryQueries ?? []).filter((q) => typeof q === 'string' && q.trim()).map((q) =>
|
|
1100
|
-
...(empty.browse ?? []).filter((b) => b && safeUrl(b.url)).map((b) =>
|
|
1101
|
-
...fallback.map((r) =>
|
|
1193
|
+
...(empty.tryQueries ?? []).filter((q) => typeof q === 'string' && q.trim()).map((q) => own({ '@id': `try/${q}`, '@type': 'query', '@title': q }, 'try')),
|
|
1194
|
+
...(empty.browse ?? []).filter((b) => b && safeUrl(b.url)).map((b) => own({ '@id': `browse/${b.url}`, '@type': 'browse', '@title': b.title ?? '', url: b.url }, 'browse')),
|
|
1195
|
+
...fallback.map((r) => ({ ...r, __empty: 'fallback' })),
|
|
1102
1196
|
];
|
|
1103
1197
|
};
|
|
1104
1198
|
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
--lbx-radius-row: calc(var(--lbx-radius) * .6);
|
|
37
37
|
--lbx-radius-tile: calc(var(--lbx-radius) * .5);
|
|
38
38
|
}
|
|
39
|
-
/* The render root: the sheet is the
|
|
40
|
-
|
|
41
|
-
.lbx-root { height:
|
|
39
|
+
/* The render root: the sheet is the host's height (from the visual viewport's top to the layout
|
|
40
|
+
viewport's bottom, index.ts); the panel is as tall as it is. */
|
|
41
|
+
.lbx-root { height: 100%; }
|
|
42
42
|
/* What the rules read: the tokens, or the high-contrast palette while it is on. */
|
|
43
43
|
.box, .sheet {
|
|
44
44
|
--c-fg: var(--lbx-c-fg, var(--lbx-fg));
|
|
@@ -155,12 +155,12 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
155
155
|
.e-opts { margin-top: 10px; display: flex; flex-direction: column; gap: 12px; }
|
|
156
156
|
.grp.inline { flex-direction: row; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
157
157
|
.grp.inline h3 { padding: 0; margin-right: 4px; font-size: 14px; }
|
|
158
|
-
.tries, .
|
|
158
|
+
.tries, .e-links { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; }
|
|
159
159
|
.tries { gap: 8px; }
|
|
160
160
|
.try { height: 34px; padding: 0 14px; gap: 6px; border-radius: 999px; box-shadow: inset 0 0 0 1.5px var(--c-line); font-size: 14px; font-weight: 700; }
|
|
161
161
|
.try .glass { color: var(--c-accent); }
|
|
162
162
|
.try[aria-selected="true"] { background: var(--lbx-c-sel-bg, var(--c-tint)); color: var(--lbx-c-sel-fg, inherit); box-shadow: inset 0 0 0 1.5px var(--c-fill); }
|
|
163
|
-
.browse .chev { display: none; }
|
|
163
|
+
.opt.browse .chev { display: none; }
|
|
164
164
|
.opt.browse { padding: 2px 6px; margin: 0 -6px; border-radius: var(--lbx-radius-row); color: var(--c-accent); font-weight: 700; font-size: 14px; }
|
|
165
165
|
.opt.browse[aria-selected="true"] { background: var(--lbx-c-sel-bg, var(--c-tint)); text-decoration: underline; text-underline-offset: 3px; }
|
|
166
166
|
.help { width: 300px; flex: none; padding: 18px 20px; border-radius: var(--lbx-radius-row); border: 1.5px dashed var(--lbx-tint-strong); display: flex; gap: 14px; align-items: center; }
|
|
@@ -188,6 +188,8 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
188
188
|
/* @lbx-part autocomplete-sheet: left out of a build whose integration replaces this template and uses none of its classes (build/vite.mjs) */
|
|
189
189
|
/* ---- the phone sheet --------------------------------------------------- */
|
|
190
190
|
.sheet { height: 100%; display: flex; flex-direction: column; overflow: hidden; background: var(--c-bg); border-top: 3px solid var(--c-fill); }
|
|
191
|
+
/* Focused from code only (tabindex -1: a swipe puts the keyboard away, index.ts): the whole screen, no ring. */
|
|
192
|
+
.sheet:focus { outline: none; }
|
|
191
193
|
.sh-head { height: 60px; flex: none; padding: 0 8px 0 4px; display: flex; align-items: center; gap: 4px; border-bottom: 1px solid var(--c-line); touch-action: none; }
|
|
192
194
|
.icon { width: 44px; height: 44px; flex: none; display: flex; align-items: center; justify-content: center; border-radius: var(--lbx-radius-row); }
|
|
193
195
|
.x { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 999px; background: var(--lbx-soft); }
|
|
@@ -199,6 +201,12 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
199
201
|
.tab { flex: none; padding: 0 14px; font-size: 15px; color: var(--c-muted); white-space: nowrap; }
|
|
200
202
|
.tab[aria-selected="true"] { color: var(--c-fg); font-weight: 700; box-shadow: inset 0 -3px 0 var(--c-fill); }
|
|
201
203
|
.sh-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; touch-action: pan-y; }
|
|
204
|
+
/* What Safari's floating bars and the keyboard cover (--lbx-cover, index.ts): the list (and the
|
|
205
|
+
no-results column) runs on behind them, its end scrolls clear of them (as space; a row brought into
|
|
206
|
+
view by the keys or by focus too). A tap on what shows through the bars is Safari's, as on the
|
|
207
|
+
legacy autocomplete. */
|
|
208
|
+
.sh-list, .sheet .empty { scroll-padding-bottom: var(--lbx-cover, 0px); }
|
|
209
|
+
.sh-list::after, .sheet .empty::after { content: ""; flex: none; height: var(--lbx-cover, 0px); }
|
|
202
210
|
.sh-list .pby { margin: 0; padding: 20px 16px 24px; justify-content: center; font-size: 13px; }
|
|
203
211
|
.m-hero { padding: 16px; gap: 12px; border-bottom: 1px solid var(--c-line); }
|
|
204
212
|
.m-hero h3 { padding: 0; font-size: 13px; color: var(--c-muted); }
|
|
@@ -222,7 +230,7 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
222
230
|
.mrow .t { flex: 1; min-width: 0; }
|
|
223
231
|
.mrow .chev, .opt.browse .chev { flex: none; color: var(--c-muted); }
|
|
224
232
|
.mrow[aria-selected="true"] { background: var(--lbx-c-sel-bg, var(--c-tint)); color: var(--lbx-c-sel-fg, inherit); box-shadow: inset 3px 0 0 var(--c-fill); }
|
|
225
|
-
.sh-foot { flex: none; padding:
|
|
233
|
+
.sh-foot { flex: none; padding: 16px 16px 0; }
|
|
226
234
|
.sheet .all { height: 48px; font-size: 16px; background: var(--c-fill); color: var(--c-on-fill); box-shadow: none; }
|
|
227
235
|
/* No results on the phone: one column, its own scroll. */
|
|
228
236
|
.sheet .empty { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; touch-action: pan-y; }
|
|
@@ -232,9 +240,9 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
|
|
|
232
240
|
.sheet .e-title { font-size: 21px; }
|
|
233
241
|
.sheet .grp.inline { flex-direction: column; align-items: stretch; }
|
|
234
242
|
.sheet .try { height: 40px; font-size: 15px; }
|
|
235
|
-
.sheet .
|
|
243
|
+
.sheet .e-links { flex-direction: column; align-items: stretch; gap: 0; }
|
|
236
244
|
.sheet .opt.browse { min-height: 48px; margin: 0 -6px; padding: 0 6px; justify-content: space-between; font-size: 16px; }
|
|
237
|
-
.sheet .browse .chev { display: block; }
|
|
245
|
+
.sheet .opt.browse .chev { display: block; }
|
|
238
246
|
.sheet .help { width: auto; padding: 16px; }
|
|
239
247
|
.sheet .help-text { flex: 1; }
|
|
240
248
|
.sheet .help-title { font-size: 15px; }
|
|
@@ -34,7 +34,7 @@ export default (s: AutocompleteScope) => {
|
|
|
34
34
|
${tries.length || browse.length
|
|
35
35
|
? html`<div class="e-opts" role="listbox" id="lbx-ac-listbox" aria-label=${s.t('label')}>
|
|
36
36
|
${tries.length ? group('try', s.t('tryLabel'), html`<div class="tries">${tries.map((x) => opt(x, 'try'))}</div>`, 'inline') : nothing}
|
|
37
|
-
${browse.length ? group('browse', s.t('browseLabel'), html`<div class="
|
|
37
|
+
${browse.length ? group('browse', s.t('browseLabel'), html`<div class="e-links">${browse.map((x) => opt(x, 'browse'))}</div>`, 'inline') : nothing}
|
|
38
38
|
</div>`
|
|
39
39
|
: nothing}
|
|
40
40
|
</div>
|
|
@@ -4,9 +4,11 @@ import { POWERED, group, showAll } from './template-autocomplete-box';
|
|
|
4
4
|
|
|
5
5
|
// The house design on a phone: a full-screen sheet. Its header
|
|
6
6
|
// (back, its own input, clear), the query pills on one sideways-scrolling
|
|
7
|
-
// band, a tab per type with results (products first; no counts
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// band, a tab per type with results (products first; no counts; no tab bar
|
|
8
|
+
// for a lone type: 48 px of a short sheet, and the options say what they
|
|
9
|
+
// are), the open tab's options (the top product, then product rows; or 56 px rows), and
|
|
10
|
+
// "Show all products" at the end of the list (below the fold with the keyboard
|
|
11
|
+
// up: the list gets the room; a swipe puts the keyboard away, index.ts).
|
|
10
12
|
// Its input is the combobox (index.ts): it controls both listboxes and
|
|
11
13
|
// points at the selected option (aria-activedescendant, set by index.ts).
|
|
12
14
|
|
|
@@ -16,6 +18,8 @@ export default (s: AutocompleteScope) => {
|
|
|
16
18
|
const results = s.state === 'results';
|
|
17
19
|
const onProducts = !!s.hero && s.tabs.find((x) => x.selected)?.kind === s.hero.r.type;
|
|
18
20
|
const open = s.tabs.find((x) => x.selected);
|
|
21
|
+
// A lone type: no tab bar, no tabpanel; its listbox is named by the type ("Products").
|
|
22
|
+
const tabbed = s.tabs.length > 1;
|
|
19
23
|
const rows = open && !onProducts ? (s.lists.find((g) => g.kind === open.kind)?.rows ?? []) : [];
|
|
20
24
|
// What the input controls: the listboxes on screen.
|
|
21
25
|
const e = s.empty;
|
|
@@ -29,7 +33,7 @@ export default (s: AutocompleteScope) => {
|
|
|
29
33
|
.filter(Boolean)
|
|
30
34
|
.join(' ');
|
|
31
35
|
return html`
|
|
32
|
-
<div class="sheet" data-lbx-part="ac-panel">
|
|
36
|
+
<div class="sheet" data-lbx-part="ac-panel" role="group" aria-label=${s.inputLabel} tabindex="-1">
|
|
33
37
|
<div class="sh-head">
|
|
34
38
|
<button type="button" class="icon back" aria-label=${s.t('closeSearch')} @click=${() => s.back()}>
|
|
35
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>
|
|
@@ -48,7 +52,7 @@ export default (s: AutocompleteScope) => {
|
|
|
48
52
|
${s.phrases.length
|
|
49
53
|
? html`<div class="sh-phrases" role="listbox" id="lbx-ac-phrases" aria-label=${s.t('phrases')}>${repeat(s.phrases, key, (p) => opt(p, 'pill'))}</div>`
|
|
50
54
|
: nothing}
|
|
51
|
-
${
|
|
55
|
+
${tabbed
|
|
52
56
|
? html`<div class="tabs" role="tablist" aria-label=${s.t('tabs')}>
|
|
53
57
|
${s.tabs.map(
|
|
54
58
|
(x) =>
|
|
@@ -56,18 +60,18 @@ export default (s: AutocompleteScope) => {
|
|
|
56
60
|
)}
|
|
57
61
|
</div>`
|
|
58
62
|
: nothing}
|
|
59
|
-
<div class="sh-list" role
|
|
63
|
+
<div class="sh-list" role=${tabbed ? 'tabpanel' : nothing} id="lbx-ac-panel" aria-labelledby=${tabbed ? `lbx-ac-tab-${open!.kind}` : nothing}>
|
|
60
64
|
${s.tabs.length
|
|
61
|
-
? html`<div role="listbox" id="lbx-ac-listbox" aria-label=${s.t('label')}>
|
|
65
|
+
? html`<div role="listbox" id="lbx-ac-listbox" aria-label=${tabbed ? s.t('label') : open!.label}>
|
|
62
66
|
${onProducts
|
|
63
67
|
? html`${group('hero', s.t('topProduct'), opt(s.hero!, 'mhero'), 'm-hero')}
|
|
64
68
|
${s.products.length ? group('products', s.t('products'), repeat(s.products, key, (p) => opt(p, 'mproduct')), 'm-products') : nothing}`
|
|
65
69
|
: repeat(rows, key, (row) => opt(row, 'mrow'))}
|
|
66
70
|
</div>`
|
|
67
71
|
: nothing}
|
|
72
|
+
${s.hero ? html`<div class="sh-foot">${showAll(s)}</div>` : nothing}
|
|
68
73
|
${POWERED(s.t)}
|
|
69
|
-
</div
|
|
70
|
-
${s.hero ? html`<div class="sh-foot">${showAll(s)}</div>` : nothing}`
|
|
74
|
+
</div>`
|
|
71
75
|
: nothing}
|
|
72
76
|
</div>
|
|
73
77
|
`;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Apply in a staged drawer, marked (element.ts swapIn): a change the shopper
|
|
2
|
+
// confirmed with the drawer closing over the list must read as one. On an
|
|
3
|
+
// iPhone the quiet fade of a prefetched result read as a flicker: had the
|
|
4
|
+
// filters been taken? (2026-10-19) The list it replaces dims at the tap
|
|
5
|
+
// (`data-lbx-swap`, as busy's dim: `--lbx-c-dim` holds it in the contrast
|
|
6
|
+
// mode), the new tiles come in, and a status at the listing's top says what
|
|
7
|
+
// was applied. Its own chunk (lbx-applied), asked for when a drawer opens
|
|
8
|
+
// where it stages: a listing page that never applies one does not load it.
|
|
9
|
+
import { cspNonce, layered, setCss } from '../../core/styles';
|
|
10
|
+
|
|
11
|
+
const SWAP_MS = 300;
|
|
12
|
+
export const CSS =
|
|
13
|
+
':is(lbx-search,lbx-plp)[data-lbx-swap] [data-lbx-list]{opacity:var(--lbx-c-dim,.15)}' +
|
|
14
|
+
'@media (forced-colors:active){:is(lbx-search,lbx-plp)[data-lbx-swap] [data-lbx-list]{opacity:.6}}' +
|
|
15
|
+
// The contrast mode (core/a11y.ts): the pill opaque, nothing of the page through it.
|
|
16
|
+
':root[data-lbx-contrast="more"] [data-lbx-applied-cue]{--lbx-applied-opaque:1}' +
|
|
17
|
+
'@media (prefers-contrast:more){:root:not([data-lbx-contrast="off"]) [data-lbx-applied-cue]{--lbx-applied-opaque:1}}';
|
|
18
|
+
// The pill is the one element with Luma's own look in the site's light
|
|
19
|
+
// DOM: a site's reset (`div,span{margin:0;padding:0}`, unlayered, so
|
|
20
|
+
// above Luma's layered CSS) took its padding and centring (a production storefront,
|
|
21
|
+
// 2026-10-19). Its wrapper's layout is its inline style (`!important`, as the
|
|
22
|
+
// status region's); the pill is in the wrapper's shadow root, with its own
|
|
23
|
+
// sheet. A site restyles it through `::part(pill)` or the `--lbx-applied-*`
|
|
24
|
+
// properties, which inherit into it.
|
|
25
|
+
// Sticky, 0 px high, the pill out of flow: margins collapse through it, nothing moves (a flex wrapper moved the list 16 px).
|
|
26
|
+
// What else a site's rule on `div` could set and break: clipped (overflow), given height (min-height: the list moves), narrowed (float, width).
|
|
27
|
+
export const WRAP =
|
|
28
|
+
'display:block!important;position:sticky!important;top:var(--lbx-applied-top,8px)!important;z-index:3!important;height:0!important;min-height:0!important;max-height:none!important;' +
|
|
29
|
+
'width:auto!important;max-width:none!important;float:none!important;margin:0!important;padding:0!important;border:0!important;overflow:visible!important;transform:none!important;filter:none!important;' +
|
|
30
|
+
'opacity:1!important;visibility:visible!important;contain:none!important;pointer-events:none!important';
|
|
31
|
+
export const PILL =
|
|
32
|
+
// Wraps (a long query, a long language) rather than cut: its words are shown nowhere else. A tap goes through it
|
|
33
|
+
// (all:initial put pointer-events back: it took taps on what it overlaps). Its font the site's; its size at least the reader's default's 15/16.
|
|
34
|
+
// `--lbx-applied-offset`: below the listing's top by this much (a template whose heading sits there).
|
|
35
|
+
'.p{all:initial;display:block;position:absolute;top:var(--lbx-applied-offset,0px);left:0;right:0;box-sizing:border-box;width:fit-content;max-width:calc(100% - 16px);margin:0 auto;padding:8px 16px;border-radius:18px;pointer-events:none;' +
|
|
36
|
+
'background:var(--lbx-applied-bg,#1d2433);color:var(--lbx-applied-fg,#fff);font-family:inherit;font-size:max(15px,.9375rem);font-weight:500;line-height:1.3;text-align:center;overflow-wrap:anywhere;box-shadow:0 2px 12px rgb(0 0 0/.25)}' +
|
|
37
|
+
// A little of the page through it, blurred (white text at least 10:1 over any page behind); opaque in the contrast mode (--lbx-applied-opaque).
|
|
38
|
+
'@supports (backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px)){.p{background:var(--lbx-applied-bg,rgb(29 36 51/calc(.88 + .12 * var(--lbx-applied-opaque,0))));' +
|
|
39
|
+
'-webkit-backdrop-filter:blur(calc(8px - 8px * var(--lbx-applied-opaque,0)));backdrop-filter:blur(calc(8px - 8px * var(--lbx-applied-opaque,0)))}}' +
|
|
40
|
+
'@media (forced-colors:active){.p{border:1px solid CanvasText;background:Canvas;color:CanvasText}}';
|
|
41
|
+
let sheet: CSSStyleSheet | null = null;
|
|
42
|
+
|
|
43
|
+
/** Its styles, before the first Apply (the drawer's opening restyles the page anyway). */
|
|
44
|
+
export const prepare = () => setCss('listing-applied', layered(CSS));
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The applied state's first tiles come in (rising row after row, only fading
|
|
48
|
+
* under reduced motion), and the status's pill goes after `after` (Luma's
|
|
49
|
+
* status region, which says the same words: the pill is hidden from
|
|
50
|
+
* assistive technology). Returns what takes the pill away (faded, or at once).
|
|
51
|
+
*/
|
|
52
|
+
export const swapIn = (host: HTMLElement, tiles: Element[], say: string, after: Element | null, reduce: boolean) => {
|
|
53
|
+
tiles.forEach((el, i) =>
|
|
54
|
+
el.animate(reduce ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 0, transform: 'translateY(12px)' }, { opacity: 1, transform: 'none' }], {
|
|
55
|
+
duration: SWAP_MS,
|
|
56
|
+
delay: reduce ? 0 : Math.min(i, 7) * 35,
|
|
57
|
+
easing: 'cubic-bezier(.2,0,0,1)',
|
|
58
|
+
fill: 'backwards',
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
const el = document.createElement('div');
|
|
62
|
+
el.setAttribute('data-lbx-applied-cue', '');
|
|
63
|
+
el.setAttribute('aria-hidden', 'true');
|
|
64
|
+
el.style.cssText = WRAP;
|
|
65
|
+
const root = el.attachShadow({ mode: 'open' });
|
|
66
|
+
// Safari before 16.4 has no constructed sheets (its constructor throws): a <style>, with the page's nonce.
|
|
67
|
+
if ('adoptedStyleSheets' in ShadowRoot.prototype) {
|
|
68
|
+
if (!sheet) (sheet = new CSSStyleSheet()), sheet.replaceSync(PILL);
|
|
69
|
+
root.adoptedStyleSheets = [sheet];
|
|
70
|
+
} else {
|
|
71
|
+
const st = root.appendChild(document.createElement('style'));
|
|
72
|
+
st.nonce = cspNonce();
|
|
73
|
+
st.textContent = PILL;
|
|
74
|
+
}
|
|
75
|
+
const pill = root.appendChild(document.createElement('span'));
|
|
76
|
+
pill.className = 'p';
|
|
77
|
+
pill.setAttribute('part', 'pill');
|
|
78
|
+
pill.textContent = `✓ ${say}`;
|
|
79
|
+
if (after) after.after(el);
|
|
80
|
+
else host.prepend(el);
|
|
81
|
+
pill.animate([{ opacity: 0 }, { opacity: 1 }], 200);
|
|
82
|
+
return (fade: boolean) => {
|
|
83
|
+
if (!fade) return el.remove();
|
|
84
|
+
// A cancelled animation rejects `finished`: removed either way.
|
|
85
|
+
pill.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 300, fill: 'forwards' }).finished.then(() => el.remove(), () => el.remove());
|
|
86
|
+
};
|
|
87
|
+
};
|
|
@@ -37,7 +37,7 @@ import { facetFields, handsBack, nextState, queryKey, stateOf, type Query, type
|
|
|
37
37
|
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
|
-
import { firstPages, targetOf, thrifty, watchIntent } from './intent';
|
|
40
|
+
import { firstPages, SETTLE_MS, targetOf, thrifty, watchIntent } from './intent';
|
|
41
41
|
import type { Page } from '../../api';
|
|
42
42
|
import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, viewport, type ListingView, type SsrState } from './view';
|
|
43
43
|
import { dropStash, peekStash, readStash, removeStash, writeStash, type Stash } from './stash';
|
|
@@ -47,6 +47,11 @@ import { watch } from '../../core/tiles';
|
|
|
47
47
|
/** A re-render answered within DIM_MS (no dim shown) fades its list in from this opacity, over FADE_MS. */
|
|
48
48
|
const FADE_FROM = 0.6;
|
|
49
49
|
const FADE_MS = 150;
|
|
50
|
+
/** An Apply's status (./applied.ts) shows this long; its new tiles come in up to SWAP_TILES (those in view). */
|
|
51
|
+
const APPLIED_MS = 2500;
|
|
52
|
+
const SWAP_TILES = 16;
|
|
53
|
+
/** Reduced motion asked for (read when needed: the setting may change). */
|
|
54
|
+
const reduced = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
50
55
|
/** A hidden first render in frames: the first STEP tiles, then about BUDGET ms of rendering per frame. */
|
|
51
56
|
const STEP = 4;
|
|
52
57
|
/** Tiles drawn (with the facets) before ours shows, per 900 px of viewport height: two rows of four on a wide screen, four of two on a phone. */
|
|
@@ -167,6 +172,17 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
167
172
|
// it does not download it with every listing.
|
|
168
173
|
const toggle = ctx.global.contrastToggle ? import('../../core/contrast-toggle') : null;
|
|
169
174
|
const visitOf = (q: Query) => JSON.stringify([q.q, q.scope]);
|
|
175
|
+
const prefetchFailed = (e: unknown) => reportError(`${route.id} filter prefetch`, e);
|
|
176
|
+
/** What a render says it shows: the count found, or nothing found. */
|
|
177
|
+
const found = (q: Query, res: ListingResult) => (res.total ? t('results', { count: res.total, query: q.title }) : t('empty', { query: q.title }));
|
|
178
|
+
// An Apply's cue (./applied.ts, its own chunk): asked for when a drawer opens where it stages.
|
|
179
|
+
let applied: typeof import('./applied') | null = null;
|
|
180
|
+
let appliedAsked = false;
|
|
181
|
+
const askApplied = () => {
|
|
182
|
+
if (appliedAsked) return;
|
|
183
|
+
appliedAsked = true;
|
|
184
|
+
import('./applied').then((m) => (m.prepare(), (applied = m)), (e) => reportError(`${route.id} applied cue`, e));
|
|
185
|
+
};
|
|
170
186
|
const earlyKey = (q: Query) => {
|
|
171
187
|
const r = firstRequest(ctx.global, cfg, route, q);
|
|
172
188
|
return r && JSON.stringify(r);
|
|
@@ -334,6 +350,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
334
350
|
*/
|
|
335
351
|
private staged: UiState | null = null;
|
|
336
352
|
private stagedMode = false;
|
|
353
|
+
/**
|
|
354
|
+
* The held state fetched in the background (prefetchHeld()): the timer
|
|
355
|
+
* waiting for the changes to pause, the held state's URL (a query
|
|
356
|
+
* resolved late is for it only), and the request it started (its key in
|
|
357
|
+
* `early`), aborted by a newer held state while it is out.
|
|
358
|
+
*/
|
|
359
|
+
private heldTimer = 0;
|
|
360
|
+
private heldKey: string | null = null;
|
|
337
361
|
/** Stops watching the `facetsApply` breakpoints of the open drawer (watchApply()). */
|
|
338
362
|
private unwatchApply: (() => void) | null = null;
|
|
339
363
|
/** Renders started: a render in chunks stops when a newer one has started. */
|
|
@@ -490,6 +514,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
490
514
|
this.ahead?.();
|
|
491
515
|
this.intent?.stop();
|
|
492
516
|
this.intent = null;
|
|
517
|
+
clearTimeout(this.heldTimer);
|
|
518
|
+
this.heldTimer = 0;
|
|
519
|
+
this.endCue();
|
|
520
|
+
this.removeAttribute('data-lbx-swap');
|
|
493
521
|
this.sized?.ro.disconnect();
|
|
494
522
|
this.sized = null;
|
|
495
523
|
this.unwatchApply?.();
|
|
@@ -581,7 +609,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
581
609
|
* Where the shopper was in each state of the visit left by a change of
|
|
582
610
|
* ours or a traversal (canonical href → scrollY), and the one a traversal
|
|
583
611
|
* goes back to: drawn whole (not in steps) and placed there. Bug (a
|
|
584
|
-
*
|
|
612
|
+
* production storefront, 2026-10-15): Back after a drawer filter
|
|
585
613
|
* drew the list in steps, the browser's restored scroll was clamped to
|
|
586
614
|
* the first step's short page, and Chromium's scroll anchoring kept the
|
|
587
615
|
* footer in view as the tiles went in above it.
|
|
@@ -607,22 +635,49 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
607
635
|
if (!DRAWER) return;
|
|
608
636
|
const next = this.staged;
|
|
609
637
|
this.staged = null;
|
|
610
|
-
|
|
638
|
+
// Changes not fetched yet (Apply within SETTLE_MS of the last): their
|
|
639
|
+
// request starts in the next task (the drawer closes first), while the
|
|
640
|
+
// drawer's entry is taken back, and the navigation joins it.
|
|
641
|
+
if (next && this.heldTimer) setTimeout(() => this.prefetchHeld(next), 0);
|
|
642
|
+
// Another state to show: the list it replaces dims now, as the drawer closes (./applied.ts).
|
|
643
|
+
const cue = (this.applyCue = next && canon(route.write(new URL(location.href), next).href) !== this.href ? { href: '' } : null);
|
|
644
|
+
this.toggleAttribute('data-lbx-swap', !!cue);
|
|
645
|
+
this.setDrawer(false, from, next ? () => {
|
|
646
|
+
this.navigate(next);
|
|
647
|
+
this.topFor = this.wantedHref;
|
|
648
|
+
if (cue) cue.href = this.wantedHref ?? '';
|
|
649
|
+
if (cue && !cue.href) this.removeAttribute('data-lbx-swap'), (this.applyCue = null); // nothing to fetch after all
|
|
650
|
+
this.toListTop();
|
|
651
|
+
} : undefined);
|
|
611
652
|
}
|
|
612
653
|
|
|
613
654
|
/** The state an Apply asked for: its render brings the listing's top into view again (late layout, a smooth scroll cut short). */
|
|
614
655
|
private topFor: string | null = null;
|
|
656
|
+
/**
|
|
657
|
+
* An Apply's swap (./applied.ts): the state it asked for ('' until its
|
|
658
|
+
* navigation; the old list dimmed meanwhile, `data-lbx-swap`); its words
|
|
659
|
+
* and state while its render puts the new tiles in (swapIn(), from the
|
|
660
|
+
* step that draws them); then the status shown for the state applied,
|
|
661
|
+
* what takes it away, and when.
|
|
662
|
+
*/
|
|
663
|
+
private applyCue: { href: string } | null = null;
|
|
664
|
+
private swapping: { say: string; href: string } | null = null;
|
|
665
|
+
private cue: { end: (fade: boolean) => void; href: string; timer: number } | null = null;
|
|
666
|
+
/** What the applied state's render announces (the status's words). */
|
|
667
|
+
private appliedSay: string | null = null;
|
|
615
668
|
/** The top of the listing in view when it is above the viewport (smooth, unless reduced motion is asked for). */
|
|
616
669
|
private toListTop() {
|
|
617
670
|
if (!this.isConnected || this.getBoundingClientRect().top >= 0) return;
|
|
618
|
-
|
|
619
|
-
this.scrollIntoView({ block: 'start', behavior: (reduce ? 'instant' : 'smooth') as ScrollBehavior });
|
|
671
|
+
this.scrollIntoView({ block: 'start', behavior: (reduced() ? 'instant' : 'smooth') as ScrollBehavior });
|
|
620
672
|
}
|
|
621
673
|
|
|
622
674
|
/** The drawer's held changes dropped: it shows the applied state again. */
|
|
623
675
|
private discardStaged() {
|
|
624
676
|
if (!this.staged) return;
|
|
625
677
|
this.staged = null;
|
|
678
|
+
// A fetch of what it held, started already, is left to expire (./intent.ts); one not started yet is not.
|
|
679
|
+
clearTimeout(this.heldTimer);
|
|
680
|
+
this.heldTimer = 0;
|
|
626
681
|
this.redraw();
|
|
627
682
|
}
|
|
628
683
|
|
|
@@ -647,6 +702,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
647
702
|
if (!open) this.discardStaged();
|
|
648
703
|
if (open && !was) {
|
|
649
704
|
this.stagedMode = atWidth(cfg.facetsApply, 'instant', viewport) === 'staged';
|
|
705
|
+
if (this.stagedMode) askApplied();
|
|
650
706
|
if (this.stagedMode && !this.drawerEntry)
|
|
651
707
|
try {
|
|
652
708
|
history.pushState(null, '', location.href);
|
|
@@ -696,8 +752,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
696
752
|
/**
|
|
697
753
|
* The open drawer closes when the viewport crosses into another
|
|
698
754
|
* `facetsApply` mode (a phone rotated, a window widened): the drawer was a
|
|
699
|
-
* phone's, and what it held was never confirmed. Regression (a
|
|
700
|
-
*
|
|
755
|
+
* phone's, and what it held was never confirmed. Regression (a production
|
|
756
|
+
* storefront, `{ 0: 'staged', 992: 'instant' }`, 2026-10-14): a value ticked in
|
|
701
757
|
* the drawer at 820 px stayed ticked in the 992 px sidebar with the
|
|
702
758
|
* results unfiltered and no apply button there, and rotating back showed
|
|
703
759
|
* the full-screen drawer again (`data-drawer-open` had stayed). Dropping
|
|
@@ -839,12 +895,18 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
839
895
|
return w;
|
|
840
896
|
}
|
|
841
897
|
|
|
842
|
-
/**
|
|
898
|
+
/**
|
|
899
|
+
* UI -> URL. The resulting URL change drives sync(). In a drawer that
|
|
900
|
+
* stages: held there, its state fetched in the background once the
|
|
901
|
+
* changes pause (nothing drawn from it until Apply).
|
|
902
|
+
*/
|
|
843
903
|
go(patch: Partial<UiState>) {
|
|
844
904
|
if (!this.current) return;
|
|
845
905
|
this.intent?.cancel(); // a dwell still pending: this click's own state is what is fetched now
|
|
846
906
|
if (this.staging()) {
|
|
847
907
|
this.staged = nextState(this.staged ?? this.wanted ?? stateOf(this.current), this.shown ?? stateOf(this.current), patch);
|
|
908
|
+
clearTimeout(this.heldTimer);
|
|
909
|
+
if (DRAWER && prefetchFilters) this.heldTimer = window.setTimeout(() => this.staging() && this.staged && this.prefetchHeld(this.staged), SETTLE_MS);
|
|
848
910
|
return this.redraw();
|
|
849
911
|
}
|
|
850
912
|
this.navigate(nextState(this.wanted ?? stateOf(this.current), this.shown ?? stateOf(this.current), patch));
|
|
@@ -1027,7 +1089,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1027
1089
|
this.message = null;
|
|
1028
1090
|
// Another state of the visit shown (a filter, a sort): its sidebar
|
|
1029
1091
|
// stays until the new facets come, as from the intent prefetch.
|
|
1030
|
-
// Bug (a
|
|
1092
|
+
// Bug (a production storefront, 2026-10-15): drawn with no
|
|
1031
1093
|
// facets, the sidebar collapsed to one group for ~250 ms and
|
|
1032
1094
|
// Chromium's scroll anchoring threw the page to its top.
|
|
1033
1095
|
const last = this.last;
|
|
@@ -1099,6 +1161,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1099
1161
|
}
|
|
1100
1162
|
this.images();
|
|
1101
1163
|
if (tk.state === 'ours') this.watch();
|
|
1164
|
+
if (DRAWER) this.swapIn(); // an Apply's new tiles, if these are they
|
|
1102
1165
|
}
|
|
1103
1166
|
|
|
1104
1167
|
/** Images a render put in that are there already (cached, persisted tiles) lose their placeholder now: no flash, no fade. */
|
|
@@ -1260,25 +1323,89 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1260
1323
|
private onIntent(el: Element) {
|
|
1261
1324
|
const cur = this.current;
|
|
1262
1325
|
// In a drawer that stages, a control leads nowhere until Apply: nothing to fetch ahead.
|
|
1263
|
-
if (this.staging()) return;
|
|
1264
|
-
if (!cur || !this.last || this.lastQ !== cur || this.inFlight || this.wanted || tk.state !== 'ours' || thrifty()) return;
|
|
1326
|
+
if (this.staging() || !this.settled(cur)) return;
|
|
1265
1327
|
// "Show more" (`data-lbx-more`): its next page, from the cursor, as nearing the end does.
|
|
1266
|
-
if (el.matches('[data-lbx-more]')) return this.prefetchNext(cur, this.last);
|
|
1328
|
+
if (el.matches('[data-lbx-more]')) return this.prefetchNext(cur, this.last!);
|
|
1267
1329
|
const raw = targetOf(el);
|
|
1268
1330
|
if (!raw) return;
|
|
1269
1331
|
const url = new URL(raw, location.href);
|
|
1270
1332
|
if (url.origin !== location.origin || url.pathname !== location.pathname || !route.accepts(url)) return;
|
|
1333
|
+
this.prefetchState(url, 'on intent');
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* What the open staged drawer holds, fetched in the background (its
|
|
1338
|
+
* changes paused for SETTLE_MS, or Apply came first): its first page into
|
|
1339
|
+
* `early`, which Apply's sync() takes, done or in flight. Nothing is drawn
|
|
1340
|
+
* from it, no hook runs, no URL is written: the results change on Apply
|
|
1341
|
+
* only. A newer held state aborts the older one still out; back at the
|
|
1342
|
+
* state shown, nothing is fetched. Limits as on intent (thrifty(), the
|
|
1343
|
+
* visit's cap; none with `prefetchFilters` off: no timer is set).
|
|
1344
|
+
*/
|
|
1345
|
+
private prefetchHeld(st: UiState) {
|
|
1346
|
+
if (!DRAWER) return;
|
|
1347
|
+
clearTimeout(this.heldTimer);
|
|
1348
|
+
this.heldTimer = 0;
|
|
1349
|
+
const cur = this.current;
|
|
1350
|
+
if (!this.settled(cur)) return;
|
|
1351
|
+
const why = "of the drawer's held state";
|
|
1352
|
+
// The request this held state starts (or finds cached, or none): the older one still out goes.
|
|
1353
|
+
const keyed = (k: string | null) => {
|
|
1354
|
+
const was = this.heldKey;
|
|
1355
|
+
if (was && was !== k) early.drop(was);
|
|
1356
|
+
this.heldKey = k;
|
|
1357
|
+
};
|
|
1358
|
+
// From a timer: a route hook that throws (fromUrl) must not reach the
|
|
1359
|
+
// site. Nothing is fetched; the drawer keeps what it holds, and Apply
|
|
1360
|
+
// reports the hook's failure as any navigation does.
|
|
1361
|
+
try {
|
|
1362
|
+
const url = route.write(new URL(location.href), st);
|
|
1363
|
+
if (canon(url.href) === this.href) return keyed(null);
|
|
1364
|
+
// The query Apply's sync() will resolve from that URL: the same
|
|
1365
|
+
// request, to the order of the values (a value map puts some in the
|
|
1366
|
+
// site's param, the others in ours, read back first). When the route
|
|
1367
|
+
// has it at hand (memoised), that one. Else the state held, at once
|
|
1368
|
+
// (resolving the URL's slugs may ask the category's values first:
|
|
1369
|
+
// two requests in a row), and once resolved, the URL's own query
|
|
1370
|
+
// supersedes it if its request differs (the same: nothing more).
|
|
1371
|
+
const r = route.resolve(url);
|
|
1372
|
+
if (!isPromise(r)) return this.prefetchState(r, why, keyed);
|
|
1373
|
+
this.prefetchState({ ...cur, ...st }, why, keyed);
|
|
1374
|
+
r.then((q) => void (this.staged === st && this.prefetchState(q, why, keyed))).catch(prefetchFailed);
|
|
1375
|
+
} catch (e) {
|
|
1376
|
+
prefetchFailed(e);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** A listing of ours to fetch ahead from: the current state shown, nothing pending for it, a connection that allows it. */
|
|
1381
|
+
private settled(cur: Query | null): cur is Query {
|
|
1382
|
+
return !!cur && !!this.last && this.lastQ === cur && !this.inFlight && !this.wanted && tk.state === 'ours' && !thrifty();
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Another state of this visit (a control's URL, or a held state's
|
|
1387
|
+
* query), not cached: its first page, the request sync() starts with
|
|
1388
|
+
* (`firstRequest`), fetched ahead into `early` and never rendered from
|
|
1389
|
+
* here. `keyed`: told the request's key (null: none) before it starts,
|
|
1390
|
+
* also when there is nothing to start (cached).
|
|
1391
|
+
*/
|
|
1392
|
+
private prefetchState(to: URL | Query | null, why: string, keyed?: (k: string | null) => void) {
|
|
1393
|
+
const cur = this.current!;
|
|
1271
1394
|
const from = this.href;
|
|
1272
|
-
if (canon(url.href) === from) return;
|
|
1273
1395
|
const then = (q: Query | null) => {
|
|
1274
|
-
if (!q || this.href !== from || this.inFlight || this.wanted || visitOf(q) !== visitOf(cur)
|
|
1396
|
+
if (!q || this.href !== from || this.inFlight || this.wanted || visitOf(q) !== visitOf(cur)) return;
|
|
1275
1397
|
const req = firstRequest(ctx.global, cfg, route, q);
|
|
1398
|
+
const k = req && JSON.stringify(req);
|
|
1399
|
+
keyed?.(k);
|
|
1400
|
+
if (!k || cache.get(keyOf(q))) return;
|
|
1276
1401
|
// Its first images too (`_global.images.warm`, low priority): the click then shows them at once.
|
|
1277
1402
|
const fetch = (signal: AbortSignal) => ctx.api.search(req!, signal).then((p) => (warm > 0 && warmImages(firstRowImages(imgs, p.hits.slice(0, warm).map(ctx.result), true), warm), p));
|
|
1278
|
-
if (
|
|
1403
|
+
if (early.start(k, visitOf(q), fetch)) mark(`${route.id} prefetch ${why}`);
|
|
1279
1404
|
};
|
|
1280
|
-
|
|
1281
|
-
if (
|
|
1405
|
+
if (!(to instanceof URL)) return then(to);
|
|
1406
|
+
if (canon(to.href) === from) return;
|
|
1407
|
+
const r = route.resolve(to);
|
|
1408
|
+
if (isPromise(r)) r.then(then).catch(prefetchFailed);
|
|
1282
1409
|
else then(r);
|
|
1283
1410
|
}
|
|
1284
1411
|
|
|
@@ -1299,14 +1426,63 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1299
1426
|
this.waited = null;
|
|
1300
1427
|
const was = this.last;
|
|
1301
1428
|
const quiet = this.quiet;
|
|
1429
|
+
// An Apply's swap (./applied.ts): any render after its navigation ends
|
|
1430
|
+
// it; the applied state's own render, not an error's, says what was
|
|
1431
|
+
// applied, and its first step brings the new tiles in (swapIn()).
|
|
1432
|
+
const href = DRAWER ? canon(url.href) : '';
|
|
1433
|
+
const cue = DRAWER && this.applyCue?.href ? this.applyCue : null;
|
|
1434
|
+
const say = cue && href === cue.href && !this.message ? t('applied', { results: found(q, res) }) : null;
|
|
1435
|
+
if (cue) this.applyCue = null;
|
|
1436
|
+
if (DRAWER && this.cue && this.cue.href !== href) this.endCue();
|
|
1437
|
+
// A render of the same state before the swap's step drew its tiles (the
|
|
1438
|
+
// facets, before a first step a frame late on a phone): it carries the
|
|
1439
|
+
// swap on; it dropped it, and the list stayed dimmed for the visit.
|
|
1440
|
+
if (DRAWER) (this.appliedSay = say), (this.swapping = say && tk.state === 'ours' ? { say, href } : this.swapping?.href === href ? this.swapping : null);
|
|
1302
1441
|
this.guarded(() => this.draw(q, res, url));
|
|
1303
|
-
if (
|
|
1442
|
+
if (DRAWER) (this.appliedSay = null), !this.swapping && this.removeAttribute('data-lbx-swap');
|
|
1443
|
+
if (!say && w && !w.dimmed && was && !quiet && tk.state === 'ours' && !sameHits(res, was)) this.fadeIn();
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* The applied state's tiles are in (the render's first step): the old
|
|
1448
|
+
* list's dim ends, those in view come in, and the status says what was
|
|
1449
|
+
* applied for APPLIED_MS (`data-lbx-applied` on the element meanwhile,
|
|
1450
|
+
* for the site's own cue). Without the cue's chunk (not loaded yet, or
|
|
1451
|
+
* failed), only the dim ends: the status region still says it.
|
|
1452
|
+
*/
|
|
1453
|
+
private swapIn() {
|
|
1454
|
+
const s = DRAWER ? this.swapping : null;
|
|
1455
|
+
if (!s) return;
|
|
1456
|
+
this.swapping = null;
|
|
1457
|
+
this.removeAttribute('data-lbx-swap');
|
|
1458
|
+
if (!applied) return;
|
|
1459
|
+
this.endCue();
|
|
1460
|
+
const tiles = [...(this.querySelector('[data-lbx-list]')?.children ?? [])].slice(0, SWAP_TILES);
|
|
1461
|
+
let end: (fade: boolean) => void;
|
|
1462
|
+
try {
|
|
1463
|
+
end = applied.swapIn(this, tiles, s.say, this.a11y?.root ?? null, reduced());
|
|
1464
|
+
} catch (e) {
|
|
1465
|
+
// In the render's step: a failure of the cue costs the cue, never the listing (guarded() would hand it back).
|
|
1466
|
+
return reportError(`${route.id} applied cue`, e);
|
|
1467
|
+
}
|
|
1468
|
+
this.setAttribute('data-lbx-applied', '');
|
|
1469
|
+
this.cue = { end, href: s.href, timer: window.setTimeout(() => this.endCue(true), APPLIED_MS) };
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** The status of an Apply taken away: faded out once its time is up, at once when another state renders. */
|
|
1473
|
+
private endCue(fade = false) {
|
|
1474
|
+
const c = DRAWER ? this.cue : null;
|
|
1475
|
+
if (!c) return;
|
|
1476
|
+
this.cue = null;
|
|
1477
|
+
clearTimeout(c.timer);
|
|
1478
|
+
this.removeAttribute('data-lbx-applied');
|
|
1479
|
+
c.end(fade);
|
|
1304
1480
|
}
|
|
1305
1481
|
|
|
1306
1482
|
/** The list fading in from FADE_FROM (Web Animations: no class to restart, no layout read); not under reduced motion. */
|
|
1307
1483
|
private fadeIn() {
|
|
1308
1484
|
const list = this.querySelector('[data-lbx-list]');
|
|
1309
|
-
if (!list || typeof list.animate !== 'function' ||
|
|
1485
|
+
if (!list || typeof list.animate !== 'function' || reduced()) return;
|
|
1310
1486
|
this.fade?.cancel();
|
|
1311
1487
|
this.fade = list.animate([{ opacity: FADE_FROM }, { opacity: 1 }], { duration: FADE_MS, easing: 'cubic-bezier(.2,0,0,1)' });
|
|
1312
1488
|
}
|
|
@@ -1318,6 +1494,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1318
1494
|
} catch (e) {
|
|
1319
1495
|
reportError(`${route.id} render`, e);
|
|
1320
1496
|
this.setBusy(false);
|
|
1497
|
+
// An Apply's swap under way: the list handed back is not left dimmed.
|
|
1498
|
+
if (DRAWER) (this.swapping = null), this.removeAttribute('data-lbx-swap');
|
|
1321
1499
|
// The server's markup is still the page's: back it goes (the takeover keeps a listing that is there).
|
|
1322
1500
|
if (this.server) {
|
|
1323
1501
|
delete (this as any)._$litPart$;
|
|
@@ -1461,7 +1639,7 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1461
1639
|
const before = this.lastQ;
|
|
1462
1640
|
const grew = !!before && !!this.last && keyOf(q) === keyOf(before) && res.page > this.last.page && res.guid === this.last.guid;
|
|
1463
1641
|
this.shownBefore = grew ? (this.last?.hits.length ?? 0) : 0;
|
|
1464
|
-
const said = this.message ?? (grew ? t('shown', { shown: res.hits.length, total: res.total, count: res.total }) :
|
|
1642
|
+
const said = this.message ?? this.appliedSay ?? (grew ? t('shown', { shown: res.hits.length, total: res.total, count: res.total }) : found(q, res));
|
|
1465
1643
|
// A quiet render keeps what is still to be said: the facets' render (said
|
|
1466
1644
|
// with the products) cut short their redraw in steps, before it spoke,
|
|
1467
1645
|
// and a filter was never announced (found by Luma's accessibility tests on a phone, 2026-10-11).
|
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
// Results are never rendered by the prefetch itself: only a click whose
|
|
22
22
|
// request is the same takes them.
|
|
23
23
|
//
|
|
24
|
+
// A drawer that stages (`facetsApply: 'staged'`) has no intent to watch: its
|
|
25
|
+
// controls lead nowhere until Apply. There the held state itself is fetched
|
|
26
|
+
// once the changes pause for SETTLE_MS (three quick ticks: one request), a
|
|
27
|
+
// newer held state aborting the older one still out; Apply takes it as a
|
|
28
|
+
// click does. Closing the drawer otherwise leaves it to expire.
|
|
29
|
+
//
|
|
24
30
|
// Limits: two in flight (a newer intent aborts the oldest), the state
|
|
25
31
|
// changing aborts those still out (their controls now lead elsewhere), kept
|
|
26
32
|
// TTL_MS, PER_VISIT per category or query, none with Save-Data, offline or on
|
|
@@ -36,6 +42,8 @@ import { reportError } from '../../core/health';
|
|
|
36
42
|
|
|
37
43
|
/** Hover or focus this long counts as intent (Chrome's speculation rules wait ~200 ms on hover for a whole page; this is one request). */
|
|
38
44
|
export const DWELL_MS = 100;
|
|
45
|
+
/** A staged drawer's changes pause this long before its held state is fetched (element.ts `prefetchHeld`). */
|
|
46
|
+
export const SETTLE_MS = 250;
|
|
39
47
|
const TTL_MS = 30_000;
|
|
40
48
|
/** First pages kept (done or in flight). */
|
|
41
49
|
const KEEP = 6;
|
|
@@ -181,6 +189,13 @@ export const firstPages = () => {
|
|
|
181
189
|
while (m.size > KEEP) m.delete(m.keys().next().value!);
|
|
182
190
|
return true;
|
|
183
191
|
},
|
|
192
|
+
/** One still out, no longer wanted (a staged drawer's older held state): aborted. A done one stays until it expires. */
|
|
193
|
+
drop(k: string) {
|
|
194
|
+
const e = m.get(k);
|
|
195
|
+
if (!e || e.settled) return;
|
|
196
|
+
e.ctrl.abort();
|
|
197
|
+
m.delete(k);
|
|
198
|
+
},
|
|
184
199
|
/** The state changed: requests still out were for controls that now lead elsewhere. */
|
|
185
200
|
abortInFlight() {
|
|
186
201
|
for (const [k, e] of m)
|