cursedbelt 5.22.0 → 5.23.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.
Files changed (33) hide show
  1. package/dist/react/components/SearchInput.d.ts +15 -1
  2. package/dist/react/components/SearchInput.d.ts.map +1 -1
  3. package/dist/react/components/SearchInput.js +36 -4
  4. package/dist/react/components/SearchInput.js.map +1 -1
  5. package/dist/react/components/searchInput.css +179 -0
  6. package/dist/react/hooks/useScrollMemory.d.ts +3 -1
  7. package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
  8. package/dist/react/hooks/useScrollMemory.js +66 -47
  9. package/dist/react/hooks/useScrollMemory.js.map +1 -1
  10. package/dist/react/media/DenseGalleryShell.d.ts +8 -2
  11. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  12. package/dist/react/media/DenseGalleryShell.js +2 -2
  13. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  14. package/dist/react/media-gallery/GalleryTable.js +1 -1
  15. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  16. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  17. package/dist/react/media-gallery/MediaGallery.js +16 -4
  18. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  19. package/dist/react/media-gallery/galleryReturn.d.ts +50 -0
  20. package/dist/react/media-gallery/galleryReturn.d.ts.map +1 -0
  21. package/dist/react/media-gallery/galleryReturn.js +221 -0
  22. package/dist/react/media-gallery/galleryReturn.js.map +1 -0
  23. package/package.json +8 -1
  24. package/src/demoFixture.spec.ts +4 -2
  25. package/src/react/components/SearchInput.spec.tsx +57 -0
  26. package/src/react/components/SearchInput.tsx +74 -20
  27. package/src/react/components/searchInput.css +179 -0
  28. package/src/react/hooks/useScrollMemory.spec.tsx +38 -0
  29. package/src/react/hooks/useScrollMemory.ts +82 -47
  30. package/src/react/media/DenseGalleryShell.tsx +9 -1
  31. package/src/react/media-gallery/GalleryTable.tsx +2 -0
  32. package/src/react/media-gallery/MediaGallery.tsx +19 -1
  33. package/src/react/media-gallery/galleryReturn.ts +248 -0
@@ -224,4 +224,42 @@ describe('useScrollMemory', () => {
224
224
  render(<Shell at='/' el={s.el} />);
225
225
  expect(s.el.scrollTop).toBe(12_000);
226
226
  });
227
+
228
+ test('a scroller that mounts AFTER the hook — a list behind a loading spinner — is still remembered', () => {
229
+ const s = fakeScroller(20_000);
230
+ const { rerender } = render(<Shell at='/' el={s.el} />);
231
+ s.scrollTo(8_000);
232
+ const list = { state: window.history.state, path: '/' };
233
+ push('/item/7');
234
+ rerender(<Shell at='/item/7' el={s.el} />);
235
+
236
+ back(list);
237
+ const Late = ({ ready }: { ready: boolean }) => {
238
+ useScrollMemory('/', { scroller: () => (ready ? s.el : null) });
239
+ return null;
240
+ };
241
+ cleanup();
242
+ const late = render(<Late ready={false} />);
243
+ late.rerender(<Late ready />);
244
+ expect(s.el.scrollTop).toBe(8_000);
245
+ });
246
+
247
+ test('a list screen that owns its scroller files on unmount and restores on remount', () => {
248
+ const s = fakeScroller(20_000);
249
+ const List = () => {
250
+ useScrollMemory('/people', { scroller: () => s.el });
251
+ return null;
252
+ };
253
+ window.history.replaceState(null, '', '/people');
254
+ const first = render(<List />);
255
+ s.scrollTo(5_500);
256
+ const list = { state: window.history.state, path: '/people' };
257
+ push('/person/1'); // the app navigates; the list unmounts
258
+ first.unmount();
259
+ s.el.scrollTop = 0; // a new screen's scroller, as far as this list knows
260
+
261
+ back(list);
262
+ render(<List />);
263
+ expect(s.el.scrollTop).toBe(5_500);
264
+ });
227
265
  });
@@ -31,67 +31,102 @@ import { type RefObject, useLayoutEffect, useRef } from 'react';
31
31
  * - **A replaced URL is the same place**: when the location changes but the history entry does
32
32
  * not (a filter written with `replaceState`), the scroll is left alone.
