@eifi1/ui-kit 0.48.0 → 0.48.2

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 (47) hide show
  1. package/README.md +4 -3
  2. package/dist/components/data-table-fit.d.ts +8 -1
  3. package/dist/components/data-table-fit.js +7 -1
  4. package/dist/components/data-table-fit.js.map +1 -1
  5. package/dist/components/data-table-groups.d.ts +3 -1
  6. package/dist/components/data-table-groups.js +11 -5
  7. package/dist/components/data-table-groups.js.map +1 -1
  8. package/dist/components/data-table-head.d.ts +8 -1
  9. package/dist/components/data-table-head.js +11 -3
  10. package/dist/components/data-table-head.js.map +1 -1
  11. package/dist/components/data-table-leading-row.d.ts +3 -1
  12. package/dist/components/data-table-leading-row.js +2 -1
  13. package/dist/components/data-table-leading-row.js.map +1 -1
  14. package/dist/components/data-table-row-actions.js +2 -2
  15. package/dist/components/data-table-row-actions.js.map +1 -1
  16. package/dist/components/data-table-row.d.ts +84 -0
  17. package/dist/components/data-table-row.js +261 -0
  18. package/dist/components/data-table-row.js.map +1 -0
  19. package/dist/components/data-table-selection.d.ts +3 -0
  20. package/dist/components/data-table-selection.js +12 -1
  21. package/dist/components/data-table-selection.js.map +1 -1
  22. package/dist/components/data-table-virtual.d.ts +98 -0
  23. package/dist/components/data-table-virtual.js +369 -0
  24. package/dist/components/data-table-virtual.js.map +1 -0
  25. package/dist/components/data-table.d.ts +58 -2
  26. package/dist/components/data-table.js +227 -230
  27. package/dist/components/data-table.js.map +1 -1
  28. package/dist/data-table.d.ts +1 -0
  29. package/dist/data-table.js.map +1 -1
  30. package/dist/eslint/index.js +1 -1
  31. package/dist/hooks/use-anchored-panel.js +2 -1
  32. package/dist/hooks/use-anchored-panel.js.map +1 -1
  33. package/dist/shell/app-shell.js +7 -1
  34. package/dist/shell/app-shell.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/components/data-table-fit.ts +17 -2
  37. package/src/components/data-table-groups.tsx +15 -4
  38. package/src/components/data-table-head.tsx +25 -2
  39. package/src/components/data-table-leading-row.tsx +4 -1
  40. package/src/components/data-table-row-actions.tsx +3 -2
  41. package/src/components/data-table-row.tsx +483 -0
  42. package/src/components/data-table-selection.ts +17 -1
  43. package/src/components/data-table-virtual.ts +547 -0
  44. package/src/components/data-table.tsx +326 -327
  45. package/src/data-table.ts +2 -0
  46. package/src/hooks/use-anchored-panel.ts +9 -1
  47. package/src/shell/app-shell.tsx +7 -1
