@payfit/unity-components 2.35.4 → 2.35.5

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/README.md CHANGED
@@ -1,3 +1,13 @@
1
1
  # Unity components
2
2
 
3
3
  The Unity component library exports the complete set of reusable components defined in PayFit's new Unity Design system.
4
+
5
+ ## 🤖 Using an AI coding agent
6
+
7
+ If you use an AI coding agent (Claude Code, Cursor, Copilot, etc.), run:
8
+
9
+ ```shell
10
+ npx @tanstack/intent@latest install
11
+ ```
12
+
13
+ This wires the agent's config so it discovers and loads the per-package skills under `libs/shared/unity/<pkg>/skills/`. See `_artifacts/domain_map.yaml` for the full skill index.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payfit/unity-components",
3
- "version": "2.35.4",
3
+ "version": "2.35.5",
4
4
  "module": "./dist/esm/index.js",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -34,7 +34,8 @@
34
34
  },
35
35
  "files": [
36
36
  "dist",
37
- "i18n"
37
+ "i18n",
38
+ "skills"
38
39
  ],
39
40
  "dependencies": {
40
41
  "@ariakit/react": "0.4.26",
@@ -42,7 +43,7 @@
42
43
  "@hookform/devtools": "4.4.0",
43
44
  "@hookform/resolvers": "5.2.1",
44
45
  "@internationalized/date": "3.12.1",
45
- "@payfit/unity-illustrations": "2.35.4",
46
+ "@payfit/unity-illustrations": "2.35.5",
46
47
  "@radix-ui/react-avatar": "1.1.11",
47
48
  "@radix-ui/react-slot": "1.2.4",
48
49
  "@react-aria/interactions": "3.28.0",
@@ -74,8 +75,8 @@
74
75
  },
75
76
  "peerDependencies": {
76
77
  "@hookform/devtools": "^4",
77
- "@payfit/unity-icons": "2.35.4",
78
- "@payfit/unity-themes": "2.35.4",
78
+ "@payfit/unity-icons": "2.35.5",
79
+ "@payfit/unity-themes": "2.35.5",
79
80
  "@storybook/react-vite": "^10.3.2",
80
81
  "@tanstack/react-query": "^5",
81
82
  "@tanstack/react-router": "^1.131",
@@ -89,9 +90,9 @@
89
90
  "@figma/code-connect": "1.4.3",
90
91
  "@hookform/devtools": "4.4.0",
91
92
  "@internationalized/date": "3.12.1",
92
- "@payfit/unity-icons": "2.35.4",
93
- "@payfit/unity-illustrations": "2.35.4",
94
- "@payfit/unity-themes": "2.35.4",
93
+ "@payfit/unity-icons": "2.35.5",
94
+ "@payfit/unity-illustrations": "2.35.5",
95
+ "@payfit/unity-themes": "2.35.5",
95
96
  "@storybook/addon-a11y": "10.3.5",
96
97
  "@storybook/addon-designs": "11.1.3",
97
98
  "@storybook/addon-docs": "10.3.5",
@@ -100,6 +101,7 @@
100
101
  "@storybook/addon-themes": "10.3.5",
101
102
  "@storybook/addon-vitest": "10.3.5",
102
103
  "@storybook/react-vite": "10.3.5",
104
+ "@tanstack/intent": "0.0.40",
103
105
  "@tanstack/react-devtools": "0.10.1",
104
106
  "@tanstack/react-form-devtools": "0.2.22",
105
107
  "@tanstack/react-query": "5.99.0",
@@ -129,9 +131,9 @@
129
131
  "vite": "7.1.12",
130
132
  "vite-plugin-node-polyfills": "0.24.0",
131
133
  "vitest": "4.1.0",
132
- "@payfit/hr-apps-tsconfigs": "0.0.0-use.local",
133
134
  "@payfit/code-pushup-tools": "0.0.0-use.local",
134
135
  "@payfit/hr-app-eslint": "0.0.0-use.local",
136
+ "@payfit/hr-apps-tsconfigs": "0.0.0-use.local",
135
137
  "@payfit/storybook-addon-console-errors": "0.0.0-use.local",
136
138
  "@payfit/storybook-config": "0.0.0-use.local",
137
139
  "@payfit/vite-configs": "0.0.0-use.local"
@@ -158,5 +160,8 @@
158
160
  "zod": {
159
161
  "optional": true
160
162
  }
161
- }
163
+ },
164
+ "keywords": [
165
+ "tanstack-intent"
166
+ ]
162
167
  }