33
33
  *
34
- * Mount it ONCE per scroller, above the screens that swap — the app shell, keyed on the route.
34
+ * Mount it ONCE per scroller: in the app shell keyed on the route when the screens swap under one
35
+ * scroller (the window, a shell `ScrollArea`), or in the list screen itself when each screen owns
36
+ * its own `ScrollArea` — mounting is then the arrival and unmounting the leave.
35
37
  */
36
38
  export function useScrollMemory(location: string, options: ScrollMemoryOptions = {}): void {
37
39
  const { enabled = true, deadlineMs = RESTORE_DEADLINE_MS } = options;
38
- // The scroller is read through a ref so an inline `() => el` does not re-run the effect
39
- // every render — which would file and restore on every keystroke.
40
- const scrollerRef = useRef(options.scroller);
41
- scrollerRef.current = options.scroller;
40
+ const spec = options.scroller ?? 'window';
42
41
  const last = useRef<{ id: string; location: string } | null>(null);
42
+ const session = useRef<Session | null>(null);
43
43
 
44
+ // 🔴 Checked EVERY commit, not on a dependency list: the scroller can mount after the hook — a
45
+ // list behind a loading spinner has no ScrollArea on its first render — and an effect keyed on
46
+ // the location alone would find nothing, return, and never look again, so that whole visit
47
+ // would go unremembered. A session ends and the next begins only when the place, the switch or
48
+ // the scroller ELEMENT actually changes; every other commit is one comparison.
44
49
  useLayoutEffect(() => {
45
- if (!enabled) return;
46
- const scroller = resolveScroller(scrollerRef.current ?? 'window');
47
- if (!scroller) return;
48
- if (scroller.isWindow && 'scrollRestoration' in window.history) {
49
- // Left on `auto`, the browser restores at `popstate` — into the screen being LEFT, before
50
- // React has swapped it — and the restore below would have to fight it.
51
- window.history.scrollRestoration = 'manual';
52
- }
53
- const id = entryId();
54
- if (!id) return;
55
-
56
- // Same entry, different location: the URL was replaced in place. It is the same place.
57
- const replacedInPlace = last.current?.id === id && last.current.location !== location;
58
- last.current = { id, location };
59
-
60
- // What gets filed when this entry is left. While a restore is pending it is the TARGET, not
61
- // the partial offset on screen — leaving mid-restore (or StrictMode's rehearsal unmount) must
62
- // not overwrite a deep position with a shallow one.
63
- let offset = scroller.get();
64
- let cancelRestore: (() => void) | null = null;
65
-
66
- if (!replacedInPlace) {
67
- const target = recalled(id);
68
- if (target > 0) {
69
- offset = target;
70
- cancelRestore = restore(scroller, target, deadlineMs, (outcome) => {
71
- cancelRestore = null;
72
- if (outcome !== 'reached') offset = scroller.get();
73
- });
74
- } else {
75
- scroller.set(0);
76
- offset = 0;
77
- }
50
+ const scroller = enabled ? resolveScroller(spec) : null;
51
+ const current = session.current;
52
+ if (current && scroller && current.location === location && current.target === scroller.events) return;
53
+ if (!current && !scroller) return;
54
+ current?.end();
55
+ session.current = scroller ? begin(scroller, location, last, deadlineMs) : null;
56
+ });
57
+ useLayoutEffect(
58
+ () => () => {
59
+ session.current?.end();
60
+ session.current = null;
61
+ },
62
+ [],
63
+ );
64
+ }
65
+
66
+ interface Session {
67
+ location: string;
68
+ target: EventTarget;
69
+ /** Stop tracking and file the offset under this entry. */
70
+ end(): void;
71
+ }
72
+
73
+ /** Arrive on the current history entry with this scroller: restore or start at the top, then track. */
74
+ function begin(
75
+ scroller: Scroller,
76
+ location: string,
77
+ last: { current: { id: string; location: string } | null },
78
+ deadlineMs: number,
79
+ ): Session | null {
80
+ if (scroller.isWindow && 'scrollRestoration' in window.history) {
81
+ // Left on `auto`, the browser restores at `popstate` — into the screen being LEFT, before
82
+ // React has swapped it — and the restore below would have to fight it.
83
+ window.history.scrollRestoration = 'manual';
84
+ }
85
+ const id = entryId();
86
+ if (!id) return null;
87
+
88
+ // Same entry, different location: the URL was replaced in place. It is the same place.
89
+ const replacedInPlace = last.current?.id === id && last.current.location !== location;
90
+ last.current = { id, location };
91
+
92
+ // What gets filed when this entry is left. While a restore is pending it is the TARGET, not
93
+ // the partial offset on screen — leaving mid-restore (or StrictMode's rehearsal unmount) must
94
+ // not overwrite a deep position with a shallow one.
95
+ let offset = scroller.get();
96
+ let cancelRestore: (() => void) | null = null;
97
+
98
+ if (!replacedInPlace) {
99
+ const target = recalled(id);
100
+ if (target > 0) {
101
+ offset = target;
102
+ cancelRestore = restore(scroller, target, deadlineMs, (outcome) => {
103
+ cancelRestore = null;
104
+ if (outcome !== 'reached') offset = scroller.get();
105
+ });
106
+ } else {
107
+ scroller.set(0);
108
+ offset = 0;
78
109
  }
110
+ }
79
111
 
80
- const onScroll = () => {
81
- if (!cancelRestore) offset = scroller.get();
82
- };
83
- const onHide = () => file(id, offset);
84
- scroller.events.addEventListener('scroll', onScroll, { passive: true });
85
- window.addEventListener('pagehide', onHide);
86
- return () => {
112
+ const onScroll = () => {
113
+ if (!cancelRestore) offset = scroller.get();
114
+ };
115
+ const onHide = () => file(id, offset);
116
+ scroller.events.addEventListener('scroll', onScroll, { passive: true });
117
+ window.addEventListener('pagehide', onHide);
118
+ return {
119
+ location,
120
+ target: scroller.events,
121
+ end() {
87
122
  const pending = cancelRestore;
88
123
  cancelRestore = null;
89
124
  pending?.();
90
125
  scroller.events.removeEventListener('scroll', onScroll);
91
126
  window.removeEventListener('pagehide', onHide);
92
127
  file(id, offset);
93
- };
94
- }, [location, enabled, deadlineMs]);
128
+ },
129
+ };
95
130
  }
