@luigisbox/luma 0.2.0 → 0.3.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/README.md +8 -3
- package/docs/ACCESSIBILITY.md +8 -3
- package/package.json +1 -1
- package/src/core/entries.ts +10 -6
- package/src/core/stash.ts +24 -4
- package/src/types.ts +11 -2
- package/src/widgets/autocomplete/index.ts +186 -31
- package/src/widgets/autocomplete/style-autocomplete.css +11 -5
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
- package/src/widgets/listing/element.ts +209 -21
- package/src/widgets/listing/instant.ts +83 -23
- package/src/widgets/listing/stash.ts +31 -8
- package/src/widgets/listing/view.ts +12 -2
- package/src/widgets/search/range.ts +40 -18
- package/src/widgets/search/style-search.css +2 -2
|
@@ -34,7 +34,7 @@ export default (s: AutocompleteScope) => {
|
|
|
34
34
|
<button type="button" class="icon back" aria-label=${s.t('closeSearch')} @click=${() => s.back()}>
|
|
35
35
|
<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>
|
|
36
36
|
</button>
|
|
37
|
-
<input class="sh-input" data-lbx-sheet-input type="text" enterkeyhint="search" autocomplete="off" autocapitalize="off" spellcheck="false"
|
|
37
|
+
<input class="sh-input" data-lbx-sheet-input type="text" enterkeyhint="search" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
|
|
38
38
|
role="combobox" aria-autocomplete="list" aria-expanded=${controls ? 'true' : 'false'} aria-controls=${controls || nothing}
|
|
39
39
|
aria-label=${s.inputLabel} placeholder=${s.placeholder} .value=${s.value} />
|
|
40
40
|
${s.value
|
|
@@ -24,6 +24,7 @@ import { firstRowCount, firstRowImages, imageHelpers, imageOf, preloadImages, se
|
|
|
24
24
|
import { view } from '../../core/templates';
|
|
25
25
|
import { afterFrame, isPromise } from '../../core/scheduler';
|
|
26
26
|
import { onUrlChange } from '../../core/sense';
|
|
27
|
+
import { onOwnBack } from '../../core/entries';
|
|
27
28
|
import { layered, setCss } from '../../core/styles';
|
|
28
29
|
import type { Takeover } from '../../core/takeover';
|
|
29
30
|
import { canon, pageKey, writeUrl } from '../../core/url';
|
|
@@ -36,8 +37,8 @@ import { flowKey, notAbove, onQuickLanded, quickChannel } from './flow';
|
|
|
36
37
|
import { firstPages, targetOf, thrifty, watchIntent } from './intent';
|
|
37
38
|
import type { Page } from '../../api';
|
|
38
39
|
import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, viewport, type ListingView, type SsrState } from './view';
|
|
39
|
-
import { dropStash, readStash, writeStash, type Stash } from './stash';
|
|
40
|
-
import { keepViewport, leavingLink, nearEnd, preconnectImages, restoreScroll, scrollSpot } from './instant';
|
|
40
|
+
import { dropStash, peekStash, readStash, writeStash, type Stash } from './stash';
|
|
41
|
+
import { adoptScroll, holdScroll, keepViewport, leavingLink, nearEnd, preconnectImages, releaseScroll, restoreScroll, scrollSpot } from './instant';
|
|
41
42
|
import { watch } from '../../core/tiles';
|
|
42
43
|
|
|
43
44
|
/** A re-render answered within DIM_MS (no dim shown) fades its list in from this opacity, over FADE_MS. */
|
|
@@ -199,7 +200,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
199
200
|
let rehearsed = false;
|
|
200
201
|
if (!seeded.size) {
|
|
201
202
|
stashed = readStash(route.id, location.href);
|
|
202
|
-
if (stashed)
|
|
203
|
+
if (stashed?.sr) adoptScroll();
|
|
204
|
+
// Too old to render from: where the shopper was only (the results are asked for as on any load).
|
|
205
|
+
if (stashed?.stale) mark(`${route.id} back: stash too old, its scroll only`);
|
|
206
|
+
else if (stashed) {
|
|
203
207
|
seeded.set(pageKey(location.href), stashed.query);
|
|
204
208
|
cache.set(keyOf(stashed.query), stashed.result);
|
|
205
209
|
mark(`${route.id} from stash (${stashed.kind}, ${stashed.result.hits.length} hits)`);
|
|
@@ -308,6 +312,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
308
312
|
private drawnUrl: URL | null = null;
|
|
309
313
|
/** Facet groups the shopper opened or closed, and the text in a group's search box, for the visit. */
|
|
310
314
|
private groupOpen: Record<string, boolean> = {};
|
|
315
|
+
/** Groups shown open for their selection in the visit (ListingView `kept`). */
|
|
316
|
+
private groupKept: Record<string, boolean> = {};
|
|
311
317
|
private groupText: Record<string, string> = {};
|
|
312
318
|
private groupSaid = 0;
|
|
313
319
|
/**
|
|
@@ -317,6 +323,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
317
323
|
*/
|
|
318
324
|
private staged: UiState | null = null;
|
|
319
325
|
private stagedMode = false;
|
|
326
|
+
/** Stops watching the `facetsApply` breakpoints of the open drawer (watchApply()). */
|
|
327
|
+
private unwatchApply: (() => void) | null = null;
|
|
320
328
|
/** Renders started: a render in chunks stops when a newer one has started. */
|
|
321
329
|
private renders = 0;
|
|
322
330
|
// Accessibility (docs/ACCESSIBILITY.md, "Listings"): our status region (and
|
|
@@ -332,6 +340,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
332
340
|
private shownBefore = 0;
|
|
333
341
|
/** The drawer's opener (focus goes back to it) and the attributes lent to its panel. */
|
|
334
342
|
private drawerFrom: HTMLElement | null = null;
|
|
343
|
+
/**
|
|
344
|
+
* The history entry of ours that stands for the open staged drawer (the
|
|
345
|
+
* same URL; Back closes the drawer, core/entries.ts `onOwnBack`), and what
|
|
346
|
+
* waits for our own history.back() of it (an Apply's navigation).
|
|
347
|
+
*/
|
|
348
|
+
private drawerEntry = false;
|
|
349
|
+
private drawerBack: (() => void) | null = null;
|
|
350
|
+
private unBack: (() => void) | null = null;
|
|
335
351
|
private panelAria: ReturnType<typeof borrow> | null = null;
|
|
336
352
|
/** Another state's results awaited (loading()): the timer that dims the list, and whether it has. */
|
|
337
353
|
private wait: { timer: number; dimmed: boolean } | null = null;
|
|
@@ -346,7 +362,18 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
346
362
|
|
|
347
363
|
/** When the page was hidden (pagehide): a restore from the back/forward cache after long is revalidated. */
|
|
348
364
|
private hiddenAt = 0;
|
|
349
|
-
|
|
365
|
+
/** scrollY when a Back stash was last written (leave()): the page hidden from there is stashed already. */
|
|
366
|
+
private leftAt = -1;
|
|
367
|
+
private onPageHide = () => {
|
|
368
|
+
this.hiddenAt = Date.now();
|
|
369
|
+
// Left with more than the first page shown, not by a link of ours (a
|
|
370
|
+
// reload, their menu, the address bar): stashed too, so a reload within
|
|
371
|
+
// the minute shows it as left, in place, and a later load of the entry
|
|
372
|
+
// is placed by Luma (the browser's restoration landed in a first page
|
|
373
|
+
// shorter than where the shopper was). Into the back/forward cache
|
|
374
|
+
// too: it may be evicted there (then Back is a page load).
|
|
375
|
+
if (restoreOn && (this.last?.page ?? 0) > 1 && this.leftAt !== Math.round(scrollY)) this.leave();
|
|
376
|
+
};
|
|
350
377
|
/**
|
|
351
378
|
* Restored from the back/forward cache (pageshow, persisted): the page is as
|
|
352
379
|
* the shopper left it, from memory. Our own Back restore (./stash.ts) is not
|
|
@@ -358,8 +385,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
358
385
|
*/
|
|
359
386
|
private onPageShow = (e: PageTransitionEvent) => {
|
|
360
387
|
if (!e.persisted) return;
|
|
388
|
+
// Where the shopper was: the browser's own restoration is off for the
|
|
389
|
+
// entry (holdScroll), and iOS Safari reads scrollY 0 at this event
|
|
390
|
+
// even when it restores it. Placed again, then the browser's back on.
|
|
391
|
+
const st = peekStash(location.href);
|
|
361
392
|
dropStash(location.href);
|
|
362
393
|
mark(`${route.id} restored from the back/forward cache`);
|
|
394
|
+
if (st?.kind === 'back' && st.scroll && tk.state === 'ours') restoreScroll(this, st.scroll, releaseScroll);
|
|
395
|
+
else releaseScroll();
|
|
363
396
|
const away = this.hiddenAt ? Date.now() - this.hiddenAt : 0;
|
|
364
397
|
if (away > BFCACHE_STALE_MS && this.last && this.lastQ && this.lastQ === this.current) this.revalidate(this.lastQ, this.last, new URL(location.href), this.href);
|
|
365
398
|
};
|
|
@@ -392,7 +425,9 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
392
425
|
if (!this.hasAttribute('role')) this.setAttribute('role', 'region');
|
|
393
426
|
if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', t('label'));
|
|
394
427
|
if (!this.hasAttribute('tabindex')) this.tabIndex = -1;
|
|
395
|
-
this
|
|
428
|
+
// Moved on within this document (an SPA's navigation): its scroll is the host's again.
|
|
429
|
+
this.unsub = onUrlChange(() => (releaseScroll(), this.urlMoved(), this.resync()));
|
|
430
|
+
this.unBack ??= onOwnBack(this.onBack);
|
|
396
431
|
// Product clicks and add-to-carts on our tiles: the framework's, never a template's (core/tiles.ts).
|
|
397
432
|
watch(this, { list: `lbx_${route.id}`, global: ctx.global, results: () => this.last?.hits.map(ctx.result) ?? [] });
|
|
398
433
|
this.addEventListener('click', this.onClick);
|
|
@@ -402,7 +437,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
402
437
|
document.addEventListener('pointerdown', this.onOutside, true);
|
|
403
438
|
addEventListener('pagehide', this.onPageHide);
|
|
404
439
|
addEventListener('pageshow', this.onPageShow);
|
|
440
|
+
addEventListener('scroll', this.onScroll, { passive: true });
|
|
405
441
|
if (prefetchFilters) this.intent ??= watchIntent(this, (el) => this.onIntent(el));
|
|
442
|
+
// Put back by a host re-render with its drawer open: still watched.
|
|
443
|
+
if (this.hasAttribute('data-drawer-open') && !this.unwatchApply) this.watchApply();
|
|
406
444
|
// Microtask: runs before paint (no empty frame) but outside the
|
|
407
445
|
// reconciler's placement pass.
|
|
408
446
|
queueMicrotask(() => this.resync());
|
|
@@ -411,6 +449,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
411
449
|
disconnectedCallback() {
|
|
412
450
|
mark(`${route.tag} disconnected`);
|
|
413
451
|
this.unsub?.();
|
|
452
|
+
this.unBack?.();
|
|
453
|
+
this.unBack = null;
|
|
414
454
|
this.removeEventListener('click', this.onClick);
|
|
415
455
|
this.removeEventListener('keydown', this.onKey);
|
|
416
456
|
this.removeEventListener('focusout', this.onFocusOut);
|
|
@@ -418,12 +458,15 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
418
458
|
document.removeEventListener('pointerdown', this.onOutside, true);
|
|
419
459
|
removeEventListener('pagehide', this.onPageHide);
|
|
420
460
|
removeEventListener('pageshow', this.onPageShow);
|
|
461
|
+
removeEventListener('scroll', this.onScroll);
|
|
421
462
|
this.ctrl?.abort();
|
|
422
463
|
this.ahead?.();
|
|
423
464
|
this.intent?.stop();
|
|
424
465
|
this.intent = null;
|
|
425
466
|
this.sized?.ro.disconnect();
|
|
426
467
|
this.sized = null;
|
|
468
|
+
this.unwatchApply?.();
|
|
469
|
+
this.unwatchApply = null;
|
|
427
470
|
// Not the pending click: the reconciler puts this same element back after a
|
|
428
471
|
// host re-render, and the click's timer then still applies it.
|
|
429
472
|
this.href = ''; // re-sync if we are ever re-attached
|
|
@@ -458,26 +501,94 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
458
501
|
if (!d || !this.contains(d)) {
|
|
459
502
|
// A link to another page (a product): remembered for Back.
|
|
460
503
|
const away = restoreOn && leavingLink(e, this);
|
|
461
|
-
if (away)
|
|
504
|
+
if (away) {
|
|
505
|
+
this.leave(away);
|
|
506
|
+
// Cancelled by a handler after ours (an SPA's router: no page load): its scroll is the host's.
|
|
507
|
+
setTimeout(() => e.defaultPrevented && releaseScroll(), 0);
|
|
508
|
+
}
|
|
462
509
|
return;
|
|
463
510
|
}
|
|
464
511
|
e.preventDefault();
|
|
465
512
|
const what = d.getAttribute('data-lbx-drawer');
|
|
466
513
|
// "apply" commits what the drawer holds (staged) and closes; "close" (and the overlay) discards it.
|
|
467
|
-
if (what === 'apply') this.applyStaged();
|
|
514
|
+
if (what === 'apply') return this.applyStaged(d as HTMLElement);
|
|
468
515
|
this.setDrawer(what === 'open', d as HTMLElement);
|
|
469
516
|
};
|
|
470
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Back on the drawer's entry: the browser's Back (the drawer closes, what
|
|
520
|
+
* it held dropped), or ours (history.back() from closing it: then what
|
|
521
|
+
* waited for it).
|
|
522
|
+
*/
|
|
523
|
+
private onBack = () => {
|
|
524
|
+
const then = this.drawerBack;
|
|
525
|
+
if (then) {
|
|
526
|
+
this.drawerBack = null;
|
|
527
|
+
// After the frame: the browser restores the entry's scroll position
|
|
528
|
+
// after its popstate, which would undo the scroll to the listing's top.
|
|
529
|
+
requestAnimationFrame(() => setTimeout(then, 0));
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
if (!this.drawerEntry || !this.isConnected) return false;
|
|
533
|
+
this.drawerEntry = false;
|
|
534
|
+
this.setDrawer(false);
|
|
535
|
+
return true;
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The URL moved on while the drawer is open, not by us (Back or Forward
|
|
540
|
+
* across our states in a drawer with no entry of its own, the host's
|
|
541
|
+
* navigation): the drawer closes, as the page it was over is gone.
|
|
542
|
+
*/
|
|
543
|
+
private urlMoved() {
|
|
544
|
+
const href = canon(location.href);
|
|
545
|
+
if (href === this.href || href === this.wantedHref) return;
|
|
546
|
+
// Back or Forward to a state of ours: where the shopper was in it, and in the one left.
|
|
547
|
+
if (this.href) this.scrolls.set(this.href, this.y);
|
|
548
|
+
const y = this.scrolls.get(href);
|
|
549
|
+
this.restoreY = y == null ? null : { href, y };
|
|
550
|
+
if (this.hasAttribute('data-drawer-open') && !this.drawerBack) this.setDrawer(false);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Where the shopper was in each state of the visit left by a change of
|
|
555
|
+
* ours or a traversal (canonical href → scrollY), and the one a traversal
|
|
556
|
+
* goes back to: drawn whole (not in steps) and placed there. Bug (a
|
|
557
|
+
* bicycle shop's QA round 4, 2026-10-15): Back after a drawer filter
|
|
558
|
+
* drew the list in steps, the browser's restored scroll was clamped to
|
|
559
|
+
* the first step's short page, and Chromium's scroll anchoring kept the
|
|
560
|
+
* footer in view as the tiles went in above it.
|
|
561
|
+
*/
|
|
562
|
+
private scrolls = new Map<string, number>();
|
|
563
|
+
/** scrollY as of the last scroll event (read there: a read in a popstate after the host's DOM writes forced a style and layout). */
|
|
564
|
+
private y = 0;
|
|
565
|
+
private onScroll = () => void (this.y = Math.round(scrollY));
|
|
566
|
+
private restoreY: { href: string; y: number } | null = null;
|
|
567
|
+
|
|
471
568
|
/** Changes go to the drawer's held state instead of the URL: it is open, where it stages. */
|
|
472
569
|
private staging() {
|
|
473
570
|
return this.stagedMode && this.hasAttribute('data-drawer-open');
|
|
474
571
|
}
|
|
475
572
|
|
|
476
|
-
/**
|
|
477
|
-
|
|
573
|
+
/**
|
|
574
|
+
* The drawer's held changes, applied: one request, one history entry
|
|
575
|
+
* (after the drawer's own entry is gone), and the listing's top in view:
|
|
576
|
+
* a new result set under a shopper far down the old one showed its
|
|
577
|
+
* middle, the count and the first tiles above the viewport.
|
|
578
|
+
*/
|
|
579
|
+
private applyStaged(from?: HTMLElement) {
|
|
478
580
|
const next = this.staged;
|
|
479
581
|
this.staged = null;
|
|
480
|
-
|
|
582
|
+
this.setDrawer(false, from, next ? () => (this.navigate(next), (this.topFor = this.wantedHref), this.toListTop()) : undefined);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** The state an Apply asked for: its render brings the listing's top into view again (late layout, a smooth scroll cut short). */
|
|
586
|
+
private topFor: string | null = null;
|
|
587
|
+
/** The top of the listing in view when it is above the viewport (smooth, unless reduced motion is asked for). */
|
|
588
|
+
private toListTop() {
|
|
589
|
+
if (!this.isConnected || this.getBoundingClientRect().top >= 0) return;
|
|
590
|
+
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
591
|
+
this.scrollIntoView({ block: 'start', behavior: (reduce ? 'instant' : 'smooth') as ScrollBehavior });
|
|
481
592
|
}
|
|
482
593
|
|
|
483
594
|
/** The drawer's held changes dropped: it shows the applied state again. */
|
|
@@ -494,8 +605,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
494
605
|
* cycles), Escape closes it, and focus goes back to the button that opened
|
|
495
606
|
* it. On wide screens the same markup is a sidebar: the role is lent while
|
|
496
607
|
* open only. Without a panel: the buttons' state, Escape and the way back.
|
|
608
|
+
* Where it stages, a history entry of ours stands for it while open:
|
|
609
|
+
* Back closes it (as the autocomplete's phone sheet); closing it otherwise
|
|
610
|
+
* takes the entry back first, then runs `then`.
|
|
497
611
|
*/
|
|
498
|
-
private setDrawer(open: boolean, from?: HTMLElement) {
|
|
612
|
+
private setDrawer(open: boolean, from?: HTMLElement, then?: () => void) {
|
|
499
613
|
const was = this.hasAttribute('data-drawer-open');
|
|
500
614
|
this.toggleAttribute('data-drawer-open', open);
|
|
501
615
|
for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(open));
|
|
@@ -504,6 +618,12 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
504
618
|
if (!open) this.discardStaged();
|
|
505
619
|
if (open && !was) {
|
|
506
620
|
this.stagedMode = atWidth(cfg.facetsApply, 'instant', viewport) === 'staged';
|
|
621
|
+
if (this.stagedMode && !this.drawerEntry)
|
|
622
|
+
try {
|
|
623
|
+
history.pushState(null, '', location.href);
|
|
624
|
+
this.drawerEntry = true;
|
|
625
|
+
} catch {}
|
|
626
|
+
this.watchApply();
|
|
507
627
|
this.drawerFrom = (from?.getAttribute('data-lbx-drawer') === 'open' ? from : document.activeElement) as HTMLElement | null;
|
|
508
628
|
if (panel) {
|
|
509
629
|
const a = (this.panelAria = borrow(panel));
|
|
@@ -519,13 +639,51 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
519
639
|
(focusables(root).find((el) => el.getAttribute('data-lbx-drawer') !== 'open') ?? (root as HTMLElement)).focus();
|
|
520
640
|
});
|
|
521
641
|
} else if (!open && was) {
|
|
642
|
+
this.unwatchApply?.();
|
|
643
|
+
this.unwatchApply = null;
|
|
522
644
|
this.panelAria?.restore();
|
|
523
645
|
this.panelAria = null;
|
|
524
646
|
const back = this.drawerFrom;
|
|
525
647
|
this.drawerFrom = null;
|
|
526
648
|
const at = document.activeElement;
|
|
527
649
|
if (back?.isConnected && (!at || at === document.body || this.contains(at))) afterFrame(() => back.focus());
|
|
650
|
+
if (this.drawerEntry) {
|
|
651
|
+
this.drawerEntry = false;
|
|
652
|
+
const done = then ?? (() => {});
|
|
653
|
+
this.drawerBack = done;
|
|
654
|
+
history.back();
|
|
655
|
+
// A Back the browser does not deliver (an entry it dropped): on after a while.
|
|
656
|
+
setTimeout(() => {
|
|
657
|
+
if (this.drawerBack !== done) return;
|
|
658
|
+
this.drawerBack = null;
|
|
659
|
+
done();
|
|
660
|
+
}, 600);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
528
663
|
}
|
|
664
|
+
then?.();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The open drawer closes when the viewport crosses into another
|
|
669
|
+
* `facetsApply` mode (a phone rotated, a window widened): the drawer was a
|
|
670
|
+
* phone's, and what it held was never confirmed. Regression (a bicycle
|
|
671
|
+
* shop, `{ 0: 'staged', 992: 'instant' }`, 2026-10-14): a value ticked in
|
|
672
|
+
* the drawer at 820 px stayed ticked in the 992 px sidebar with the
|
|
673
|
+
* results unfiltered and no apply button there, and rotating back showed
|
|
674
|
+
* the full-screen drawer again (`data-drawer-open` had stayed). Dropping
|
|
675
|
+
* is safer than applying: the shopper never pressed Apply.
|
|
676
|
+
*/
|
|
677
|
+
private watchApply() {
|
|
678
|
+
const v = cfg.facetsApply;
|
|
679
|
+
if (!v || typeof v !== 'object' || typeof matchMedia !== 'function') return;
|
|
680
|
+
const mode = atWidth(v, 'instant', viewport);
|
|
681
|
+
const lists = Object.keys(v).map(Number).filter((w) => w > 0).map((w) => matchMedia(`(min-width: ${w}px)`));
|
|
682
|
+
const on = () => {
|
|
683
|
+
if (this.hasAttribute('data-drawer-open') && atWidth(v, 'instant', viewport) !== mode) this.setDrawer(false);
|
|
684
|
+
};
|
|
685
|
+
for (const l of lists) l.addEventListener('change', on);
|
|
686
|
+
this.unwatchApply = () => lists.forEach((l) => l.removeEventListener('change', on));
|
|
529
687
|
}
|
|
530
688
|
|
|
531
689
|
/**
|
|
@@ -674,6 +832,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
674
832
|
if (!this.inFlight) this.setBusy(false);
|
|
675
833
|
return;
|
|
676
834
|
}
|
|
835
|
+
if (this.href) this.scrolls.set(this.href, this.y);
|
|
836
|
+
this.restoreY = null;
|
|
677
837
|
this.wanted = next;
|
|
678
838
|
this.wantedHref = canon(url.href);
|
|
679
839
|
// Respond first, work later: the fetch and the history write run in later
|
|
@@ -832,7 +992,15 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
832
992
|
if (c.signal.aborted || this.href !== href || (p.quickPending && !notAbove(route.id, 'quick'))) return;
|
|
833
993
|
mark(`${route.id} products before their facets`);
|
|
834
994
|
this.message = null;
|
|
835
|
-
|
|
995
|
+
// Another state of the visit shown (a filter, a sort): its sidebar
|
|
996
|
+
// stays until the new facets come, as from the intent prefetch.
|
|
997
|
+
// Bug (a bicycle shop's QA round 3, 2026-10-15): drawn with no
|
|
998
|
+
// facets, the sidebar collapsed to one group for ~250 ms and
|
|
999
|
+
// Chromium's scroll anchoring threw the page to its top.
|
|
1000
|
+
const last = this.last;
|
|
1001
|
+
const lq = this.lastQ;
|
|
1002
|
+
const keep = last && lq && tk.state === 'ours' && visitOf(lq) === visitOf(q);
|
|
1003
|
+
this.render(q, keep ? { ...p, facets: last.facets, facetsAll: last.facetsAll } : p, url);
|
|
836
1004
|
productsShown = true;
|
|
837
1005
|
});
|
|
838
1006
|
if (productsShown) this.quiet = true; // said with the products
|
|
@@ -918,13 +1086,27 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
918
1086
|
* The shopper follows a link to another page (a product): the listing as
|
|
919
1087
|
* it is, and where they are, for Back (./stash.ts). Only what is shown.
|
|
920
1088
|
*/
|
|
921
|
-
private leave(a
|
|
1089
|
+
private leave(a?: HTMLAnchorElement) {
|
|
922
1090
|
if (!this.last || !this.lastQ || this.lastQ !== this.current || this.inFlight) return;
|
|
923
|
-
|
|
1091
|
+
this.leftAt = Math.round(scrollY);
|
|
1092
|
+
// The browser's own restoration off for this entry: Luma places the page after its render (holdScroll).
|
|
1093
|
+
const sr = holdScroll();
|
|
1094
|
+
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`);
|
|
1095
|
+
else releaseScroll();
|
|
924
1096
|
}
|
|
925
1097
|
|
|
926
1098
|
/** After a render: back where the shopper was (a stashed page), the next page when they near the end, the image host. */
|
|
927
1099
|
private afterRender(q: Query, res: ListingResult, url: URL) {
|
|
1100
|
+
const back = this.restoreY;
|
|
1101
|
+
if (back && back.href === this.href && !res.partial) {
|
|
1102
|
+
this.restoreY = null;
|
|
1103
|
+
// After the frame, with layout clean (a read now forced it: the main-thread gates).
|
|
1104
|
+
if (tk.state === 'ours') afterFrame(() => this.href === back.href && restoreScroll(this, { y: back.y }));
|
|
1105
|
+
}
|
|
1106
|
+
if (this.topFor && this.topFor === this.href) {
|
|
1107
|
+
this.topFor = null;
|
|
1108
|
+
afterFrame(() => this.toListTop());
|
|
1109
|
+
}
|
|
928
1110
|
// Whether the facets sit above the products (notAbove): read a frame
|
|
929
1111
|
// later, when layout is clean (the read costs only itself). Shown only.
|
|
930
1112
|
// The quick types likewise (notAbove), where some are shown.
|
|
@@ -956,16 +1138,19 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
956
1138
|
const href = this.href;
|
|
957
1139
|
// After the reveal (a font wait may delay it): ours must be laid out to find the tile.
|
|
958
1140
|
const at = (n = 0) =>
|
|
959
|
-
this.href === href && (tk.state === 'ours' ? (restoreScroll(this, st.scroll ?? { y: 0 }), mark(`${route.id} scroll restored`)) : n < 120
|
|
1141
|
+
this.href === href && (tk.state === 'ours' ? (restoreScroll(this, st.scroll ?? { y: 0 }, releaseScroll), mark(`${route.id} scroll restored`)) : n < 120 ? requestAnimationFrame(() => at(n + 1)) : releaseScroll());
|
|
960
1142
|
at();
|
|
961
1143
|
// Silently fresh after a while away: asked again in the background,
|
|
962
1144
|
// re-rendered only when the results changed. A quick round trip to
|
|
963
1145
|
// a product shows the listing as left (a reshuffle in front of the
|
|
964
|
-
// shopper, on an API that orders ties anew, was the bug).
|
|
965
|
-
|
|
966
|
-
|
|
1146
|
+
// shopper, on an API that orders ties anew, was the bug). A stale
|
|
1147
|
+
// stash's results were not used: these are fresh.
|
|
1148
|
+
if (!st.stale) {
|
|
1149
|
+
if (Date.now() - st.at > BFCACHE_STALE_MS) setTimeout(() => this.revalidate(q, st.result, url, href), 1500);
|
|
1150
|
+
else mark(`${route.id} back: as left, not revalidated`);
|
|
1151
|
+
}
|
|
967
1152
|
}
|
|
968
|
-
}
|
|
1153
|
+
} else if (st) releaseScroll();
|
|
969
1154
|
this.ahead?.();
|
|
970
1155
|
this.ahead = null;
|
|
971
1156
|
if (skipOut) this.sizeTiles();
|
|
@@ -1131,6 +1316,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1131
1316
|
const at = (st: UiState) => canon(route.write(new URL(location.href), st).href);
|
|
1132
1317
|
return {
|
|
1133
1318
|
open: this.groupOpen,
|
|
1319
|
+
kept: this.groupKept,
|
|
1320
|
+
keptOpen: (name) => void (this.groupKept[name] = true),
|
|
1134
1321
|
openDefault: atWidth(cfg.facetGroups?.open, true, viewport),
|
|
1135
1322
|
text: this.groupText,
|
|
1136
1323
|
apply: atWidth(cfg.facetsApply, 'instant', viewport),
|
|
@@ -1271,12 +1458,13 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1271
1458
|
const total = () => void (this.dataset.total = String(res.total));
|
|
1272
1459
|
if (tk.state !== 'hold') total();
|
|
1273
1460
|
const visit = JSON.stringify([q.q, q.scope]);
|
|
1274
|
-
if (visit !== this.uiVisit) (this.uiVisit = visit), (this.ui = {}), (this.groupOpen = {}), (this.groupText = {}), (this.staged = null);
|
|
1461
|
+
if (visit !== this.uiVisit) (this.uiVisit = visit), (this.ui = {}), (this.groupOpen = {}), (this.groupKept = {}), (this.groupText = {}), (this.staged = null);
|
|
1275
1462
|
const d0 = performance.now();
|
|
1276
1463
|
let data = this.dataFor(q, res, url);
|
|
1277
1464
|
/** The scope's own time (ms): a re-render's first step goes to the next frame when the two would not fit BUDGET. */
|
|
1278
1465
|
const dataMs = performance.now() - d0;
|
|
1279
|
-
|
|
1466
|
+
// Back or Forward to a state with a place in it: every tile at once, so the page is as tall as it was.
|
|
1467
|
+
let cut: number | undefined = this.restoreY?.href === this.href ? Infinity : undefined;
|
|
1280
1468
|
this.drawn = data;
|
|
1281
1469
|
// Server markup that is not hydrated (no cached result for it: its facets
|
|
1282
1470
|
// were missing) is replaced, not rendered after.
|
|
@@ -24,36 +24,71 @@ export const leavingLink = (e: MouseEvent, root: Element): HTMLAnchorElement | n
|
|
|
24
24
|
return a;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
/** A link laid out on the page: not display:none (a tile may carry a phone-only copy of a link, hidden on wider screens). */
|
|
28
|
+
const shown = (a: Element) => a.getClientRects().length > 0;
|
|
29
|
+
/** The links to `href` in root that are laid out (a tile has several: its image, its title, its compare links). */
|
|
30
|
+
const linksTo = (root: Element, href: string) => [...root.querySelectorAll<HTMLAnchorElement>('a[href]')].filter((a) => a.href === href && shown(a));
|
|
31
|
+
/** The `n`th laid-out link to `href` in root (the one followed, when it can be told), else the first. */
|
|
32
|
+
const linkTo = (root: Element, href: string, n = 0) => {
|
|
33
|
+
const as = linksTo(root, href);
|
|
34
|
+
return as[n] ?? as[0] ?? null;
|
|
31
35
|
};
|
|
32
36
|
|
|
33
|
-
/** Where the shopper
|
|
34
|
-
export
|
|
37
|
+
/** Where the shopper was (a Back stash's `scroll`). */
|
|
38
|
+
export interface Spot {
|
|
39
|
+
y: number;
|
|
40
|
+
href?: string;
|
|
41
|
+
top?: number;
|
|
42
|
+
n?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Where the shopper is, pinned to the link they follow (layout above it may
|
|
47
|
+
* differ on the way back): which of the laid-out links to its href it is
|
|
48
|
+
* (`n`), and its top. Without a link (the page left otherwise), the first
|
|
49
|
+
* tile link in the viewport.
|
|
50
|
+
*/
|
|
51
|
+
export const scrollSpot = (root: Element, a?: HTMLAnchorElement | null): Spot => {
|
|
52
|
+
a ??= [...root.querySelectorAll<HTMLAnchorElement>('[data-lbx-list] a[href]')].find((l) => shown(l) && l.getBoundingClientRect().top >= 0) ?? null;
|
|
53
|
+
if (!a) return { y: Math.round(scrollY) };
|
|
54
|
+
const as = linksTo(root, a.href);
|
|
55
|
+
const n = as.indexOf(a);
|
|
56
|
+
const pin = n >= 0 ? a : as[0] ?? a;
|
|
57
|
+
return { y: Math.round(scrollY), href: a.href, top: Math.round(pin.getBoundingClientRect().top), ...(n > 0 ? { n } : {}) };
|
|
58
|
+
};
|
|
35
59
|
|
|
36
60
|
const scroll = (top: number) => scrollTo({ top, left: scrollX, behavior: 'instant' as ScrollBehavior });
|
|
37
61
|
|
|
38
62
|
/**
|
|
39
63
|
* Back where the shopper was: the followed link at the same place in the
|
|
40
|
-
* viewport (scrollY when it is gone
|
|
41
|
-
*
|
|
42
|
-
*
|
|
64
|
+
* viewport (scrollY when it is gone; the top of the listing when scrollY
|
|
65
|
+
* would land past its end: a listing shorter than the one left, the link
|
|
66
|
+
* on a page not shown). Late layout
|
|
67
|
+
* above us (their images, banners, fonts) moves it: followed for a while,
|
|
68
|
+
* until the shopper scrolls, taps or types themselves; then `after`.
|
|
43
69
|
*/
|
|
44
|
-
export const restoreScroll = (root: Element, s:
|
|
70
|
+
export const restoreScroll = (root: Element, s: Spot, after?: () => void) => {
|
|
45
71
|
let done = false;
|
|
72
|
+
/** The link gone and the listing too short for scrollY: its top, once decided. */
|
|
73
|
+
let short = false;
|
|
46
74
|
const place = () => {
|
|
47
75
|
if (done) return;
|
|
48
|
-
const a = s.href ? linkTo(root, s.href) : null;
|
|
76
|
+
const a = s.href ? linkTo(root, s.href, s.n) : null;
|
|
49
77
|
if (a) {
|
|
50
78
|
const d = a.getBoundingClientRect().top - (s.top ?? 0);
|
|
51
79
|
if (Math.abs(d) >= 1) scroll(scrollY + d);
|
|
52
|
-
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const box = root.getBoundingClientRect();
|
|
83
|
+
// A link in the listing was the place: gone, and the listing ends above where the viewport was (fewer pages than left).
|
|
84
|
+
if (!short && s.href && s.y > 0 && s.y + innerHeight > box.bottom + scrollY) short = true;
|
|
85
|
+
const y = short ? Math.max(0, Math.round(box.top + scrollY)) : s.y;
|
|
86
|
+
if (Math.abs(scrollY - y) >= 1) scroll(y);
|
|
53
87
|
};
|
|
54
88
|
const STOP = ['wheel', 'touchstart', 'keydown', 'pointerdown'];
|
|
55
89
|
const ro = new ResizeObserver(() => place());
|
|
56
90
|
const stop = () => {
|
|
91
|
+
if (!done) after?.();
|
|
57
92
|
done = true;
|
|
58
93
|
ro.disconnect();
|
|
59
94
|
for (const t of STOP) removeEventListener(t, stop, true);
|
|
@@ -64,24 +99,49 @@ export const restoreScroll = (root: Element, s: { y: number; href?: string; top?
|
|
|
64
99
|
setTimeout(stop, 2500);
|
|
65
100
|
};
|
|
66
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The browser's own scroll restoration, off for the entry the shopper leaves
|
|
104
|
+
* with a Back stash (`history.scrollRestoration = 'manual'`): a load of it
|
|
105
|
+
* (Back that is not a restore from memory: iOS Safari's) is placed by Luma
|
|
106
|
+
* after its render. The browser's restoration ran before it, on a page not
|
|
107
|
+
* grown to the stashed pages yet, and could land after Luma's place too.
|
|
108
|
+
* Only from `auto`: a host that set it (Turbo, a router) keeps its own.
|
|
109
|
+
* Back on (`releaseScroll`) once Luma has placed the page, or when the
|
|
110
|
+
* document moves on within itself (an SPA's navigation: theirs to restore).
|
|
111
|
+
*/
|
|
112
|
+
let ownScroll = false;
|
|
113
|
+
/** Off for this entry, when it is the browser's (or already ours): 1, for the stash's `sr`. */
|
|
114
|
+
export const holdScroll = (): 1 | undefined => {
|
|
115
|
+
try {
|
|
116
|
+
if (!ownScroll && history.scrollRestoration !== 'auto') return undefined;
|
|
117
|
+
history.scrollRestoration = 'manual';
|
|
118
|
+
ownScroll = true;
|
|
119
|
+
return 1;
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
/** A page loaded on an entry Luma turned it off for (the stash's `sr`): Luma's to turn back on. */
|
|
125
|
+
export const adoptScroll = () => void (ownScroll = true);
|
|
126
|
+
/** Back to the browser's, if Luma turned it off. */
|
|
127
|
+
export const releaseScroll = () => {
|
|
128
|
+
if (!ownScroll) return;
|
|
129
|
+
ownScroll = false;
|
|
130
|
+
try {
|
|
131
|
+
if (history.scrollRestoration === 'manual') history.scrollRestoration = 'auto';
|
|
132
|
+
} catch {}
|
|
133
|
+
};
|
|
134
|
+
|
|
67
135
|
/**
|
|
68
136
|
* Keep what the shopper is looking at in place across a re-render (a
|
|
69
137
|
* revalidated result): the first tile link in the viewport stays where it is.
|
|
70
138
|
*/
|
|
71
139
|
export const keepViewport = (root: Element, f: () => void) => {
|
|
72
|
-
|
|
73
|
-
for (const a of root.querySelectorAll<HTMLAnchorElement>('[data-lbx-list] a[href]')) {
|
|
74
|
-
const top = a.getBoundingClientRect().top;
|
|
75
|
-
if (top >= 0) {
|
|
76
|
-
pin = { href: a.href, top };
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
140
|
+
const pin = scrollSpot(root);
|
|
80
141
|
f();
|
|
81
|
-
|
|
82
|
-
const a = linkTo(root, pin.href);
|
|
142
|
+
const a = pin.href ? linkTo(root, pin.href, pin.n) : null;
|
|
83
143
|
if (a) {
|
|
84
|
-
const d = a.getBoundingClientRect().top - pin.top;
|
|
144
|
+
const d = a.getBoundingClientRect().top - (pin.top ?? 0);
|
|
85
145
|
if (Math.abs(d) >= 1) scroll(scrollY + d);
|
|
86
146
|
}
|
|
87
147
|
};
|