@@ -0,0 +1,512 @@
1
+ ---
2
+ name: unity-data-table
3
+ description: >
4
+ Render tabular data in @payfit/unity-components. Pick between the composite
5
+ DataTable (Tanstack Table integration with sorting, filtering, pagination,
6
+ virtualization, bulk actions, F6 focus trap) and the primitive Table
7
+ (TableHeader/TableBody/TableRow/TableCell/TableEmptyState). Filter and
8
+ FilterToolbar orchestrate the AppliedFilter[] contract; Filter.renderControl
9
+ accepts arbitrary controls (not select-only). Columns AND data MUST be
10
+ memoized. manualPagination requires re-slicing data.
11
+ type: core
12
+ library: '@payfit/unity-components'
13
+ library_version: '2.x'
14
+ sources:
15
+ - 'PayFit/hr-apps:libs/shared/unity/components/src/components/data-table/DataTable.tsx'
16
+ - 'PayFit/hr-apps:libs/shared/unity/components/src/components/data-table/parts/DataTableRoot.tsx'
17
+ - 'PayFit/hr-apps:libs/shared/unity/components/src/components/data-table/parts/DataTableBulkActions.tsx'
18
+ - 'PayFit/hr-apps:libs/shared/unity/components/src/components/table/Table.tsx'
19
+ - 'PayFit/hr-apps:libs/shared/unity/components/src/components/filter-toolbar/FilterToolbar.tsx'
20
+ - 'PayFit/hr-apps:libs/shared/unity/components/src/components/filter/Filter.tsx'
21
+ - 'PayFit/hr-apps:libs/shared/unity/components/src/docs/guides/data/Building Tables.mdx'
22
+ ---
23
+
24
+ DataTable is the composite (Tanstack Table + Unity Table + pagination + empty
25
+ states + virtualization). Table is the primitive — use only when you have no
26
+ table state to manage.
27
+
28
+ ## Setup
29
+
30
+ Minimum working client-side DataTable. Columns and data MUST be memoized.
31
+
32
+ ```tsx
33
+ import { useMemo, useState } from 'react'
34
+
35
+ import {
36
+ DataTable,
37
+ DataTableRoot,
38
+ TableCell,
39
+ TableRow,
40
+ } from '@payfit/unity-components'
41
+ import {
42
+ createColumnHelper,
43
+ flexRender,
44
+ getCoreRowModel,
45
+ getPaginationRowModel,
46
+ useReactTable,
47
+ } from '@tanstack/react-table'
48
+
49
+ type Employee = {
50
+ id: string
51
+ name: string
52
+ position: string
53
+ status: 'active' | 'inactive'
54
+ }
55
+
56
+ const columnHelper = createColumnHelper<Employee>()
57
+
58
+ export function EmployeeTable({ employees }: { employees: Employee[] }) {
59
+ const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })
60
+
61
+ const columns = useMemo(
62
+ () => [
63
+ columnHelper.accessor('name', {
64
+ header: 'Name',
65
+ meta: { isRowHeader: true, headerClassName: 'uy:w-1/3' },
66
+ }),
67
+ columnHelper.accessor('position', { header: 'Position' }),
68
+ columnHelper.accessor('status', { header: 'Status' }),
69
+ ],
70
+ [],
71
+ )
72
+
73
+ const data = useMemo(() => employees, [employees])
74
+
75
+ const table = useReactTable({
76
+ data,
77
+ columns,
78
+ getRowId: row => row.id,
79
+ state: { pagination },
80
+ onPaginationChange: setPagination,
81
+ getCoreRowModel: getCoreRowModel(),
82
+ getPaginationRowModel: getPaginationRowModel(),
83
+ })
84
+
85
+ return (
86
+ <DataTableRoot>
87
+ <DataTable table={table} layout="fixed">
88
+ {row => (
89
+ <TableRow key={row.id}>
90
+ {row.getVisibleCells().map(cell => (
91
+ <TableCell key={cell.id}>
92
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
93
+ </TableCell>
94
+ ))}
95
+ </TableRow>
96
+ )}
97
+ </DataTable>
98
+ </DataTableRoot>
99
+ )
100
+ }
101
+ ```
102
+
103
+ ## Core Patterns
104
+
105
+ ### Define columns with the column helper and ColumnMeta
106
+
107
+ ColumnMeta drives accessibility (`isRowHeader`), keyboard navigation
108
+ (`isFocusable: false` for cells whose children are themselves focusable like
109
+ checkboxes), `helperText` (renders a tooltip next to the header), and
110
+ `headerClassName` (required when `layout="fixed"`).
111
+
112
+ ```tsx
113
+ import { Badge } from '@payfit/unity-components'
114
+ import { createColumnHelper } from '@tanstack/react-table'
115
+
116
+ const columnHelper = createColumnHelper<Employee>()
117
+
118
+ export const employeeColumns = [
119
+ columnHelper.accessor('name', {
120
+ id: 'employee',
121
+ header: 'Employee',
122
+ enableSorting: true,
123
+ meta: { isRowHeader: true, headerClassName: 'uy:w-[260px]' },
124
+ }),
125
+ columnHelper.accessor('status', {
126
+ header: 'Status',
127
+ enableColumnFilter: true,
128
+ filterFn: 'arrIncludesSome',
129
+ cell: info => {
130
+ const status = info.getValue()
131
+ return (
132
+ <Badge variant={status === 'active' ? 'success' : 'neutral'}>
133
+ {status}
134
+ </Badge>
135
+ )
136
+ },
137
+ meta: { helperText: 'Active employees can sign in.' },
138
+ }),
139
+ columnHelper.display({
140
+ id: 'actions',
141
+ header: '',
142
+ cell: ({ row }) => <RowMenu row={row.original} />,
143
+ meta: { isFocusable: false },
144
+ }),
145
+ ]
146
+ ```
147
+
148
+ ### Server-side pagination
149
+
150
+ `manualPagination: true` disables Tanstack's internal slicing. Pass only the
151
+ current page's slice as `data` and supply `pageCount`.
152
+
153
+ ```tsx
154
+ import { useMemo, useState } from 'react'
155
+
156
+ import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
157
+
158
+ export function ServerTable({ totalCount, fetchPage }: Props) {
159
+ const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 })
160
+ const { data: pageRows = [] } = useQuery({
161
+ queryKey: ['employees', pagination],
162
+ queryFn: () => fetchPage(pagination.pageIndex, pagination.pageSize),
163
+ })
164
+
165
+ const columns = useMemo(() => employeeColumns, [])
166
+ const data = useMemo(() => pageRows, [pageRows])
167
+
168
+ const table = useReactTable({
169
+ data,
170
+ columns,
171
+ manualPagination: true,
172
+ pageCount: Math.ceil(totalCount / pagination.pageSize),
173
+ state: { pagination },
174
+ onPaginationChange: setPagination,
175
+ getCoreRowModel: getCoreRowModel(),
176
+ })
177
+
178
+ return (
179
+ <DataTableRoot>
180
+ <DataTable table={table}>
181
+ {row => (
182
+ <TableRow key={row.id}>
183
+ {row.getVisibleCells().map(cell => (
184
+ <TableCell key={cell.id}>
185
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
186
+ </TableCell>
187
+ ))}
188
+ </TableRow>
189
+ )}
190
+ </DataTable>
191
+ </DataTableRoot>
192
+ )
193
+ }
194
+ ```
195
+
196
+ ### Filtering with FilterToolbar
197
+
198
+ `FilterToolbar.onChange` emits `SerializableAppliedFilter[]`. Map that onto
199
+ `table.setColumnFilters` / `table.setGlobalFilter`. `renderControl` takes any
200
+ control — it is a render function on purpose so you can drop in date pickers,
201
+ multi-selects, text fields, etc.
202
+
203
+ ```tsx
204
+ import type { FilterDef } from '@payfit/unity-components'
205
+
206
+ import { FilterToolbar, Select, SelectItem } from '@payfit/unity-components'
207
+ import { getFilteredRowModel } from '@tanstack/react-table'
208
+
209
+ const filterDefs: FilterDef[] = [
210
+ {
211
+ id: 'status',
212
+ label: 'Status',
213
+ renderControl: (value, onChange) => (
214
+ <Select selectedKey={value as string} onSelectionChange={onChange}>
215
+ <SelectItem id="active">Active</SelectItem>
216
+ <SelectItem id="inactive">Inactive</SelectItem>
217
+ </Select>
218
+ ),
219
+ renderLabel: value => String(value),
220
+ },
221
+ ]
222
+
223
+ export function FilteredTable() {
224
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
225
+
226
+ const table = useReactTable({
227
+ data,
228
+ columns,
229
+ state: { columnFilters, pagination },
230
+ onColumnFiltersChange: setColumnFilters,
231
+ onPaginationChange: setPagination,
232
+ getCoreRowModel: getCoreRowModel(),
233
+ getFilteredRowModel: getFilteredRowModel(),
234
+ getPaginationRowModel: getPaginationRowModel(),
235
+ })
236
+
237
+ return (
238
+ <>
239
+ <FilterToolbar
240
+ filterDefs={filterDefs}
241
+ onChange={applied =>
242
+ setColumnFilters(applied.map(f => ({ id: f.id, value: f.value })))
243
+ }
244
+ />
245
+ <DataTableRoot>
246
+ <DataTable table={table}>
247
+ {row => (
248
+ <TableRow key={row.id}>
249
+ {row.getVisibleCells().map(cell => (
250
+ <TableCell key={cell.id}>
251
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
252
+ </TableCell>
253
+ ))}
254
+ </TableRow>
255
+ )}
256
+ </DataTable>
257
+ </DataTableRoot>
258
+ </>
259
+ )
260
+ }
261
+ ```
262
+
263
+ ### Bulk actions with row selection
264
+
265
+ `DataTableBulkActions` lives next to `DataTable` inside `DataTableRoot`.
266
+ It auto-shows when rows are selected and provides the F6 focus trap. Use a
267
+ display column with `meta.isFocusable: false` for the checkbox.
268
+
269
+ ```tsx
270
+ import {
271
+ CheckboxStandalone,
272
+ DataTable,
273
+ DataTableBulkActions,
274
+ DataTableRoot,
275
+ } from '@payfit/unity-components'
276
+
277
+ const checkboxColumn = columnHelper.display({
278
+ id: 'select',
279
+ header: ({ table }) => (
280
+ <CheckboxStandalone
281
+ isSelected={table.getIsAllPageRowsSelected()}
282
+ isIndeterminate={table.getIsSomePageRowsSelected()}
283
+ onChange={value => table.toggleAllPageRowsSelected(value)}
284
+ slot="selection"
285
+ >
286
+ Select all
287
+ </CheckboxStandalone>
288
+ ),
289
+ cell: ({ row }) => (
290
+ <CheckboxStandalone
291
+ isSelected={row.getIsSelected()}
292
+ onChange={value => row.toggleSelected(value)}
293
+ slot="selection"
294
+ >
295
+ Select row
296
+ </CheckboxStandalone>
297
+ ),
298
+ enableSorting: false,
299
+ meta: { isFocusable: false },
300
+ })
301
+
302
+ export function BulkTable() {
303
+ const [rowSelection, setRowSelection] = useState({})
304
+ const columns = useMemo(() => [checkboxColumn, ...employeeColumns], [])
305
+ const table = useReactTable({
306
+ data,
307
+ columns,
308
+ state: { rowSelection, pagination },
309
+ onRowSelectionChange: setRowSelection,
310
+ onPaginationChange: setPagination,
311
+ enableRowSelection: true,
312
+ getCoreRowModel: getCoreRowModel(),
313
+ getPaginationRowModel: getPaginationRowModel(),
314
+ })
315
+
316
+ return (
317
+ <DataTableRoot>
318
+ <DataTable table={table}>
319
+ {row => (
320
+ <TableRow key={row.id} isSelected={row.getIsSelected()}>
321
+ {row.getVisibleCells().map(cell => (
322
+ <TableCell key={cell.id}>
323
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
324
+ </TableCell>
325
+ ))}
326
+ </TableRow>
327
+ )}
328
+ </DataTable>
329
+ <DataTableBulkActions
330
+ table={table}
331
+ actions={[
332
+ { id: 'archive', label: 'Archive', onAction: rows => archive(rows) },
333
+ { id: 'delete', label: 'Delete', onAction: rows => remove(rows) },
334
+ ]}
335
+ />
336
+ </DataTableRoot>
337
+ )
338
+ }
339
+ ```
340
+
341
+ ## Common Mistakes
342
+
343
+ ### CRITICAL Define columns inline (not memoized)
344
+
345
+ Wrong:
346
+
347
+ ```tsx
348
+ function MyTable() {
349
+ const columns = [
350
+ { accessorKey: 'name', header: 'Name' },
351
+ { accessorKey: 'status', header: 'Status' },
352
+ ]
353
+ const table = useReactTable({ data, columns, ... })
354
+ return <DataTable table={table}>{row => ...}</DataTable>
355
+ }
356
+ ```
357
+
358
+ Correct:
359
+
360
+ ```tsx
361
+ function MyTable() {
362
+ const columns = useMemo(() => [
363
+ { accessorKey: 'name', header: 'Name' },
364
+ { accessorKey: 'status', header: 'Status' },
365
+ ], [])
366
+ const table = useReactTable({ data, columns, ... })
367
+ return <DataTable table={table}>{row => ...}</DataTable>
368
+ }
369
+ ```
370
+
371
+ A new columns array each render makes Tanstack Table re-run the whole pipeline; rows re-render even when data is unchanged.
372
+
373
+ Source: DataTable.stories.tsx:656-657; mocks/employee-columns.tsx:14-61
374
+
375
+ ### HIGH Return string from cell function instead of JSX
376
+
377
+ Wrong:
378
+
379
+ ```tsx
380
+ { accessorKey: 'status', cell: info => info.getValue() }
381
+ ```
382
+
383
+ Correct:
384
+
385
+ ```tsx
386
+ {
387
+ accessorKey: 'status',
388
+ cell: info => <Badge color={statusColor(info.getValue())}>{info.getValue()}</Badge>
389
+ }
390
+ ```
391
+
392
+ flexRender expects ReactNode. A raw string renders unstyled and may overflow. Cell layout is the maintainer-flagged hotspot for table questions.
393
+
394
+ Source: DataTable.stories.tsx:123-159 (cell function patterns); maintainer interview
395
+
396
+ ### HIGH Use the primitive Table when DataTable + Tanstack would do
397
+
398
+ Wrong:
399
+
400
+ ```tsx
401
+ <Table layout="fixed">
402
+ <TableHeader>…</TableHeader>
403
+ <TableBody>
404
+ {data.map(row => (
405
+ <TableRow>…</TableRow>
406
+ ))}
407
+ </TableBody>
408
+ </Table>
409
+ ```
410
+
411
+ Correct:
412
+
413
+ ```tsx
414
+ const table = useReactTable({ data, columns, getCoreRowModel(),
415
+ getPaginationRowModel(), state: { pagination }, onPaginationChange: setPagination })
416
+ <DataTableRoot>
417
+ <DataTable table={table}>{row => /* ... */}</DataTable>
418
+ </DataTableRoot>
419
+ ```
420
+
421
+ Primitive Table has no state; agent hand-rolls pagination/sorting/selection that DataTable provides out of the box.
422
+
423
+ Source: DataTable.tsx (composite) vs Table.tsx (primitives)
424
+
425
+ ### HIGH Manage filter state separately from FilterToolbar
426
+
427
+ Wrong:
428
+
429
+ ```tsx
430
+ const [filters, setFilters] = useState([])
431
+ <FilterToolbar filterDefs={…} onChange={setFilters} />
432
+ <DataTable table={table} />
433
+ ```
434
+
435
+ Correct:
436
+
437
+ ```tsx
438
+ const [columnFilters, setColumnFilters] = useState([])
439
+ const [globalFilter, setGlobalFilter] = useState('')
440
+ const table = useReactTable({ /* … */ state: { columnFilters, globalFilter },
441
+ onGlobalFilterChange: setGlobalFilter, onColumnFiltersChange: setColumnFilters,
442
+ getFilteredRowModel() })
443
+ <FilterToolbar filterDefs={…} onChange={mapToTableState(setColumnFilters, setGlobalFilter)} />
444
+ ```
445
+
446
+ FilterToolbar.onChange emits AppliedFilter[]. Agent stores them locally without mapping to table.setGlobalFilter / setColumnFilters, so rows do not actually filter.
447
+
448
+ Source: FilterToolbar.tsx; DataTable.stories.tsx:1141-1209
449
+
450
+ ### CRITICAL Server-side pagination without slicing data
451
+
452
+ Wrong:
453
+
454
+ ```tsx
455
+ useReactTable({
456
+ data: allEmployees,
457
+ manualPagination: true,
458
+ pageCount: Math.ceil(allEmployees.length / 10),
459
+ })
460
+ ```
461
+
462
+ Correct:
463
+
464
+ ```tsx
465
+ const currentData = useMemo(() => {
466
+ const start = pagination.pageIndex * pagination.pageSize
467
+ return allEmployees.slice(start, start + pagination.pageSize)
468
+ }, [pagination])
469
+ useReactTable({
470
+ data: currentData,
471
+ manualPagination: true,
472
+ pageCount: Math.ceil(allEmployees.length / pagination.pageSize),
473
+ state: { pagination },
474
+ onPaginationChange: setPagination,
475
+ })
476
+ ```
477
+
478
+ manualPagination: true tells Tanstack not to slice. Agents pass the full dataset and expect TanStack to paginate anyway.
479
+
480
+ Source: DataTable.stories.tsx:146-178 (manual pagination)
481
+
482
+ ### MEDIUM Render large datasets without enableVirtualization
483
+
484
+ Wrong:
485
+
486
+ ```tsx
487
+ <DataTable table={table}>{row => …}</DataTable>
488
+ ```
489
+
490
+ Correct:
491
+
492
+ ```tsx
493
+ <DataTable
494
+ table={table}
495
+ enableVirtualization
496
+ estimatedRowHeight={40}
497
+ overscan={10}
498
+ >
499
+ {row => …}
500
+ </DataTable>
501
+ ```
502
+
503
+ Without virtualization, 500+ rows mount in the DOM. Keyboard nav context clones cells; reconciliation is O(n) per render.
504
+
505
+ Source: DataTable.tsx:278-280; Table.tsx import of @tanstack/react-virtual
506
+
507
+ ## See also
508
+
509
+ - `unity-layout-and-styling` — `uy:` cell content classes, `headerClassName`
510
+ width helpers, `layout="fixed"` rules.
511
+ - `unity-find-component` — picking between primitive Table and composite
512
+ DataTable when you are not sure which fits.