96
131
 
97
132
  /** What scrolls: the window, or an element — by ref or by a getter read when the screen arrives. */
@@ -5,6 +5,7 @@ import {
5
5
  type CSSProperties,
6
6
  type MouseEvent as ReactMouseEvent,
7
7
  type ReactNode,
8
+ type Ref,
8
9
  useCallback,
9
10
  useEffect,
10
11
  useState,
@@ -231,6 +232,12 @@ export interface DenseGalleryShellProps<O extends string = string> {
231
232
  * exactly one place the caption height is written down.
232
233
  */
233
234
  rowExtraHeight?: number;
235
+ /**
236
+ * The shell's root element (`.cbgd-root`) — for a caller that has to find the browse view's
237
+ * scroller in the DOM. `MediaGallery` uses it to put the grid back where you were when a
238
+ * file closes (`media-gallery/galleryReturn.ts`), because closing a file mounts a new shell.
239
+ */
240
+ rootRef?: Ref<HTMLDivElement>;
234
241
  }
235
242
 
236
243
  /** The browse view: header + optional filter strip + a virtualized thumbnail grid. */
@@ -254,9 +261,10 @@ export function DenseGalleryShell<O extends string = string>({
254
261
  minColumnWidth = 160,
255
262
  // 🔴 Deliberately NOT defaulted here — see the prop's doc comment.
256
263
  rowExtraHeight,
264
+ rootRef,
257
265
  }: DenseGalleryShellProps<O>) {
258
266
  return (
259
- <div className='cbgd-root'>
267
+ <div className='cbgd-root' ref={rootRef}>
260
268
  {chrome === 'none' ? null : (
261
269
  <header className='cbgd-header'>
262
270
  <span className='cbgd-title'>{title}</span>
@@ -496,6 +496,8 @@ function GalleryTableRow({
496
496
  // The same two hooks the grid tile carries: the attribute is what the gallery's reveal
497
497
  // scrolls to, and `aria-current` is what says "this is the one" to a screen reader.
498
498
  data-playing={stageMark ? 'true' : undefined}
499
+ // How a returning table finds the last file shown — see `galleryReturn.ts`.
500
+ data-item-id={item.id}
499
501
  aria-current={stageMark ? 'true' : undefined}
500
502
  {...(previewSrc
501
503
  ? {
@@ -78,6 +78,7 @@ import {
78
78
  } from '../hooks/useCheckboxSelection.js';
79
79
  import { DenseGalleryDetail, DenseGalleryShell } from '../media/DenseGalleryShell.js';
80
80
  import { CBGD_BARE_TILE_HEIGHT } from '../media/VirtualMediaGrid.js';
81
+ import { useGalleryReturn } from './galleryReturn.js';
81
82
  import { useHlsSource } from '../media/hlsSource.js';
82
83
  import {
83
84
  isRecoverableFault,
@@ -552,12 +553,16 @@ interface TileDrag {
552
553
  * `stopPropagation` in {@link SelectBox} buys.
553
554
  */
554
555
  function TileShell({
556
+ id,
555
557
  name,
556
558
  selection,
557
559
  drag,
558
560
  stageMark,
559
561
  children,
560
562
  }: {
563
+ /** The item's id, carried as `data-item-id` — how a returning grid finds the last file shown
564
+ * (`galleryReturn.ts`). */
565
+ id: string;
561
566
  name: string;
562
567
  selection?: TileSelection;
563
568
  drag?: TileDrag;
@@ -582,6 +587,7 @@ function TileShell({
582
587
  // The hook the reveal below finds the tile by — see `MediaGallery`'s `revealPlaying`.
583
588
  // An attribute rather than the class, so the query cannot be broken by a styling change.
584
589
  data-playing={stageMark ? 'true' : undefined}
590
+ data-item-id={id}
585
591
  aria-current={stageMark ? 'true' : undefined}
586
592
  // 🔴 The DRAGGABLE element is this wrapper, not the tile inside it. Every tile branch
587
593
  // is a <button>, and a draggable button is a browser-by-browser coin toss: some start
@@ -684,7 +690,7 @@ function GalleryTile({
684
690
  * handlers, so this costs nothing and cannot change behavior anywhere else.
685
691
  */
686
692
  return (
687
- <TileShell name={item.name} selection={selection} drag={drag} stageMark={stageMark}>
693
+ <TileShell id={item.id} name={item.name} selection={selection} drag={drag} stageMark={stageMark}>
688
694
  <RawGalleryTile
689
695
  item={item}
690
696
  cache={cache}
@@ -2021,6 +2027,17 @@ export function MediaGallery({
2021
2027
  }, [checkedKey]);
2022
2028
 
2023
2029
  const selected = selectedId ? (items.find((i) => i.id === selectedId) ?? null) : null;
2030
+ /** The browse view's root — how the return below finds the grid it puts back in place. */
2031
+ const browseRootRef = useRef<HTMLDivElement | null>(null);
2032
+ // Closing a file mounts a NEW grid; this puts it back where you were, then shows the last
2033
+ // file the stage showed. Task 2255 — see `galleryReturn.ts`.
2034
+ useGalleryReturn({
2035
+ rootRef: browseRootRef,
2036
+ browsing: selected === null,
2037
+ viewMode,
2038
+ shownId: selected?.id ?? null,
2039
+ displayIds,
2040
+ });
2024
2041
  const select = (item: MediaGalleryItem | null) => {
2025
2042
  // Only move our own state when we own it; a controlled consumer decides for itself what
2026
2043
  // the notification means.
@@ -3164,6 +3181,7 @@ export function MediaGallery({
3164
3181
  {capabilityDialogs}
3165
3182
  <DenseGalleryShell<MediaGalleryOrder>
3166
3183
  {...(tileCaptions ? {} : { rowExtraHeight: CBGD_BARE_TILE_HEIGHT })}
3184
+ rootRef={browseRootRef}
3167
3185
  title={title}
3168
3186
  order={{
3169
3187
  value: order,
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Closing a file puts the grid back where you were — task 2255.
3
+ *
4
+ * Owner, 2026-09-28, after flix's "Back keeps your place": *"is a common enough need … I want it
5
+ * to be in cb … find other apps we have that have scrolling and similar issues where we can't
6
+ * go back to the right place like flix had"*.
7
+ *
8
+ * `MediaGallery` draws `DenseGalleryDetail` while a file is open and `DenseGalleryShell`
9
+ * otherwise. They are different trees, so closing a file — by ✕, by a deep link's Back
10
+ * (`useGalleryDeepLink`'s `?i=`), by deleting it — MOUNTS A NEW GRID, and its scroller starts
11
+ * at zero. Measured in Chromium before this (`e2e/gallery-return.spec.ts`, 240 tiles, scrolled
12
+ * to 3,000px): every close landed at scrollTop 0, at phone and desktop size, two hundred tiles
13
+ * from where you had been.
14
+ *
15
+ * 🔴 Why not `useScrollMemory`. That hook is per HISTORY ENTRY, and a gallery's open/close is
16
+ * a history entry only when its consumer deep-links it. An uncontrolled gallery closes with no
17
+ * navigation at all, so the memory has to live with the gallery — which is also what keeps
18
+ * every consumer from having to wire anything.
19
+ *
20
+ * Two rules, in order:
21
+ * 1. **The offset comes back** — the browse scroller's own `scrollTop` (the tile grid's
22
+ * `.cbgd-grid`, or the table's `.cbgt-root`), recorded while you browse and put back when
23
+ * the view returns, if it returns in the same grid/table mode. It retries each frame while
24
+ * the virtualized track is still growing into the offset, and a touch, wheel, key or
25
+ * pointer press cancels it — the viewer always wins.
26
+ * 2. **…then the last file SHOWN is on screen.** Stepping through twenty files moves you: the
27
+ * place you are now is the last file the stage showed, not the one you opened. When that
28
+ * file's tile is not visible after rule 1 it is brought to the middle of the scroller. A
29
+ * tile that is already even partly visible is left alone — a grid that jumps a few pixels
30
+ * under a tile you can see reads as a bug (the same limit the strip's reveal keeps).
31
+ * A virtualized tile that is not mounted is found by arithmetic on the tiles that are:
32
+ * their row pitch and column count place it, and the next frame centres it exactly.
33
+ */
34
+ import { type RefObject, useEffect, useLayoutEffect, useRef } from 'react';
35
+
36
+ /** The browse view's scroller in either view: the tile grid, or the table's frame. */
37
+ const SCROLLER = '.cbgd-grid, .cbgt-root';
38
+ /** Every tile and every table row carries its item's id under this attribute. */
39
+ export const ITEM_ID_ATTR = 'data-item-id';
40
+ /** How long a return may keep trying before it settles for what the scroller can hold. */
41
+ const DEADLINE_MS = 1500;
42
+ const CANCEL_EVENTS = ['wheel', 'touchstart', 'pointerdown', 'keydown'] as const;
43
+
44
+ export interface GalleryReturnOptions {
45
+ /** `.cbgd-root` of the browse view — `DenseGalleryShell`'s `rootRef`. */
46
+ rootRef: RefObject<HTMLDivElement | null>;
47
+ /** True while the browse view (no file open) is the one on screen. */
48
+ browsing: boolean;
49
+ /** Grid or table: an offset recorded in one means nothing in the other. */
50
+ viewMode: string;
51
+ /** The file on the stage right now, or null. */
52
+ shownId: string | null;
53
+ /** The displayed order, which is what places a tile that is not mounted. */
54
+ displayIds: readonly string[];
55
+ }
56
+
57
+ export function useGalleryReturn({
58
+ rootRef,
59
+ browsing,
60
+ viewMode,
61
+ shownId,
62
+ displayIds,
63
+ }: GalleryReturnOptions): void {
64
+ const saved = useRef<{ mode: string; top: number } | null>(null);
65
+ const lastShown = useRef<string | null>(null);
66
+ const restoring = useRef(false);
67
+ const wasBrowsing = useRef(browsing);
68
+ // Read through refs at fire time: they are inputs the return needs CURRENT, not triggers.
69
+ const modeRef = useRef(viewMode);
70
+ const idsRef = useRef(displayIds);
71
+ modeRef.current = viewMode;
72
+ idsRef.current = displayIds;
73
+ if (shownId) lastShown.current = shownId;
74
+
75
+ // Record the browse scroller's offset while browsing. Capture phase, because `scroll` does
76
+ // not bubble and the scroller is whichever of the two views is mounted.
77
+ useEffect(() => {
78
+ const root = rootRef.current;
79
+ if (!browsing || !root) return;
80
+ const onScroll = (event: Event): void => {
81
+ const target = event.target;
82
+ if (restoring.current || !(target instanceof HTMLElement) || !target.matches(SCROLLER)) return;
83
+ saved.current = { mode: modeRef.current, top: target.scrollTop };
84
+ };
85
+ root.addEventListener('scroll', onScroll, { capture: true, passive: true });
86
+ return () => root.removeEventListener('scroll', onScroll, { capture: true });
87
+ }, [browsing, rootRef]);
88
+
89
+ // A layout effect, so the offset is back before the returning grid is ever painted at zero.
90
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `browsing` is the one trigger — a return is the moment it turns true. Everything else is read through refs above, on purpose: a re-sort or a new page of items must not replay a return.
91
+ useLayoutEffect(() => {
92
+ const returning = browsing && !wasBrowsing.current;
93
+ wasBrowsing.current = browsing;
94
+ if (!returning) return;
95
+ const scroller = rootRef.current?.querySelector<HTMLElement>(SCROLLER);
96
+ if (!scroller) return;
97
+ const target = saved.current?.mode === modeRef.current ? saved.current.top : null;
98
+ const revealId = lastShown.current;
99
+ if (target === null && !revealId) return;
100
+ return returnTo(scroller, target, revealId, idsRef, () => {
101
+ restoring.current = false;
102
+ saved.current = { mode: modeRef.current, top: scroller.scrollTop };
103
+ }, restoring);
104
+ }, [browsing]);
105
+ }
106
+
107
+ /**
108
+ * Put `scroller` back at `target`, then bring `revealId`'s tile into view. Returns the cancel.
109
+ */
110
+ function returnTo(
111
+ scroller: HTMLElement,
112
+ target: number | null,
113
+ revealId: string | null,
114
+ idsRef: RefObject<readonly string[]>,
115
+ done: () => void,
116
+ restoring: { current: boolean },
117
+ ): () => void {
118
+ // Each phase gets its own deadline, so a list that never grows back into the offset still
119
+ // leaves the reveal its full budget.
120
+ let started = performance.now();
121
+ let frame = 0;
122
+ let phase: 'offset' | 'reveal' | 'centre' = target === null ? 'reveal' : 'offset';
123
+ let finished = false;
124
+ restoring.current = true;
125
+
126
+ const finish = (): void => {
127
+ if (finished) return;
128
+ finished = true;
129
+ cancelAnimationFrame(frame);
130
+ for (const type of CANCEL_EVENTS) scroller.removeEventListener(type, finish);
131
+ done();
132
+ };
133
+ for (const type of CANCEL_EVENTS) scroller.addEventListener(type, finish, { passive: true });
134
+
135
+ const tick = (): void => {
136
+ if (finished) return;
137
+ const late = performance.now() - started > DEADLINE_MS;
138
+ if (phase === 'offset' && target !== null) {
139
+ scroller.scrollTop = target;
140
+ // Reached, or the list is simply shorter now (a filter, a delete): settle for its end.
141
+ if (Math.abs(scroller.scrollTop - target) < 1 || late) {
142
+ phase = 'reveal';
143
+ started = performance.now();
144
+ }
145
+ frame = requestAnimationFrame(tick);
146
+ return;
147
+ }
148
+ const ids = idsRef.current ?? [];
149
+ const index = revealId ? ids.indexOf(revealId) : -1;
150
+ if (index < 0 || late) {
151
+ finish();
152
+ return;
153
+ }
154
+ const tile = scroller.querySelector<HTMLElement>(`[${ITEM_ID_ATTR}="${cssEscape(revealId ?? '')}"]`);
155
+ const band = visibleBand(scroller);
156
+ if (tile) {
157
+ const box = tile.getBoundingClientRect();
158
+ const visible = box.bottom > band.top && box.top < band.bottom;
159
+ // Rule 2's limit: a tile you can already see is not moved — unless WE just jumped to an
160
+ // estimate of it, which is only ever close.
161
+ if (!visible || phase === 'centre') scroller.scrollTop += centreDelta(box, band);
162
+ finish();
163
+ return;
164
+ }
165
+ // 🔴 Not mounted — but first make sure the VIRTUALIZER has caught up with the offset just
166
+ // set. The table re-windows on its own scroll state a render later, so for a frame or two
167
+ // after a jump it still holds the rows from the top; estimating off those would place a
168
+ // tile that is about to appear right where you are. Nothing mounted in the band = wait.
169
+ if (!mountedInBand(scroller, band)) {
170
+ frame = requestAnimationFrame(tick);
171
+ return;
172
+ }
173
+ // Truly windowed out. Place it by the tiles that ARE mounted.
174
+ const estimate = estimateTop(scroller, band, ids, index);
175
+ if (estimate === null) {
176
+ finish();
177
+ return;
178
+ }
179
+ scroller.scrollTop = estimate;
180
+ phase = 'centre';
181
+ frame = requestAnimationFrame(tick);
182
+ };
183
+
184
+ tick();
185
+ return () => {
186
+ if (finished) return;
187
+ finished = true;
188
+ cancelAnimationFrame(frame);
189
+ for (const type of CANCEL_EVENTS) scroller.removeEventListener(type, finish);
190
+ restoring.current = false;
191
+ };
192
+ }
193
+
194
+ interface Band {
195
+ top: number;
196
+ bottom: number;
197
+ }
198
+
199
+ /** The part of the scroller a person can see — below the table's sticky header, if any. */
200
+ function visibleBand(scroller: HTMLElement): Band {
201
+ const view = scroller.getBoundingClientRect();
202
+ const head = scroller.querySelector('thead');
203
+ const top = head ? Math.max(view.top, head.getBoundingClientRect().bottom) : view.top;
204
+ return { top, bottom: view.bottom };
205
+ }
206
+
207
+ /** Does any tile or row sit in the visible band — i.e. has the window caught up? */
208
+ function mountedInBand(scroller: HTMLElement, band: Band): boolean {
209
+ for (const el of scroller.querySelectorAll<HTMLElement>(`[${ITEM_ID_ATTR}]`)) {
210
+ const box = el.getBoundingClientRect();
211
+ if (box.bottom > band.top && box.top < band.bottom) return true;
212
+ }
213
+ return false;
214
+ }
215
+
216
+ function centreDelta(box: DOMRect, band: Band): number {
217
+ return box.top + box.height / 2 - (band.top + band.bottom) / 2;
218
+ }
219
+
220
+ /**
221
+ * A `scrollTop` that centres the tile at `index`, derived from the mounted ones: the first
222
+ * mounted row gives the column count (only the LAST row of a grid can be short), and the next
223
+ * row down gives the pitch. A table is the same arithmetic with one column.
224
+ */
225
+ function estimateTop(scroller: HTMLElement, band: Band, ids: readonly string[], index: number): number | null {
226
+ const placed: { index: number; top: number; height: number }[] = [];
227
+ for (const el of scroller.querySelectorAll<HTMLElement>(`[${ITEM_ID_ATTR}]`)) {
228
+ const at = ids.indexOf(el.getAttribute(ITEM_ID_ATTR) ?? '');
229
+ if (at < 0) continue;
230
+ const box = el.getBoundingClientRect();
231
+ placed.push({ index: at, top: box.top - band.top + scroller.scrollTop, height: box.height });
232
+ }
233
+ if (placed.length === 0) return null;
234
+ placed.sort((a, b) => a.index - b.index);
235
+ const anchor = placed[0] as (typeof placed)[number];
236
+ const cols = Math.max(1, placed.filter((p) => Math.abs(p.top - anchor.top) < 1).length);
237
+ const below = placed.find((p) => p.top - anchor.top >= 1);
238
+ const rowOf = (at: number): number => Math.floor(at / cols);
239
+ const pitch = below ? (below.top - anchor.top) / (rowOf(below.index) - rowOf(anchor.index) || 1) : anchor.height;
240
+ const rows = rowOf(index) - rowOf(anchor.index);
241
+ const top = anchor.top + rows * pitch;
242
+ return Math.max(0, top + anchor.height / 2 - (band.bottom - band.top) / 2);
243
+ }
244
+
245
+ /** `CSS.escape` where it exists (every browser); ids are app-chosen strings. */
246
+ function cssEscape(value: string): string {
247
+ return typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(value) : value.replace(/["\\]/g, '\\$&');
248
+ }