jattac.libs.web.responsive-table 0.17.1 → 0.18.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.
@@ -0,0 +1,449 @@
1
+ # Scroll Position Control
2
+ ## Saving and Restoring the Table's Scroll Offset
3
+
4
+ Tables that switch between data subsets, lose page focus, or live inside routed views often need to remember where the user was scrolling — and snap back there when they return. The scroll position API gives parent components full control over this, without forking internal state.
5
+
6
+ ---
7
+
8
+ [← Previous: Row Expansion and Collapse](./expand-collapse.md) | [Return to API Reference →](./api.md)
9
+
10
+ ---
11
+
12
+ ## Table of Contents
13
+
14
+ - [When You Need This](#when-you-need-this)
15
+ - [Props and Ref API at a Glance](#props-and-ref-api-at-a-glance)
16
+ - [How It Works](#how-it-works)
17
+ - [Internal Scroll (maxHeight)](#internal-scroll-maxheight)
18
+ - [Page-Level Scroll (no maxHeight)](#page-level-scroll-no-maxheight)
19
+ - [onScrollPositionChange](#onscrollpositionchange)
20
+ - [Signature](#signature)
21
+ - [Basic Usage](#basic-usage)
22
+ - [ref.scrollTo()](#refscrollto)
23
+ - [Signature](#signature-1)
24
+ - [Basic Restore](#basic-restore)
25
+ - [Common Patterns](#common-patterns)
26
+ - [Tab Switch — Preserve Position Per Tab](#tab-switch--preserve-position-per-tab)
27
+ - [Subset Swap — Preserve Position Across Filter](#subset-swap--preserve-position-across-filter)
28
+ - [Route Navigation — Save on Leave, Restore on Return](#route-navigation--save-on-leave-restore-on-return)
29
+ - [Reset on Data Change](#reset-on-data-change)
30
+ - [Best Practices](#best-practices)
31
+ - [Pitfalls and Edge Cases](#pitfalls-and-edge-cases)
32
+
33
+ ---
34
+
35
+ ## When You Need This
36
+
37
+ Without this API, a user who scrolls halfway down a long table and then:
38
+
39
+ - Switches to another tab and back
40
+ - Triggers a filter that narrows the dataset
41
+ - Navigates away and returns
42
+
43
+ …will find the table snapped back to the top. The scroll position API lets the parent save the offset the moment it changes and restore it on the next render — so the user stays where they were.
44
+
45
+ **Concrete scenarios where this matters:**
46
+
47
+ | Scenario | Problem | Solution |
48
+ | :--- | :--- | :--- |
49
+ | Tab switcher with multiple tables | Each tab re-mounts the table at top | Save offset before unmount, restore after mount |
50
+ | Filter/search that re-renders the table | Applying a filter snaps to top even if results overlap | Save offset before filter, restore after (or reset intentionally) |
51
+ | Route navigation (SPA) | Navigating away and returning resets scroll | Store offset in route state, restore via `ref.scrollTo()` |
52
+ | Programmatic data subset swap | Parent changes `data` prop to a different slice | Save, swap, decide whether to restore or reset based on context |
53
+
54
+ ---
55
+
56
+ ## Props and Ref API at a Glance
57
+
58
+ | Prop / Method | Type | Description |
59
+ | :--- | :--- | :--- |
60
+ | `onScrollPositionChange` | `(scrollTop: number) => void` | Fired on each scroll event with the current pixel offset. `scrollTop` reflects the internal container when `maxHeight` is set, or `window.scrollY` otherwise. |
61
+ | `ref.scrollTo(position)` | `(position: number) => void` | Scrolls the internal container (or the page, if no `maxHeight`) to the given pixel offset. |
62
+
63
+ ---
64
+
65
+ ## How It Works
66
+
67
+ The scroll API has two modes that mirror the table's own layout modes.
68
+
69
+ ### Internal Scroll (`maxHeight`)
70
+
71
+ When `maxHeight` is set, the table creates a fixed-height `<div>` with `overflow-y: auto`. All scrolling happens inside this container.
72
+
73
+ - `onScrollPositionChange` fires from the `onScroll` event on that div and reports `scrollTop`.
74
+ - `ref.scrollTo(position)` calls `container.scrollTo({ top: position })` on that same div.
75
+
76
+ No `window` listener is registered.
77
+
78
+ ### Page-Level Scroll (no `maxHeight`)
79
+
80
+ When `maxHeight` is absent, the table expands to its full height and the page scrolls. The internal container is not a scroll target.
81
+
82
+ - `onScrollPositionChange` fires from a passive `window` scroll listener and reports `window.scrollY`.
83
+ - `ref.scrollTo(position)` calls `window.scrollTo({ top: position })`.
84
+
85
+ The window listener is registered in a `useEffect` and cleaned up on unmount. Changing `onScrollPositionChange` from a defined function to `undefined` (or vice versa) automatically re-registers or removes the listener.
86
+
87
+ ---
88
+
89
+ ## `onScrollPositionChange`
90
+
91
+ ### Signature
92
+
93
+ ```typescript
94
+ onScrollPositionChange?: (scrollTop: number) => void
95
+ ```
96
+
97
+ Called on each scroll event. The value is the raw pixel offset — `scrollTop` for internal containers, `window.scrollY` for page-level scroll.
98
+
99
+ The callback fires at the browser's native scroll event rate (typically every animation frame while scrolling). If your handler is expensive, throttle it yourself before storing the value. For a simple `setState` or a ref assignment, the raw rate is fine.
100
+
101
+ ### Basic Usage
102
+
103
+ ```tsx
104
+ import { useRef, useState } from 'react';
105
+ import ResponsiveTable, { ResponsiveTableHandle } from 'jattac.libs.web.responsive-table';
106
+
107
+ function OrdersPage() {
108
+ const tableRef = useRef<ResponsiveTableHandle<Order>>(null);
109
+ const [savedPosition, setSavedPosition] = useState(0);
110
+
111
+ return (
112
+ <ResponsiveTable
113
+ ref={tableRef}
114
+ data={orders}
115
+ columnDefinitions={columns}
116
+ maxHeight="600px"
117
+ onScrollPositionChange={(pos) => setSavedPosition(pos)}
118
+ />
119
+ );
120
+ }
121
+ ```
122
+
123
+ Using a ref instead of state avoids re-renders on every scroll tick:
124
+
125
+ ```tsx
126
+ const scrollPositionRef = useRef(0);
127
+
128
+ <ResponsiveTable
129
+ onScrollPositionChange={(pos) => { scrollPositionRef.current = pos; }}
130
+ ...
131
+ />
132
+ ```
133
+
134
+ This is the recommended pattern when the position is only used reactively (restoring on re-mount or data swap) rather than for display.
135
+
136
+ ---
137
+
138
+ ## `ref.scrollTo()`
139
+
140
+ ### Signature
141
+
142
+ ```typescript
143
+ tableRef.current?.scrollTo(position: number): void
144
+ ```
145
+
146
+ Scrolls to the given pixel offset. The target is the internal container when `maxHeight` is set, or `window` otherwise — matching exactly what `onScrollPositionChange` reports.
147
+
148
+ ### Basic Restore
149
+
150
+ ```tsx
151
+ const tableRef = useRef<ResponsiveTableHandle<Order>>(null);
152
+ const savedPosition = useRef(0);
153
+
154
+ // Save
155
+ <ResponsiveTable
156
+ ref={tableRef}
157
+ onScrollPositionChange={(pos) => { savedPosition.current = pos; }}
158
+ ...
159
+ />
160
+
161
+ // Restore (e.g. after switching a tab back or swapping data)
162
+ tableRef.current?.scrollTo(savedPosition.current);
163
+ ```
164
+
165
+ ---
166
+
167
+ ## Common Patterns
168
+
169
+ ### Tab Switch — Preserve Position Per Tab
170
+
171
+ Each tab has its own table and its own saved position. When the user switches to a tab, the position saved before they left is restored.
172
+
173
+ ```tsx
174
+ type Tab = 'pending' | 'completed';
175
+
176
+ function OrdersPage() {
177
+ const pendingRef = useRef<ResponsiveTableHandle<Order>>(null);
178
+ const completedRef = useRef<ResponsiveTableHandle<Order>>(null);
179
+ const positions = useRef<Record<Tab, number>>({ pending: 0, completed: 0 });
180
+ const [activeTab, setActiveTab] = useState<Tab>('pending');
181
+
182
+ const switchTab = (tab: Tab) => {
183
+ setActiveTab(tab);
184
+ // React re-renders synchronously in the same event — scrollTo on the next
185
+ // frame so the table is in the DOM before we scroll it
186
+ requestAnimationFrame(() => {
187
+ const ref = tab === 'pending' ? pendingRef : completedRef;
188
+ ref.current?.scrollTo(positions.current[tab]);
189
+ });
190
+ };
191
+
192
+ return (
193
+ <>
194
+ <TabBar active={activeTab} onSwitch={switchTab} />
195
+
196
+ {activeTab === 'pending' && (
197
+ <ResponsiveTable
198
+ ref={pendingRef}
199
+ data={pendingOrders}
200
+ columnDefinitions={columns}
201
+ maxHeight="600px"
202
+ onScrollPositionChange={(pos) => { positions.current.pending = pos; }}
203
+ />
204
+ )}
205
+ {activeTab === 'completed' && (
206
+ <ResponsiveTable
207
+ ref={completedRef}
208
+ data={completedOrders}
209
+ columnDefinitions={columns}
210
+ maxHeight="600px"
211
+ onScrollPositionChange={(pos) => { positions.current.completed = pos; }}
212
+ />
213
+ )}
214
+ </>
215
+ );
216
+ }
217
+ ```
218
+
219
+ > Use `requestAnimationFrame` when calling `scrollTo` immediately after switching tabs — the component needs to be in the DOM before the scroll target can be set.
220
+
221
+ ---
222
+
223
+ ### Subset Swap — Preserve Position Across Filter
224
+
225
+ The same table instance displays different slices of the same dataset. The user filters to "APAC region" while scrolled 400px down; you want to keep them roughly at that position in the filtered results.
226
+
227
+ ```tsx
228
+ function OrdersPage() {
229
+ const tableRef = useRef<ResponsiveTableHandle<Order>>(null);
230
+ const savedPosition = useRef(0);
231
+ const [region, setRegion] = useState<string>('all');
232
+
233
+ const applyFilter = (newRegion: string) => {
234
+ setRegion(newRegion);
235
+ // Decide whether to restore the old position or reset to top.
236
+ // Restoring makes sense if the filtered set overlaps the original.
237
+ // Resetting to 0 makes sense if the set is completely different.
238
+ requestAnimationFrame(() => {
239
+ tableRef.current?.scrollTo(savedPosition.current);
240
+ });
241
+ };
242
+
243
+ const filteredOrders = region === 'all'
244
+ ? orders
245
+ : orders.filter((o) => o.region === region);
246
+
247
+ return (
248
+ <>
249
+ <RegionFilter value={region} onChange={applyFilter} />
250
+ <ResponsiveTable
251
+ ref={tableRef}
252
+ data={filteredOrders}
253
+ columnDefinitions={columns}
254
+ maxHeight="600px"
255
+ onScrollPositionChange={(pos) => { savedPosition.current = pos; }}
256
+ />
257
+ </>
258
+ );
259
+ }
260
+ ```
261
+
262
+ ---
263
+
264
+ ### Route Navigation — Save on Leave, Restore on Return
265
+
266
+ Store the position in route state (React Router v6 pattern). The position travels with the navigation history, so Back restores scroll context naturally.
267
+
268
+ ```tsx
269
+ import { useLocation, useNavigate } from 'react-router-dom';
270
+
271
+ function OrdersPage() {
272
+ const tableRef = useRef<ResponsiveTableHandle<Order>>(null);
273
+ const navigate = useNavigate();
274
+ const location = useLocation();
275
+ const restoredPosition = (location.state as { scrollPos?: number })?.scrollPos ?? 0;
276
+
277
+ // Restore when the page mounts (Back navigation)
278
+ useEffect(() => {
279
+ if (restoredPosition > 0) {
280
+ // Use a short delay to let content settle before scrolling
281
+ const id = setTimeout(() => {
282
+ tableRef.current?.scrollTo(restoredPosition);
283
+ }, 50);
284
+ return () => clearTimeout(id);
285
+ }
286
+ }, []); // empty deps — read once at mount
287
+
288
+ const navigateToDetail = (order: Order, currentScrollPos: number) => {
289
+ navigate(`/orders/${order.id}`, {
290
+ state: { scrollPos: currentScrollPos },
291
+ });
292
+ };
293
+
294
+ const savedPosition = useRef(0);
295
+
296
+ return (
297
+ <ResponsiveTable
298
+ ref={tableRef}
299
+ data={orders}
300
+ columnDefinitions={columns}
301
+ onScrollPositionChange={(pos) => { savedPosition.current = pos; }}
302
+ onRowClick={(order) => navigateToDetail(order, savedPosition.current)}
303
+ />
304
+ );
305
+ }
306
+ ```
307
+
308
+ ---
309
+
310
+ ### Reset on Data Change
311
+
312
+ Sometimes the right UX is to snap to the top rather than restore. When a filter produces a completely different set of results, position 0 is more useful than a stale offset that may be past the end of the new shorter list.
313
+
314
+ ```tsx
315
+ const [region, setRegion] = useState('all');
316
+ const tableRef = useRef<ResponsiveTableHandle<Order>>(null);
317
+
318
+ const applyFilter = (newRegion: string) => {
319
+ setRegion(newRegion);
320
+ requestAnimationFrame(() => {
321
+ tableRef.current?.scrollTo(0); // always reset to top on filter change
322
+ });
323
+ };
324
+ ```
325
+
326
+ ---
327
+
328
+ ## Best Practices
329
+
330
+ **Store the position in a ref, not in state.**
331
+ `onScrollPositionChange` fires on every scroll event. Updating a state variable on every call triggers a re-render on every scroll tick — unnecessary layout work. A ref stores the latest value without triggering renders:
332
+
333
+ ```tsx
334
+ // Preferred
335
+ const pos = useRef(0);
336
+ <ResponsiveTable onScrollPositionChange={(p) => { pos.current = p; }} ... />
337
+
338
+ // Avoid — causes re-render on every scroll tick
339
+ const [pos, setPos] = useState(0);
340
+ <ResponsiveTable onScrollPositionChange={setPos} ... />
341
+ ```
342
+
343
+ The only time `useState` is appropriate is when you're displaying the scroll position to the user (e.g., a "Back to top" button that appears after a threshold).
344
+
345
+ **Use `requestAnimationFrame` when restoring immediately after a render.**
346
+ `scrollTo` must run after the component is in the DOM and the browser has laid out its content. If you call `scrollTo` in a state-update callback or immediately in a `useEffect`, the scroll target may not exist yet:
347
+
348
+ ```tsx
349
+ // Safe
350
+ requestAnimationFrame(() => tableRef.current?.scrollTo(savedPos));
351
+
352
+ // May silently no-op if the element is not yet laid out
353
+ tableRef.current?.scrollTo(savedPos);
354
+ ```
355
+
356
+ **Guard against `null`.**
357
+ The ref is `null` until mount. Use optional chaining:
358
+
359
+ ```tsx
360
+ tableRef.current?.scrollTo(savedPos); // safe
361
+ tableRef.current.scrollTo(savedPos); // throws before mount
362
+ ```
363
+
364
+ **Match `maxHeight` setting to how you save position.**
365
+ The position reported by `onScrollPositionChange` corresponds to whatever is scrolling:
366
+ - With `maxHeight` → `scrollTop` of the internal div
367
+ - Without `maxHeight` → `window.scrollY`
368
+
369
+ `ref.scrollTo()` targets the same element. Do not mix positions from one mode with a restored call in another — e.g., save a position while `maxHeight="600px"` and then restore it after removing `maxHeight`. The values refer to different scroll containers and the restore will snap to the wrong location.
370
+
371
+ **Decide restore-vs-reset intentionally.**
372
+ Restoring the position is the right default for tab switches and back-navigation — the user returns to the same context. Resetting to 0 is often better after filter or sort changes that meaningfully reorder or shrink the dataset. There is no universal rule; the right answer depends on whether the new set of rows is recognisably "the same list" the user was scrolling through.
373
+
374
+ **Persist position outside React state for cross-session memory.**
375
+ `useRef` forgets the position on unmount. For a true "return to where you were" on page reload, persist to `sessionStorage` or `localStorage`:
376
+
377
+ ```tsx
378
+ const savedPos = useRef(
379
+ Number(sessionStorage.getItem('orders-scroll') ?? 0)
380
+ );
381
+
382
+ <ResponsiveTable
383
+ onScrollPositionChange={(pos) => {
384
+ savedPos.current = pos;
385
+ sessionStorage.setItem('orders-scroll', String(pos));
386
+ }}
387
+ ...
388
+ />
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Pitfalls and Edge Cases
394
+
395
+ **Calling `scrollTo` before the component mounts.**
396
+ The `ref` is `null` until the first render completes. An immediate `scrollTo` call in a `useEffect` with no delay or `requestAnimationFrame` may have no effect because the layout hasn't completed. If your `useEffect` runs before the browser paints, add a `requestAnimationFrame` or a minimal `setTimeout`.
397
+
398
+ **Restoring a position that is larger than the scrollable height.**
399
+ If the saved position was 800px but the new content is only 400px tall, `scrollTo(800)` is a no-op — both native `element.scrollTo` and `window.scrollTo` clamp to the maximum scrollable distance. This is silent and safe; the user lands at the bottom of the shorter list.
400
+
401
+ **`onScrollPositionChange` fires at full scroll event rate.**
402
+ The native scroll event fires on every frame while scrolling — potentially 60–120 times per second. If your callback writes to `localStorage`, makes an API call, or does any work more expensive than a simple ref assignment, wrap it in a throttle or debounce:
403
+
404
+ ```tsx
405
+ import { useCallback, useRef } from 'react';
406
+
407
+ function useThrottle<T extends (...args: Parameters<T>) => void>(fn: T, ms: number): T {
408
+ const last = useRef(0);
409
+ return useCallback((...args: Parameters<T>) => {
410
+ const now = Date.now();
411
+ if (now - last.current >= ms) {
412
+ last.current = now;
413
+ fn(...args);
414
+ }
415
+ }, [fn, ms]) as T;
416
+ }
417
+
418
+ const savePosition = useThrottle(
419
+ (pos: number) => sessionStorage.setItem('scroll', String(pos)),
420
+ 200
421
+ );
422
+
423
+ <ResponsiveTable onScrollPositionChange={savePosition} ... />
424
+ ```
425
+
426
+ **`onScrollPositionChange` identity must be stable.**
427
+ The prop is in the `useEffect` dependency array for the window listener. If you pass an inline arrow function without `useCallback`, the effect re-registers the window listener on every parent render — adding and removing the event listener repeatedly. Use `useCallback` or store the handler in a ref:
428
+
429
+ ```tsx
430
+ // Safe — stable reference
431
+ const handleScroll = useCallback((pos: number) => {
432
+ savedPos.current = pos;
433
+ }, []);
434
+
435
+ <ResponsiveTable onScrollPositionChange={handleScroll} ... />
436
+
437
+ // Risky — new function on every render, causes listener churn
438
+ <ResponsiveTable onScrollPositionChange={(pos) => { savedPos.current = pos; }} ... />
439
+ ```
440
+
441
+ **Page-level scroll and `maxHeight` cannot be mixed on the same instance.**
442
+ `onScrollPositionChange` and `scrollTo` always target the same container — either the internal div (when `maxHeight` is set) or the window (when it is not). There is no API to listen to one and scroll the other. If you change `maxHeight` dynamically between renders, save and restore positions separately and do not carry a position from one mode across to the other.
443
+
444
+ **Mobile view scrolls the page, not a container.**
445
+ The mobile card layout expands to full height and is scrolled by the window. When `isMobile` is true (viewport is below `mobileBreakpoint`), the table behaves as if `maxHeight` were absent — the window listener is active and `scrollTo` targets `window`. This is consistent with the desktop page-level-scroll mode.
446
+
447
+ ---
448
+
449
+ **Previous:** [Row Expansion and Collapse](./expand-collapse.md) | **Next:** [API Reference](./api.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jattac.libs.web.responsive-table",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "A fully responsive, customizable, and lightweight React table component with a modern, mobile-first design and a powerful plugin system.",
5
5
  "author": {
6
6
  "name": "Nyingi Maina",