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.
- package/dist/UI/ResponsiveTable.d.ts +8 -0
- package/dist/UI/ScrollPosition.test.d.ts +1 -0
- package/dist/index.es.js +23 -3
- package/dist/index.es.js.map +1 -1
- package/dist/index.js +23 -3
- package/dist/index.js.map +1 -1
- package/docs/api.md +22 -0
- package/docs/scroll-position.md +449 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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",
|