@luigisbox/luma 0.1.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 +208 -76
- package/build/vite.mjs +9 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +10 -5
- package/docs/ACCESSIBILITY.md +9 -5
- package/docs/INTEGRATION-PERFORMANCE.md +5 -6
- package/package.json +1 -1
- package/parity/hold.mjs +1 -1
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/token.ts +3 -2
- package/src/core/entries.ts +10 -6
- package/src/core/integration.ts +6 -0
- package/src/core/kept.ts +25 -0
- package/src/core/stash.ts +24 -4
- package/src/core/tiles.ts +137 -0
- package/src/main.ts +54 -4
- package/src/types.ts +29 -2
- package/src/widgets/autocomplete/index.ts +206 -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 +212 -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/recommender/index.ts +15 -4
- package/src/widgets/search/range.ts +40 -18
- package/src/widgets/search/style-search.css +2 -2
|
@@ -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,9 @@ 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';
|
|
42
|
+
import { watch } from '../../core/tiles';
|
|
41
43
|
|
|
42
44
|
/** A re-render answered within DIM_MS (no dim shown) fades its list in from this opacity, over FADE_MS. */
|
|
43
45
|
const FADE_FROM = 0.6;
|
|
@@ -198,7 +200,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
198
200
|
let rehearsed = false;
|
|
199
201
|
if (!seeded.size) {
|
|
200
202
|
stashed = readStash(route.id, location.href);
|
|
201
|
-
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) {
|
|
202
207
|
seeded.set(pageKey(location.href), stashed.query);
|
|
203
208
|
cache.set(keyOf(stashed.query), stashed.result);
|
|
204
209
|
mark(`${route.id} from stash (${stashed.kind}, ${stashed.result.hits.length} hits)`);
|
|
@@ -307,6 +312,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
307
312
|
private drawnUrl: URL | null = null;
|
|
308
313
|
/** Facet groups the shopper opened or closed, and the text in a group's search box, for the visit. */
|
|
309
314
|
private groupOpen: Record<string, boolean> = {};
|
|
315
|
+
/** Groups shown open for their selection in the visit (ListingView `kept`). */
|
|
316
|
+
private groupKept: Record<string, boolean> = {};
|
|
310
317
|
private groupText: Record<string, string> = {};
|
|
311
318
|
private groupSaid = 0;
|
|
312
319
|
/**
|
|
@@ -316,6 +323,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
316
323
|
*/
|
|
317
324
|
private staged: UiState | null = null;
|
|
318
325
|
private stagedMode = false;
|
|
326
|
+
/** Stops watching the `facetsApply` breakpoints of the open drawer (watchApply()). */
|
|
327
|
+
private unwatchApply: (() => void) | null = null;
|
|
319
328
|
/** Renders started: a render in chunks stops when a newer one has started. */
|
|
320
329
|
private renders = 0;
|
|
321
330
|
// Accessibility (docs/ACCESSIBILITY.md, "Listings"): our status region (and
|
|
@@ -331,6 +340,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
331
340
|
private shownBefore = 0;
|
|
332
341
|
/** The drawer's opener (focus goes back to it) and the attributes lent to its panel. */
|
|
333
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;
|
|
334
351
|
private panelAria: ReturnType<typeof borrow> | null = null;
|
|
335
352
|
/** Another state's results awaited (loading()): the timer that dims the list, and whether it has. */
|
|
336
353
|
private wait: { timer: number; dimmed: boolean } | null = null;
|
|
@@ -345,7 +362,18 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
345
362
|
|
|
346
363
|
/** When the page was hidden (pagehide): a restore from the back/forward cache after long is revalidated. */
|
|
347
364
|
private hiddenAt = 0;
|
|
348
|
-
|
|
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
|
+
};
|
|
349
377
|
/**
|
|
350
378
|
* Restored from the back/forward cache (pageshow, persisted): the page is as
|
|
351
379
|
* the shopper left it, from memory. Our own Back restore (./stash.ts) is not
|
|
@@ -357,8 +385,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
357
385
|
*/
|
|
358
386
|
private onPageShow = (e: PageTransitionEvent) => {
|
|
359
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);
|
|
360
392
|
dropStash(location.href);
|
|
361
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();
|
|
362
396
|
const away = this.hiddenAt ? Date.now() - this.hiddenAt : 0;
|
|
363
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);
|
|
364
398
|
};
|
|
@@ -391,7 +425,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
391
425
|
if (!this.hasAttribute('role')) this.setAttribute('role', 'region');
|
|
392
426
|
if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', t('label'));
|
|
393
427
|
if (!this.hasAttribute('tabindex')) this.tabIndex = -1;
|
|
394
|
-
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);
|
|
431
|
+
// Product clicks and add-to-carts on our tiles: the framework's, never a template's (core/tiles.ts).
|
|
432
|
+
watch(this, { list: `lbx_${route.id}`, global: ctx.global, results: () => this.last?.hits.map(ctx.result) ?? [] });
|
|
395
433
|
this.addEventListener('click', this.onClick);
|
|
396
434
|
this.addEventListener('keydown', this.onKey);
|
|
397
435
|
this.addEventListener('focusout', this.onFocusOut);
|
|
@@ -399,7 +437,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
399
437
|
document.addEventListener('pointerdown', this.onOutside, true);
|
|
400
438
|
addEventListener('pagehide', this.onPageHide);
|
|
401
439
|
addEventListener('pageshow', this.onPageShow);
|
|
440
|
+
addEventListener('scroll', this.onScroll, { passive: true });
|
|
402
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();
|
|
403
444
|
// Microtask: runs before paint (no empty frame) but outside the
|
|
404
445
|
// reconciler's placement pass.
|
|
405
446
|
queueMicrotask(() => this.resync());
|
|
@@ -408,6 +449,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
408
449
|
disconnectedCallback() {
|
|
409
450
|
mark(`${route.tag} disconnected`);
|
|
410
451
|
this.unsub?.();
|
|
452
|
+
this.unBack?.();
|
|
453
|
+
this.unBack = null;
|
|
411
454
|
this.removeEventListener('click', this.onClick);
|
|
412
455
|
this.removeEventListener('keydown', this.onKey);
|
|
413
456
|
this.removeEventListener('focusout', this.onFocusOut);
|
|
@@ -415,12 +458,15 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
415
458
|
document.removeEventListener('pointerdown', this.onOutside, true);
|
|
416
459
|
removeEventListener('pagehide', this.onPageHide);
|
|
417
460
|
removeEventListener('pageshow', this.onPageShow);
|
|
461
|
+
removeEventListener('scroll', this.onScroll);
|
|
418
462
|
this.ctrl?.abort();
|
|
419
463
|
this.ahead?.();
|
|
420
464
|
this.intent?.stop();
|
|
421
465
|
this.intent = null;
|
|
422
466
|
this.sized?.ro.disconnect();
|
|
423
467
|
this.sized = null;
|
|
468
|
+
this.unwatchApply?.();
|
|
469
|
+
this.unwatchApply = null;
|
|
424
470
|
// Not the pending click: the reconciler puts this same element back after a
|
|
425
471
|
// host re-render, and the click's timer then still applies it.
|
|
426
472
|
this.href = ''; // re-sync if we are ever re-attached
|
|
@@ -455,26 +501,94 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
455
501
|
if (!d || !this.contains(d)) {
|
|
456
502
|
// A link to another page (a product): remembered for Back.
|
|
457
503
|
const away = restoreOn && leavingLink(e, this);
|
|
458
|
-
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
|
+
}
|
|
459
509
|
return;
|
|
460
510
|
}
|
|
461
511
|
e.preventDefault();
|
|
462
512
|
const what = d.getAttribute('data-lbx-drawer');
|
|
463
513
|
// "apply" commits what the drawer holds (staged) and closes; "close" (and the overlay) discards it.
|
|
464
|
-
if (what === 'apply') this.applyStaged();
|
|
514
|
+
if (what === 'apply') return this.applyStaged(d as HTMLElement);
|
|
465
515
|
this.setDrawer(what === 'open', d as HTMLElement);
|
|
466
516
|
};
|
|
467
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
|
+
|
|
468
568
|
/** Changes go to the drawer's held state instead of the URL: it is open, where it stages. */
|
|
469
569
|
private staging() {
|
|
470
570
|
return this.stagedMode && this.hasAttribute('data-drawer-open');
|
|
471
571
|
}
|
|
472
572
|
|
|
473
|
-
/**
|
|
474
|
-
|
|
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) {
|
|
475
580
|
const next = this.staged;
|
|
476
581
|
this.staged = null;
|
|
477
|
-
|
|
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 });
|
|
478
592
|
}
|
|
479
593
|
|
|
480
594
|
/** The drawer's held changes dropped: it shows the applied state again. */
|
|
@@ -491,8 +605,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
491
605
|
* cycles), Escape closes it, and focus goes back to the button that opened
|
|
492
606
|
* it. On wide screens the same markup is a sidebar: the role is lent while
|
|
493
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`.
|
|
494
611
|
*/
|
|
495
|
-
private setDrawer(open: boolean, from?: HTMLElement) {
|
|
612
|
+
private setDrawer(open: boolean, from?: HTMLElement, then?: () => void) {
|
|
496
613
|
const was = this.hasAttribute('data-drawer-open');
|
|
497
614
|
this.toggleAttribute('data-drawer-open', open);
|
|
498
615
|
for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(open));
|
|
@@ -501,6 +618,12 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
501
618
|
if (!open) this.discardStaged();
|
|
502
619
|
if (open && !was) {
|
|
503
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();
|
|
504
627
|
this.drawerFrom = (from?.getAttribute('data-lbx-drawer') === 'open' ? from : document.activeElement) as HTMLElement | null;
|
|
505
628
|
if (panel) {
|
|
506
629
|
const a = (this.panelAria = borrow(panel));
|
|
@@ -516,13 +639,51 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
516
639
|
(focusables(root).find((el) => el.getAttribute('data-lbx-drawer') !== 'open') ?? (root as HTMLElement)).focus();
|
|
517
640
|
});
|
|
518
641
|
} else if (!open && was) {
|
|
642
|
+
this.unwatchApply?.();
|
|
643
|
+
this.unwatchApply = null;
|
|
519
644
|
this.panelAria?.restore();
|
|
520
645
|
this.panelAria = null;
|
|
521
646
|
const back = this.drawerFrom;
|
|
522
647
|
this.drawerFrom = null;
|
|
523
648
|
const at = document.activeElement;
|
|
524
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
|
+
}
|
|
525
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));
|
|
526
687
|
}
|
|
527
688
|
|
|
528
689
|
/**
|
|
@@ -671,6 +832,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
671
832
|
if (!this.inFlight) this.setBusy(false);
|
|
672
833
|
return;
|
|
673
834
|
}
|
|
835
|
+
if (this.href) this.scrolls.set(this.href, this.y);
|
|
836
|
+
this.restoreY = null;
|
|
674
837
|
this.wanted = next;
|
|
675
838
|
this.wantedHref = canon(url.href);
|
|
676
839
|
// Respond first, work later: the fetch and the history write run in later
|
|
@@ -829,7 +992,15 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
829
992
|
if (c.signal.aborted || this.href !== href || (p.quickPending && !notAbove(route.id, 'quick'))) return;
|
|
830
993
|
mark(`${route.id} products before their facets`);
|
|
831
994
|
this.message = null;
|
|
832
|
-
|
|
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);
|
|
833
1004
|
productsShown = true;
|
|
834
1005
|
});
|
|
835
1006
|
if (productsShown) this.quiet = true; // said with the products
|
|
@@ -915,13 +1086,27 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
915
1086
|
* The shopper follows a link to another page (a product): the listing as
|
|
916
1087
|
* it is, and where they are, for Back (./stash.ts). Only what is shown.
|
|
917
1088
|
*/
|
|
918
|
-
private leave(a
|
|
1089
|
+
private leave(a?: HTMLAnchorElement) {
|
|
919
1090
|
if (!this.last || !this.lastQ || this.lastQ !== this.current || this.inFlight) return;
|
|
920
|
-
|
|
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();
|
|
921
1096
|
}
|
|
922
1097
|
|
|
923
1098
|
/** After a render: back where the shopper was (a stashed page), the next page when they near the end, the image host. */
|
|
924
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
|
+
}
|
|
925
1110
|
// Whether the facets sit above the products (notAbove): read a frame
|
|
926
1111
|
// later, when layout is clean (the read costs only itself). Shown only.
|
|
927
1112
|
// The quick types likewise (notAbove), where some are shown.
|
|
@@ -953,16 +1138,19 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
953
1138
|
const href = this.href;
|
|
954
1139
|
// After the reveal (a font wait may delay it): ours must be laid out to find the tile.
|
|
955
1140
|
const at = (n = 0) =>
|
|
956
|
-
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());
|
|
957
1142
|
at();
|
|
958
1143
|
// Silently fresh after a while away: asked again in the background,
|
|
959
1144
|
// re-rendered only when the results changed. A quick round trip to
|
|
960
1145
|
// a product shows the listing as left (a reshuffle in front of the
|
|
961
|
-
// shopper, on an API that orders ties anew, was the bug).
|
|
962
|
-
|
|
963
|
-
|
|
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
|
+
}
|
|
964
1152
|
}
|
|
965
|
-
}
|
|
1153
|
+
} else if (st) releaseScroll();
|
|
966
1154
|
this.ahead?.();
|
|
967
1155
|
this.ahead = null;
|
|
968
1156
|
if (skipOut) this.sizeTiles();
|
|
@@ -1128,6 +1316,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1128
1316
|
const at = (st: UiState) => canon(route.write(new URL(location.href), st).href);
|
|
1129
1317
|
return {
|
|
1130
1318
|
open: this.groupOpen,
|
|
1319
|
+
kept: this.groupKept,
|
|
1320
|
+
keptOpen: (name) => void (this.groupKept[name] = true),
|
|
1131
1321
|
openDefault: atWidth(cfg.facetGroups?.open, true, viewport),
|
|
1132
1322
|
text: this.groupText,
|
|
1133
1323
|
apply: atWidth(cfg.facetsApply, 'instant', viewport),
|
|
@@ -1268,12 +1458,13 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
|
|
|
1268
1458
|
const total = () => void (this.dataset.total = String(res.total));
|
|
1269
1459
|
if (tk.state !== 'hold') total();
|
|
1270
1460
|
const visit = JSON.stringify([q.q, q.scope]);
|
|
1271
|
-
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);
|
|
1272
1462
|
const d0 = performance.now();
|
|
1273
1463
|
let data = this.dataFor(q, res, url);
|
|
1274
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. */
|
|
1275
1465
|
const dataMs = performance.now() - d0;
|
|
1276
|
-
|
|
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;
|
|
1277
1468
|
this.drawn = data;
|
|
1278
1469
|
// Server markup that is not hydrated (no cached result for it: its facets
|
|
1279
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
|
};
|
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
// (widgets/search/speculate.ts). Used by a plain navigation (or a prerender
|
|
6
6
|
// of the page) within its `ttl` (2 minutes), once.
|
|
7
7
|
// - `back`: the listing as the shopper left it for a product page (pages
|
|
8
|
-
// 1..N, where they were). Used by a back/forward load within 30 minutes
|
|
9
|
-
//
|
|
8
|
+
// 1..N, where they were). Used by a back/forward load within 30 minutes, or
|
|
9
|
+
// by a reload or a navigation from the page itself within a minute (a link
|
|
10
|
+
// whose server redirects back to it); the listing then restores the scroll
|
|
11
|
+
// position and revalidates in the background. Otherwise, on a load of the
|
|
12
|
+
// entry left (back/forward, reload): where they were only (`stale`), the
|
|
13
|
+
// browser's own restoration being off for it (`sr`).
|
|
10
14
|
// Bounded: at most 3 entries (one `spec`), 400k characters each; a full store
|
|
11
15
|
// drops ours first. Best-effort: storage may be unavailable, never required.
|
|
12
16
|
|
|
@@ -14,6 +18,7 @@ import { navigation, STASH, stashFits, stashKey } from '../../core/stash';
|
|
|
14
18
|
import { pageKey } from '../../core/page';
|
|
15
19
|
import type { ListingResult } from './query';
|
|
16
20
|
import type { Query } from './state';
|
|
21
|
+
import type { Spot } from './instant';
|
|
17
22
|
|
|
18
23
|
export interface Stash {
|
|
19
24
|
v: 1;
|
|
@@ -26,8 +31,12 @@ export interface Stash {
|
|
|
26
31
|
route: string;
|
|
27
32
|
query: Query;
|
|
28
33
|
result: ListingResult;
|
|
29
|
-
/** back: the link the shopper followed (its href, its top in the viewport) and scrollY where it is gone. */
|
|
30
|
-
scroll?:
|
|
34
|
+
/** back: the link the shopper followed (its href, which of the laid-out links to it, its top in the viewport) and scrollY where it is gone. */
|
|
35
|
+
scroll?: Spot;
|
|
36
|
+
/** back: Luma turned the browser's own scroll restoration off for the entry left (`history.scrollRestoration`): back on once restored. */
|
|
37
|
+
sr?: 1;
|
|
38
|
+
/** Read only: too old to render from, where the shopper was is all that is used. */
|
|
39
|
+
stale?: true;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
const MAX_CHARS = 400_000;
|
|
@@ -48,7 +57,7 @@ const entries = (except?: string) => {
|
|
|
48
57
|
return out.sort((a, b) => a.at - b.at);
|
|
49
58
|
};
|
|
50
59
|
|
|
51
|
-
/** The stashed result this page load may render from, or null. */
|
|
60
|
+
/** The stashed result this page load may render from, or null. `stale`: a Back entry too old to render, for its scroll only. */
|
|
52
61
|
export const readStash = (route: string, href: string): Stash | null => {
|
|
53
62
|
try {
|
|
54
63
|
const key = stashKey(href);
|
|
@@ -59,8 +68,22 @@ export const readStash = (route: string, href: string): Stash | null => {
|
|
|
59
68
|
const nav = navigation();
|
|
60
69
|
const fresh = s.v === 1 && age >= 0 && age < (s.ttl ?? MAX_AGE[s.kind] ?? 0);
|
|
61
70
|
if (!fresh || s.kind === 'spec') sessionStorage.removeItem(key); // a speculation is used once
|
|
62
|
-
if (
|
|
63
|
-
|
|
71
|
+
if (s.route !== route || pageKey(nav.url) !== pageKey(href)) return null;
|
|
72
|
+
if (fresh && stashFits(s.kind, s.at)) return s;
|
|
73
|
+
// The entry left loaded again (Back after long, a reload after the round
|
|
74
|
+
// trip's minute): the browser restores nothing where Luma turned its
|
|
75
|
+
// restoration off; where the shopper was is still known.
|
|
76
|
+
const again = nav.type === 'back_forward' || nav.type === 'reload';
|
|
77
|
+
return s.v === 1 && s.kind === 'back' && s.sr && again ? { ...s, stale: true } : null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** This URL's stash as it is, whatever the load (the back/forward cache's restore: where the shopper was). */
|
|
84
|
+
export const peekStash = (href: string): Stash | null => {
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(sessionStorage.getItem(stashKey(href)) ?? 'null') as Stash | null;
|
|
64
87
|
} catch {
|
|
65
88
|
return null;
|
|
66
89
|
}
|
|
@@ -76,7 +99,7 @@ export const dropStash = (href: string) => {
|
|
|
76
99
|
/** false: too big, or storage refused it. */
|
|
77
100
|
export const writeStash = (href: string, s: Omit<Stash, 'v' | 'at'>) => {
|
|
78
101
|
try {
|
|
79
|
-
const json = JSON.stringify({ at: Date.now(), v: 1, kind: s.kind, ttl: s.ttl, route: s.route, query: s.query, result: s.result, scroll: s.scroll });
|
|
102
|
+
const json = JSON.stringify({ at: Date.now(), v: 1, kind: s.kind, ttl: s.ttl, route: s.route, query: s.query, result: s.result, scroll: s.scroll, sr: s.sr });
|
|
80
103
|
if (json.length > MAX_CHARS) return false;
|
|
81
104
|
const key = stashKey(href);
|
|
82
105
|
const others = entries(key);
|