@proteos/ui 0.46.0 → 0.47.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "license": "FSL-1.1-Apache-2.0",
5
5
  "description": "Proteos React component library (React Aria Components + Tailwind v4).",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  "tailwind-merge": "^3.5.0",
56
56
  "tailwind-variants": "^3.2.2",
57
57
  "tailwindcss": "^4.2.4",
58
- "@proteos/sdk": "0.46.0"
58
+ "@proteos/sdk": "0.47.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@biomejs/biome": "^2.4.13",
@@ -16,7 +16,14 @@ export function Checkbox({ children, className, ...props }: CheckboxProps) {
16
16
  {...props}
17
17
  className={(renderProps) =>
18
18
  cn(
19
- 'group inline-flex items-center gap-2.5 cursor-pointer',
19
+ // `relative` so RAC's visually-hidden <input> — which is absolutely
20
+ // positioned and is the element that actually receives focus — is
21
+ // laid out against THIS checkbox. Without a positioned ancestor here
22
+ // it resolves against a far-away container, ends up outside any
23
+ // scroll clipping, and focusing it makes the browser scroll a
24
+ // container the user cannot scroll back (the roles permission
25
+ // matrix, where toggling a row threw the page off-screen).
26
+ 'group relative inline-flex items-center gap-2.5 cursor-pointer',
20
27
  'font-sans text-[14px] text-ink-2 select-none',
21
28
  'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
22
29
  typeof className === 'function' ? className(renderProps) : className,
@@ -0,0 +1,112 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { useState } from 'react'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+
6
+ import { DataTable, type InfiniteQueryLike } from './data-table'
7
+
8
+ type Row = { id: string; name: string }
9
+
10
+ const ROWS: Row[] = [
11
+ { id: 'r-1', name: 'Alpha' },
12
+ { id: 'r-2', name: 'Beta' },
13
+ { id: 'r-3', name: 'Gamma' },
14
+ ]
15
+
16
+ function query(rows: Row[]): InfiniteQueryLike<Row> {
17
+ return {
18
+ data: { pages: [{ data: rows, meta: { items_total: rows.length } }] },
19
+ isLoading: false,
20
+ isFetchingNextPage: false,
21
+ hasNextPage: false,
22
+ error: null,
23
+ fetchNextPage: vi.fn(),
24
+ }
25
+ }
26
+
27
+ const columns = [{ id: 'name', accessorKey: 'name', header: 'Name', size: 160 }]
28
+
29
+ function Harness({ onChange }: { onChange?: (ids: Set<string>) => void }) {
30
+ const [selected, setSelected] = useState<Set<string>>(new Set())
31
+ return (
32
+ <DataTable<Row>
33
+ query={query(ROWS)}
34
+ columns={columns}
35
+ ariaLabel="Records"
36
+ selectionMode="multiple"
37
+ selectedIds={selected}
38
+ onSelectionChange={(ids) => {
39
+ setSelected(ids)
40
+ onChange?.(ids)
41
+ }}
42
+ />
43
+ )
44
+ }
45
+
46
+ // The body is virtualized against a real scroll box, which jsdom cannot
47
+ // measure (every rect is 0×0, so the virtualizer yields no items and no row
48
+ // ever renders). Swap the virtualizer for a pass-through that emits every
49
+ // row — the windowing itself is TanStack's to test, not ours.
50
+ vi.mock('@tanstack/react-virtual', () => ({
51
+ useVirtualizer: ({ count, estimateSize }: { count: number; estimateSize: () => number }) => {
52
+ const size = estimateSize()
53
+ return {
54
+ getTotalSize: () => count * size,
55
+ getVirtualItems: () =>
56
+ Array.from({ length: count }, (_unused, index) => ({
57
+ index,
58
+ key: index,
59
+ start: index * size,
60
+ size,
61
+ })),
62
+ }
63
+ },
64
+ }))
65
+
66
+ describe('DataTable selection', () => {
67
+ it('renders no checkboxes when selection is off', () => {
68
+ render(<DataTable<Row> query={query(ROWS)} columns={columns} ariaLabel="Records" />)
69
+ expect(screen.queryByRole('checkbox')).toBeNull()
70
+ })
71
+
72
+ it('select-all toggles every loaded row, then clears', async () => {
73
+ const user = userEvent.setup()
74
+ const onChange = vi.fn()
75
+ render(<Harness onChange={onChange} />)
76
+
77
+ await user.click(screen.getByRole('checkbox', { name: 'Select all rows' }))
78
+ expect(onChange).toHaveBeenLastCalledWith(new Set(['r-1', 'r-2', 'r-3']))
79
+
80
+ await user.click(screen.getByRole('checkbox', { name: 'Deselect all rows' }))
81
+ expect(onChange).toHaveBeenLastCalledWith(new Set())
82
+ })
83
+
84
+ it('selecting one row leaves select-all indeterminate', async () => {
85
+ const user = userEvent.setup()
86
+ render(<Harness />)
87
+
88
+ await user.click(screen.getByRole('checkbox', { name: 'Select row 1' }))
89
+
90
+ const selectAll = screen.getByRole('checkbox', { name: 'Select all rows' })
91
+ expect(selectAll).toBePartiallyChecked()
92
+ })
93
+
94
+ it('does not open a row when its checkbox is clicked', async () => {
95
+ const user = userEvent.setup()
96
+ const onRowClick = vi.fn()
97
+ render(
98
+ <DataTable<Row>
99
+ query={query(ROWS)}
100
+ columns={columns}
101
+ ariaLabel="Records"
102
+ selectionMode="multiple"
103
+ selectedIds={new Set()}
104
+ onSelectionChange={vi.fn()}
105
+ onRowClick={onRowClick}
106
+ />,
107
+ )
108
+
109
+ await user.click(screen.getByRole('checkbox', { name: 'Select row 1' }))
110
+ expect(onRowClick).not.toHaveBeenCalled()
111
+ })
112
+ })
@@ -24,6 +24,7 @@ import {
24
24
  } from 'react'
25
25
 
26
26
  import { cn } from '../../lib/cn'
27
+ import { Checkbox } from '../checkbox'
27
28
  import {
28
29
  isColumnDragData,
29
30
  type SortDirection,
@@ -138,6 +139,20 @@ export type DataTableProps<TRow> = {
138
139
  * at slot='end'. Sits inside the scrollable area below the rows.
139
140
  */
140
141
  bodyAfter?: ReactNode
142
+ /**
143
+ * Row selection. `none` (default) renders no checkbox column; `multiple`
144
+ * prepends a sticky leading checkbox column and turns the header checkbox
145
+ * into a tri-state select-all.
146
+ *
147
+ * Selection is CONTROLLED — the owner holds the ids because it is the one
148
+ * that acts on them (running a batch action, clearing after a save). The
149
+ * header checkbox covers the rows currently LOADED: an infinite list can't
150
+ * honestly claim to select rows it has never fetched, so "select everything
151
+ * matching the filter" belongs to the owner's toolbar, not here.
152
+ */
153
+ selectionMode?: 'none' | 'multiple'
154
+ selectedIds?: Set<string>
155
+ onSelectionChange?: (ids: Set<string>) => void
141
156
  /**
142
157
  * Draw vertical gridlines between body cells. The header always has them;
143
158
  * the body opts in (e.g. inline-edit mode wants the full Excel-style grid).
@@ -148,6 +163,9 @@ export type DataTableProps<TRow> = {
148
163
 
149
164
  const DEFAULT_ROW_HEIGHT = 32
150
165
 
166
+ /** Column id of the leading checkbox column added by `selectionMode`. */
167
+ export const SELECTION_COLUMN_ID = '__select__'
168
+
151
169
  export function DataTable<TRow>({
152
170
  query,
153
171
  columns,
@@ -172,6 +190,9 @@ export function DataTable<TRow>({
172
190
  extraRows,
173
191
  bodyBefore,
174
192
  bodyAfter,
193
+ selectionMode = 'none',
194
+ selectedIds,
195
+ onSelectionChange,
175
196
  columnBorders = false,
176
197
  }: DataTableProps<TRow>) {
177
198
  const rows = useMemo<TRow[]>(() => {
@@ -183,21 +204,124 @@ export function DataTable<TRow>({
183
204
  return [...startExtras, ...dataRows, ...endExtras]
184
205
  }, [query.data, extraRows])
185
206
 
207
+ const resolveRowId = useMemo(
208
+ () =>
209
+ getRowId ??
210
+ ((row: TRow, index: number) => {
211
+ const r = row as Record<string, unknown>
212
+ if (typeof r.id === 'string' || typeof r.id === 'number') return String(r.id)
213
+ if (typeof r.slug === 'string') return r.slug
214
+ return String(index)
215
+ }),
216
+ [getRowId],
217
+ )
218
+
219
+ const selectionEnabled = selectionMode === 'multiple'
220
+ const selected = useMemo(() => selectedIds ?? new Set<string>(), [selectedIds])
221
+ // Ids of the rows currently LOADED — what the header checkbox operates on.
222
+ const loadedIds = useMemo(
223
+ () => (selectionEnabled ? rows.map((row, index) => resolveRowId(row, index)) : []),
224
+ [selectionEnabled, rows, resolveRowId],
225
+ )
226
+ // Anchor for shift-click range selection, in loaded-row index space.
227
+ const lastToggledIndexRef = useRef<number | null>(null)
228
+
229
+ const toggleRow = (id: string, index: number, isRange: boolean) => {
230
+ if (!onSelectionChange) return
231
+ const next = new Set(selected)
232
+ const anchor = lastToggledIndexRef.current
233
+ if (isRange && anchor !== null) {
234
+ const [from, to] = anchor <= index ? [anchor, index] : [index, anchor]
235
+ // A range drag always ADDS — matching how file managers and mail
236
+ // clients behave; use individual clicks to take rows back out.
237
+ for (let i = from; i <= to; i += 1) {
238
+ const rangeId = loadedIds[i]
239
+ if (rangeId !== undefined) next.add(rangeId)
240
+ }
241
+ } else if (next.has(id)) {
242
+ next.delete(id)
243
+ } else {
244
+ next.add(id)
245
+ }
246
+ lastToggledIndexRef.current = index
247
+ onSelectionChange(next)
248
+ }
249
+
250
+ const loadedSelectedCount = loadedIds.reduce((count, id) => count + (selected.has(id) ? 1 : 0), 0)
251
+ const allLoadedSelected = loadedIds.length > 0 && loadedSelectedCount === loadedIds.length
252
+
253
+ const toggleAllLoaded = () => {
254
+ if (!onSelectionChange) return
255
+ const next = new Set(selected)
256
+ if (allLoadedSelected) {
257
+ for (const id of loadedIds) next.delete(id)
258
+ } else {
259
+ for (const id of loadedIds) next.add(id)
260
+ }
261
+ lastToggledIndexRef.current = null
262
+ onSelectionChange(next)
263
+ }
264
+
265
+ // RAC's Checkbox onChange carries no modifier keys, so the shift state is
266
+ // captured on the preceding pointer event.
267
+ const rangeModifierRef = useRef(false)
268
+
269
+ const selectionColumn = useMemo<ColumnDef<TRow>>(
270
+ () => ({
271
+ id: SELECTION_COLUMN_ID,
272
+ size: 36,
273
+ enableSorting: false,
274
+ meta: { align: 'center', sticky: 'left', cellClassName: 'px-0 justify-center' },
275
+ header: () => (
276
+ <span className="inline-flex items-center justify-center w-full">
277
+ <Checkbox
278
+ aria-label={allLoadedSelected ? 'Deselect all rows' : 'Select all rows'}
279
+ isSelected={allLoadedSelected}
280
+ isIndeterminate={loadedSelectedCount > 0 && !allLoadedSelected}
281
+ isDisabled={loadedIds.length === 0}
282
+ onChange={toggleAllLoaded}
283
+ />
284
+ </span>
285
+ ),
286
+ cell: ({ row }) => (
287
+ // Stop the click here: selecting a row must never also open it.
288
+ <span
289
+ className="inline-flex items-center justify-center w-full"
290
+ onClick={(event) => event.stopPropagation()}
291
+ onKeyDown={(event) => {
292
+ event.stopPropagation()
293
+ rangeModifierRef.current = event.shiftKey
294
+ }}
295
+ onPointerDownCapture={(event) => {
296
+ rangeModifierRef.current = event.shiftKey
297
+ }}
298
+ role="presentation"
299
+ >
300
+ <Checkbox
301
+ aria-label={`Select row ${row.index + 1}`}
302
+ isSelected={selected.has(row.id)}
303
+ onChange={() => toggleRow(row.id, row.index, rangeModifierRef.current)}
304
+ />
305
+ </span>
306
+ ),
307
+ }),
308
+ // biome-ignore lint/correctness/useExhaustiveDependencies: handlers read the latest selection via closure recreated on these inputs
309
+ [selected, loadedIds, allLoadedSelected, loadedSelectedCount],
310
+ )
311
+
312
+ const tableColumns = useMemo(
313
+ () => (selectionEnabled ? [selectionColumn, ...columns] : columns),
314
+ [selectionEnabled, selectionColumn, columns],
315
+ )
316
+
186
317
  const table = useReactTable({
187
318
  data: rows,
188
- columns,
319
+ columns: tableColumns,
189
320
  state: { sorting: sorting ?? [] },
190
321
  ...(onSortingChange ? { onSortingChange } : {}),
191
322
  manualSorting: true,
192
323
  getCoreRowModel: getCoreRowModel(),
193
- getRowId: getRowId
194
- ? (row, index) => getRowId(row, index)
195
- : (row, index) => {
196
- const r = row as Record<string, unknown>
197
- if (typeof r.id === 'string' || typeof r.id === 'number') return String(r.id)
198
- if (typeof r.slug === 'string') return r.slug
199
- return String(index)
200
- },
324
+ getRowId: (row, index) => resolveRowId(row, index),
201
325
  })
202
326
 
203
327
  const tableRows = table.getRowModel().rows
@@ -243,8 +367,8 @@ export function DataTable<TRow>({
243
367
  })
244
368
 
245
369
  const totalColumnWidth = useMemo(
246
- () => columns.reduce((sum, c) => sum + (c.size ?? 140), 0),
247
- [columns],
370
+ () => tableColumns.reduce((sum, c) => sum + (c.size ?? 140), 0),
371
+ [tableColumns],
248
372
  )
249
373
 
250
374
  // Single global pragmatic-dnd monitor for column reorder. We mirror the
@@ -7,7 +7,7 @@ export type {
7
7
  } from './cells'
8
8
  export { BooleanCell, DateCell, DateTimeCell, LinkCell, TruncateCell } from './cells'
9
9
  export type { DataTableProps, InfiniteQueryLike } from './data-table'
10
- export { DataTable } from './data-table'
10
+ export { DataTable, SELECTION_COLUMN_ID } from './data-table'
11
11
 
12
12
  export type { ActionsColumnOptions } from './make-actions-column'
13
13
  export { makeActionsColumn } from './make-actions-column'
package/src/test/setup.ts CHANGED
@@ -4,6 +4,36 @@ import { afterEach, expect } from 'vitest'
4
4
 
5
5
  expect.extend(matchers)
6
6
 
7
+ // jsdom implements neither Element.scrollTo nor IntersectionObserver, both of
8
+ // which the virtualized DataTable calls on mount. Stub them so scrolling
9
+ // components can be rendered in tests at all.
10
+ if (!Element.prototype.scrollTo) {
11
+ Element.prototype.scrollTo = () => {}
12
+ }
13
+ if (!('ResizeObserver' in globalThis)) {
14
+ class NoopResizeObserver implements ResizeObserver {
15
+ disconnect() {}
16
+ observe() {}
17
+ unobserve() {}
18
+ }
19
+ globalThis.ResizeObserver = NoopResizeObserver as unknown as typeof ResizeObserver
20
+ }
21
+ if (!('IntersectionObserver' in globalThis)) {
22
+ class NoopIntersectionObserver implements IntersectionObserver {
23
+ readonly root = null
24
+ readonly rootMargin = ''
25
+ readonly thresholds: ReadonlyArray<number> = []
26
+ disconnect() {}
27
+ observe() {}
28
+ unobserve() {}
29
+ takeRecords(): IntersectionObserverEntry[] {
30
+ return []
31
+ }
32
+ }
33
+ globalThis.IntersectionObserver =
34
+ NoopIntersectionObserver as unknown as typeof IntersectionObserver
35
+ }
36
+
7
37
  afterEach(() => {
8
38
  cleanup()
9
39
  })