@@ -272,7 +272,8 @@ export function useRowActionColumn<T>({
272
272
  () => (swipeColumn ? [...actionColumns, swipeColumn] : actionColumns),
273
273
  [actionColumns, swipeColumn],
274
274
  );
275
- const swipesFromActions = (row: T): MobileSwipeActions | null => {
275
+ // A stable function (0.48.2): the phone cards compare it by identity.
276
+ const swipesFromActions = useCallback((row: T): MobileSwipeActions | null => {
276
277
  const swipes = rowActionsFor(row).filter((a) => a.swipe && !a.action.disabledReason?.(row));
277
278
  if (!swipes.length) return null;
278
279
  const toSwipe = (a: ResolvedRowAction<T>): SwipeAction => ({
@@ -287,6 +288,6 @@ export function useRowActionColumn<T>({
287
288
  start: swipes.filter((a) => a.swipe === "start").map(toSwipe),
288
289
  end: swipes.filter((a) => a.swipe === "end").map(toSwipe),
289
290
  };
290
- };
291
+ }, [rowActionsFor]);
291
292
  return { columns, swipesFromActions };
292
293
  }
@@ -0,0 +1,483 @@
1
+ import { Fragment, memo } from "react";
2
+ import type { MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
3
+ import { RowLink, cleanAttrs, fromOutside, fromOwnControl, logicalAlign, splitCellProps } from "./data-table-shared";
4
+ import { GROW_CELL_CLASS, GROW_PEER_CELL_CLASS } from "./data-table-fit";
5
+ import { MobileRow, type MobileRowProps } from "./data-table-mobile";
6
+ import type { DataTableColumn, MobileSwipeActions } from "./data-table";
7
+ import { cn } from "../lib/cn";
8
+
9
+ // DataTable's rows as memoised components (0.48.2, keksdose K-052-2, dev #632 — Marcel:
10
+ // "The table is extremely slow. Is it virtualised or is everytime the whole table
11
+ // rendered?"). The owner's selection, expansion and the roving focus all live ABOVE the
12
+ // rows, so every tick re-rendered the whole table: on keksdose's register one checkbox
13
+ // cost 430–1200 ms of INP at 271 rows. A row now bails out unless one of its own inputs
14
+ // changed.
15
+ //
16
+ // What a row compares, and so the contract a call site keeps:
17
+ // * the row object, its index and its key;
18
+ // * what the table derived for it from the caller's functions, as PRIMITIVES — selected,
19
+ // selectable, expanded, its tab stop, its `rowClassName` string, its `rowHref`, and
20
+ // its `rowAttributes` compared key by key. The functions themselves may be inline;
21
+ // * the column definitions BY IDENTITY: a cell is the caller's render function, and
22
+ // what it reads is the caller's to say. A page that memoises its columns (keksdose's
23
+ // `useTransactionColumns`) gets the bail-out; one that writes them inline gets a new
24
+ // array every render and the full re-render it always had — never a stale cell;
25
+ // * an OPEN row's detail (`expandedRow`) is called on every render, so it re-renders
26
+ // with the table as before, and an editor inside it keeps its live inputs.
27
+ // The handlers do not take part: they read the table's latest props through `ctx`, a
28
+ // ref the table refreshes each commit, so a row that bailed out still acts on the
29
+ // current selection and the current `onRowClick`.
30
+
31
+ /** What a row's handlers read at event time — DataTable's latest props and helpers. */
32
+ export interface DataTableRowCtx<T> {
33
+ onRowClick: ((row: T) => void) | undefined;
34
+ onToggle: ((row: T, checked: boolean) => void) | undefined;
35
+ isSelected: (row: T) => boolean;
36
+ canSelect: (row: T) => boolean;
37
+ selectRange: (toIndex: number) => boolean;
38
+ /** Anchor the next Shift+click range on this row's key. */
39
+ setAnchor: (key: string | number) => void;
40
+ /** Remember the checkbox click that drew a range… */
41
+ markRangeClick: (e: Event) => void;
42
+ /** …and drop that click's own change event (true once for it). */
43
+ takeRangeClick: (e: Event) => boolean;
44
+ setFocusRowKey: (key: string | number) => void;
45
+ /** The phone card's long press: enter selection mode with this row selected. */
46
+ onLongPress: (row: T) => void;
47
+ /** Under `virtualize` (0.48.2): Arrow/Home/End over every row, rendered or not. True
48
+ * when the key was one of them. Without it the roving walks the rendered rows. */
49
+ moveFocus?: (fromIndex: number, key: string) => boolean;
50
+ }
51
+
52
+ /** `rowAttributes`' result compared key by key: a call site returns a fresh object (or
53
+ * `undefined`) per call, and its values are strings. */
54
+ function sameAttrs(
55
+ a: Record<string, string | undefined> | undefined,
56
+ b: Record<string, string | undefined> | undefined,
57
+ ): boolean {
58
+ if (a === b) return true;
59
+ if (!a || !b) return false;
60
+ const ak = Object.keys(a);
61
+ if (ak.length !== Object.keys(b).length) return false;
62
+ for (const k of ak) if (a[k] !== b[k]) return false;
63
+ return true;
64
+ }
65
+
66
+ function sameProps<P extends { attrs?: Record<string, string | undefined> }>(prev: P, next: P): boolean {
67
+ for (const k of Object.keys(next) as (keyof P)[]) {
68
+ if (k === "attrs") continue;
69
+ if (!Object.is(prev[k], next[k])) return false;
70
+ }
71
+ return sameAttrs(prev.attrs, next.attrs);
72
+ }
73
+
74
+ /** Arrow/Home/End between the focusable rows of the table's bodies — the roving half of
75
+ * the keyboard-activatable rows (see the row's `onKeyDown`). */
76
+ function moveRowFocus(from: HTMLElement, key: string): boolean {
77
+ if (!(key in ROW_NAV_KEYS)) return false;
78
+ // Every body of the table: a grouped table (`groupBy`, 0.48) has one per group.
79
+ const table = from.closest("table");
80
+ if (!table) return false;
81
+ const rows = Array.from(table.tBodies).flatMap((body) =>
82
+ Array.from(body.children).filter(
83
+ (el): el is HTMLElement => el instanceof HTMLElement && el.hasAttribute("data-row-nav"),
84
+ ),
85
+ );
86
+ const i = rows.indexOf(from);
87
+ const target =
88
+ key === "ArrowDown"
89
+ ? rows[i + 1]
90
+ : key === "ArrowUp"
91
+ ? rows[i - 1]
92
+ : key === "Home"
93
+ ? rows[0]
94
+ : key === "End"
95
+ ? rows[rows.length - 1]
96
+ : undefined;
97
+ // At either end the key is still consumed, or ArrowDown on the last row would
98
+ // scroll the page out from under a focused row.
99
+ target?.focus();
100
+ return true;
101
+ }
102
+ const ROW_NAV_KEYS = { ArrowDown: 1, ArrowUp: 1, Home: 1, End: 1 } as const;
103
+
104
+ // ---------- Desktop row ----------
105
+
106
+ export interface DataTableRowProps<T> {
107
+ row: T;
108
+ rowIndex: number;
109
+ rowKey: string | number;
110
+ ctx: RefObject<DataTableRowCtx<T>>;
111
+ columns: DataTableColumn<T>[];
112
+ widths: Record<string, number>;
113
+ growDrawn: boolean;
114
+ linkColumn: DataTableColumn<T> | undefined;
115
+ href: string | undefined;
116
+ /** The selection column is drawn (DataTable's `selection`). */
117
+ selectionColumn: boolean;
118
+ selectable: boolean;
119
+ selected: boolean;
120
+ expanded: boolean;
121
+ /** The open row's detail; `null` on a closed row. */
122
+ expansion: ReactNode;
123
+ /** The row carries the visually hidden disclosure button (focusable rows that expand). */
124
+ disclosure: boolean;
125
+ detailId: string;
126
+ rowsFocusable: boolean;
127
+ rowInteractive: boolean;
128
+ tabbable: boolean;
129
+ className: string | undefined;
130
+ attrs: Record<string, string | undefined> | undefined;
131
+ compact: boolean;
132
+ totalColSpan: number;
133
+ rowDetailsLabel: string;
134
+ selectRowLabel: string;
135
+ /** Under `virtualize` (0.48.2): the row's `aria-rowindex` (its open detail row is the
136
+ * next), and the row is marked with its index for the window's measuring. */
137
+ ariaRowIndex?: number;
138
+ }
139
+
140
+ function DataTableRowImpl<T>({
141
+ row,
142
+ rowIndex,
143
+ rowKey: key,
144
+ ctx,
145
+ columns,
146
+ widths,
147
+ growDrawn,
148
+ linkColumn,
149
+ href,
150
+ selectionColumn,
151
+ selectable,
152
+ selected,
153
+ expanded,
154
+ expansion,
155
+ disclosure: hasDisclosure,
156
+ detailId,
157
+ rowsFocusable,
158
+ rowInteractive,
159
+ tabbable,
160
+ className,
161
+ attrs,
162
+ compact,
163
+ totalColSpan,
164
+ rowDetailsLabel,
165
+ selectRowLabel,
166
+ ariaRowIndex,
167
+ }: DataTableRowProps<T>) {
168
+ const windowed = ariaRowIndex !== undefined;
169
+ // Whether the row is open, said where ARIA allows it (round 0.34, the
170
+ // showcase's axe check): `aria-expanded` belongs to a treegrid's row, not a
171
+ // table's, and the <tr> wore it. A disclosure button in the first cell carries
172
+ // it now, with `aria-controls` on the detail row while that is there.
173
+ // Visually hidden and not a tab stop: the row stays the one roving stop, with
174
+ // Enter/Space and the whole-row click — this is the screen reader cursor's
175
+ // way in, and where it hears the state. Named by `aria-label`, so the cell's
176
+ // text stays the cell's own.
177
+ const disclosure = hasDisclosure ? (
178
+ <button
179
+ type="button"
180
+ tabIndex={-1}
181
+ className="sr-only-fixed"
182
+ aria-label={rowDetailsLabel}
183
+ aria-expanded={expanded}
184
+ aria-controls={expansion ? detailId : undefined}
185
+ // The row's own handler leaves a click on a control inside it alone.
186
+ onClick={() => ctx.current.onRowClick!(row)}
187
+ />
188
+ ) : null;
189
+ return (
190
+ <Fragment>
191
+ <tr
192
+ {...cleanAttrs(attrs)}
193
+ aria-rowindex={ariaRowIndex}
194
+ data-row-index={windowed ? rowIndex : undefined}
195
+ data-row-nav={rowsFocusable ? "" : undefined}
196
+ tabIndex={rowsFocusable ? (tabbable ? 0 : -1) : undefined}
197
+ onFocus={
198
+ rowsFocusable
199
+ ? (e) => {
200
+ if (e.target === e.currentTarget) ctx.current.setFocusRowKey(key);
201
+ }
202
+ : undefined
203
+ }
204
+ onKeyDown={
205
+ rowsFocusable
206
+ ? (e) => {
207
+ // Only the row's own key presses: Enter on a nested button
208
+ // or link is that control's, and Space in a nested input
209
+ // is a space.
210
+ if (e.target !== e.currentTarget) return;
211
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
212
+ if (e.key === "Enter" || e.key === " ") {
213
+ // Space would otherwise scroll the table.
214
+ e.preventDefault();
215
+ ctx.current.onRowClick!(row);
216
+ return;
217
+ }
218
+ if (e.shiftKey) return;
219
+ const move = ctx.current.moveFocus;
220
+ if (move ? move(rowIndex, e.key) : moveRowFocus(e.currentTarget, e.key)) e.preventDefault();
221
+ }
222
+ : undefined
223
+ }
224
+ className={cn(
225
+ "border-t border-subtle",
226
+ rowInteractive && "cursor-pointer hover:bg-hover",
227
+ // The kit's focus frame width (§5, `--focus-ring-width`), drawn as an
228
+ // OUTLINE rather than FOCUS_RING's box-shadow: a shadow on a `<tr>`
229
+ // is not painted by every engine, an outline is.
230
+ rowsFocusable &&
231
+ "focus-visible:bg-hover focus-visible:outline-focus focus-visible:-outline-offset-2 focus-visible:outline-brand",
232
+ className,
233
+ )}
234
+ onClick={
235
+ rowInteractive
236
+ ? (e: ReactMouseEvent<HTMLTableRowElement>) => {
237
+ const c = ctx.current;
238
+ // A click on a control inside the row (a button, a link,
239
+ // an input the cell renders) is that control's — it must
240
+ // not also open the row. Cells no longer have to
241
+ // `stopPropagation` to get that.
242
+ if (fromOwnControl(e.target, e.currentTarget)) return;
243
+ if (fromOutside(e.target, e.currentTarget)) return;
244
+ // Modifier-clicks anywhere in the row drive
245
+ // selection instead of expanding it (feedback #289):
246
+ // Ctrl/Cmd toggles a single row, Shift selects the
247
+ // range from the last-toggled row (inclusive of this
248
+ // one) — the same as the checkbox, but for the whole
249
+ // row, which is what the checkbox-only version was
250
+ // missing.
251
+ if (c.onToggle && c.canSelect(row)) {
252
+ if (e.shiftKey) {
253
+ // Drop the text selection a shift-click would
254
+ // otherwise smear across the rows.
255
+ window.getSelection?.()?.removeAllRanges();
256
+ if (c.selectRange(rowIndex)) return;
257
+ // No anchor yet: treat it as the first pick.
258
+ c.setAnchor(key);
259
+ c.onToggle(row, !c.isSelected(row));
260
+ return;
261
+ }
262
+ if (e.metaKey || e.ctrlKey) {
263
+ c.setAnchor(key);
264
+ c.onToggle(row, !c.isSelected(row));
265
+ return;
266
+ }
267
+ }
268
+ c.onRowClick?.(row);
269
+ }
270
+ : undefined
271
+ }
272
+ >
273
+ {selectionColumn && (
274
+ <td
275
+ className={cn("align-top", compact ? "w-8 px-2 py-1" : "w-10 px-3 py-2")}
276
+ // Don't let selecting a row also trigger the row click
277
+ // (which expands/edits it).
278
+ onClick={(e) => e.stopPropagation()}
279
+ >
280
+ {selectable && (
281
+ <input
282
+ type="checkbox"
283
+ className={cn("accent-indigo-600", compact ? "size-3.5" : "size-4")}
284
+ checked={selected}
285
+ // Shift+click selects the range from the last toggled
286
+ // row. `preventDefault` puts the box back, but it does NOT
287
+ // stop React's `onChange`: React derives a checkbox's
288
+ // change from the `click` itself, after the browser has
289
+ // already flipped `checked` and before the cancellation
290
+ // reverts it — so the same click also reported itself as
291
+ // a single toggle, and the owner got `onToggleMany` and
292
+ // then `onToggle` for the clicked row. The click is
293
+ // remembered by identity and its change is dropped; a
294
+ // boolean flag would outlive a click whose change never
295
+ // came and swallow the user's next real one.
296
+ onClick={(e) => {
297
+ if (e.shiftKey && ctx.current.selectRange(rowIndex)) {
298
+ e.preventDefault();
299
+ ctx.current.markRangeClick(e.nativeEvent);
300
+ }
301
+ }}
302
+ onChange={(e) => {
303
+ const c = ctx.current;
304
+ if (c.takeRangeClick(e.nativeEvent)) return;
305
+ c.setAnchor(key);
306
+ c.onToggle?.(row, e.target.checked);
307
+ }}
308
+ aria-label={selectRowLabel}
309
+ />
310
+ )}
311
+ </td>
312
+ )}
313
+ {columns.map((col, colIndex) => {
314
+ const width = widths[col.key];
315
+ // The caller's first, the table's over it — see `cellProps`.
316
+ const own = splitCellProps(col.cellProps?.(row));
317
+ const pinned = width ? { width, minWidth: width, maxWidth: width } : undefined;
318
+ return (
319
+ <td
320
+ {...own.attrs}
321
+ key={col.key}
322
+ data-col={col.key}
323
+ style={own.style || pinned ? { ...own.style, ...pinned } : undefined}
324
+ className={cn(
325
+ "align-top",
326
+ compact ? "px-2 py-1" : "px-3 py-2",
327
+ width && "overflow-hidden text-ellipsis",
328
+ col.grow ? GROW_CELL_CLASS : growDrawn && GROW_PEER_CELL_CLASS,
329
+ logicalAlign(own.className),
330
+ logicalAlign(col.className),
331
+ )}
332
+ >
333
+ {colIndex === 0 && disclosure}
334
+ {col === linkColumn && href ? (
335
+ // No `onActivate`: the click is left to bubble to the
336
+ // <tr> handler above, which already owns expand-vs-select.
337
+ // Calling it here as well would toggle the row twice.
338
+ <RowLink href={href} className="block" data-row-link="">
339
+ {col.cell(row)}
340
+ </RowLink>
341
+ ) : (
342
+ col.cell(row)
343
+ )}
344
+ </td>
345
+ );
346
+ })}
347
+ </tr>
348
+ {expansion && (
349
+ <tr
350
+ id={hasDisclosure ? detailId : undefined}
351
+ aria-rowindex={windowed ? ariaRowIndex + 1 : undefined}
352
+ data-row-detail={windowed ? rowIndex : undefined}
353
+ className="bg-surface-2 border-t border-subtle"
354
+ >
355
+ {/* The detail panel spans every column. In an `auto` table
356
+ layout (the default here — columns only get fixed style
357
+ widths once the user resizes/auto-sizes them) the cell's
358
+ content preferred-width is redistributed across the
359
+ spanned columns, so a long expansion visibly re-sizes the
360
+ columns above it — and one-line content does not, since
361
+ its preferred width is small (feedback #104). Wrapping the
362
+ content in `w-0 min-w-full` pins the cell's preferred
363
+ width to ~0 (the explicit width:0 child), so it can never
364
+ perturb the columns; the div then fills to the full row
365
+ width only at paint time, and long text wraps within it.
366
+ Inside it the content is pinned to what the scroller SHOWS
367
+ (0.47, keksdose S4): on a table wider than its scroller a
368
+ table-wide panel put an inline editor's Save/Cancel off-screen.
369
+ `sticky start-0` holds it at the scroller's start edge (the
370
+ right one in RTL) as the row scrolls sideways, and
371
+ `--dt-visible-w` (the scroller's clientWidth, measured above)
372
+ sizes it; before the first measure, and in a table no wider
373
+ than its scroller, it is the full width as before. */}
374
+ <td colSpan={totalColSpan} className="p-0">
375
+ <div className="w-0 min-w-full">
376
+ <div
377
+ className={cn(
378
+ "sticky start-0 w-[var(--dt-visible-w,100%)] max-w-full",
379
+ compact ? "px-2 py-2" : "px-3 py-3",
380
+ )}
381
+ >
382
+ {expansion}
383
+ </div>
384
+ </div>
385
+ </td>
386
+ </tr>
387
+ )}
388
+ </Fragment>
389
+ );
390
+ }
391
+
392
+ /** One desktop row (its `<tr>` and, while open, its detail row), memoised — see the
393
+ * file's note for what it compares. */
394
+ export const DataTableRow = memo(DataTableRowImpl, sameProps) as <T>(
395
+ props: DataTableRowProps<T> & { key?: string | number },
396
+ ) => ReactNode;
397
+
398
+ // ---------- Phone card ----------
399
+
400
+ export interface DataTableMobileRowProps<T>
401
+ extends Pick<
402
+ MobileRowProps<T>,
403
+ | "row"
404
+ | "opensRow"
405
+ | "mobileDir"
406
+ | "mobileCardLinkable"
407
+ | "mobileCard"
408
+ | "mobilePrimaryCol"
409
+ | "mobileSecondaryColumns"
410
+ | "mobileExpandAsDialog"
411
+ | "compact"
412
+ | "large"
413
+ > {
414
+ ctx: RefObject<DataTableRowCtx<T>>;
415
+ rowKey: string | number;
416
+ expanded: boolean;
417
+ /** The opened row's content (`openedRowContent`); `null` on a closed card. */
418
+ expansion: ReactNode;
419
+ className: string | undefined;
420
+ attrs: Record<string, string | undefined> | undefined;
421
+ /** The table has `onRowClick`. */
422
+ interactive: boolean;
423
+ href: string | undefined;
424
+ /** `mobileSwipeActions`, or the swipes derived from `rowActions` — by identity. */
425
+ swipeActionsFor: ((row: T) => MobileSwipeActions | null) | undefined;
426
+ /** The table has `selection`. */
427
+ selectionOn: boolean;
428
+ selectActive: boolean;
429
+ selectable: boolean;
430
+ selected: boolean;
431
+ }
432
+
433
+ const yes = () => true;
434
+
435
+ function DataTableMobileRowImpl<T>({
436
+ ctx,
437
+ rowKey: key,
438
+ expanded,
439
+ expansion,
440
+ className,
441
+ attrs,
442
+ interactive,
443
+ href,
444
+ selectionOn,
445
+ selectActive,
446
+ selectable,
447
+ selected,
448
+ ...rest
449
+ }: DataTableMobileRowProps<T>) {
450
+ const { row } = rest;
451
+ // `MobileRow` keeps its own shape (functions of the row); these hand it the values the
452
+ // table derived, and the handlers read the latest props through `ctx`.
453
+ return (
454
+ <MobileRow
455
+ {...rest}
456
+ isExpanded={expanded ? yes : undefined}
457
+ openedRowContent={() => expansion}
458
+ rowClassName={() => className}
459
+ rowAttributes={() => attrs}
460
+ onRowClick={interactive ? (r) => ctx.current.onRowClick!(r) : undefined}
461
+ rowHref={href !== undefined ? () => href : undefined}
462
+ select={
463
+ selectionOn
464
+ ? {
465
+ active: selectActive,
466
+ selectable,
467
+ selected,
468
+ onToggle: (checked) => {
469
+ ctx.current.setAnchor(key);
470
+ ctx.current.onToggle?.(row, checked);
471
+ },
472
+ onLongPress: () => ctx.current.onLongPress(row),
473
+ }
474
+ : null
475
+ }
476
+ />
477
+ );
478
+ }
479
+
480
+ /** One phone card, memoised like {@link DataTableRow}. */
481
+ export const DataTableMobileRow = memo(DataTableMobileRowImpl, sameProps) as <T>(
482
+ props: DataTableMobileRowProps<T> & { key?: string | number },
483
+ ) => ReactNode;
@@ -51,5 +51,21 @@ export function useRangeSelection<T>({
51
51
  else for (const r of range) selection.onToggle(r, true);
52
52
  return true;
53
53
  };
54
- return { selectionAnchor, rangeClick, canSelect, selectRange };
54
+ // The ref writes as functions (0.48.2): the memoised rows (data-table-row.tsx) reach
55
+ // them through DataTable's row context rather than holding the refs themselves.
56
+ /** Every single-row toggle anchors the next Shift+click's range on its row. */
57
+ const setAnchor = (key: string | number) => {
58
+ selectionAnchor.current = key;
59
+ };
60
+ /** Remember the checkbox click that drew a range. */
61
+ const markRangeClick = (e: Event) => {
62
+ rangeClick.current = e;
63
+ };
64
+ /** True — once — for the change event of the click that drew a range. */
65
+ const takeRangeClick = (e: Event) => {
66
+ if (!rangeClick.current || rangeClick.current !== e) return false;
67
+ rangeClick.current = null;
68
+ return true;
69
+ };
70
+ return { selectionAnchor, rangeClick, canSelect, selectRange, setAnchor, markRangeClick, takeRangeClick };
55
71
  }