@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.
@@ -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
- private onPageHide = () => void (this.hiddenAt = Date.now());
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.unsub = onUrlChange(() => this.resync());
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) this.leave(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
- /** The drawer's held changes, applied: one request, one history entry. */
477
- private applyStaged() {
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
- if (next) this.navigate(next);
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
- this.render(q, p, url);
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: HTMLAnchorElement) {
1089
+ private leave(a?: HTMLAnchorElement) {
922
1090
  if (!this.last || !this.lastQ || this.lastQ !== this.current || this.inFlight) return;
923
- if (writeStash(location.href, { kind: 'back', route: route.id, query: this.lastQ, result: { ...this.last, quickPending: undefined }, scroll: scrollSpot(this, a) })) mark(`${route.id} stashed for back`);
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 && requestAnimationFrame(() => at(n + 1)));
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
- if (Date.now() - st.at > BFCACHE_STALE_MS) setTimeout(() => this.revalidate(q, st.result, url, href), 1500);
966
- else mark(`${route.id} back: as left, not revalidated`);
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
- let cut: number | undefined;
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
- /** The first link to `href` in root (a tile has several: its image, its title). */
28
- const linkTo = (root: Element, href: string) => {
29
- for (const a of root.querySelectorAll<HTMLAnchorElement>('a[href]')) if (a.href === href) return a;
30
- return null;
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 is, pinned to the link they follow (layout above it may differ on the way back). */
34
- export const scrollSpot = (root: Element, a: HTMLAnchorElement) => ({ y: Math.round(scrollY), href: a.href, top: Math.round((linkTo(root, a.href) ?? a).getBoundingClientRect().top) });
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). Late layout above us (their images,
41
- * banners, fonts) moves it: followed for a while, until the shopper scrolls,
42
- * taps or types themselves.
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: { y: number; href?: string; top?: number }) => {
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
- } else if (Math.abs(scrollY - s.y) >= 1) scroll(s.y);
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
- let pin: { href: string; top: number } | null = null;
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
- if (!pin) return;
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
  };