@duro-app/ui 0.33.0 → 0.34.1
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/components/Table/FromTanstack.d.ts +45 -0
- package/dist/components/Table/FromTanstack.d.ts.map +1 -0
- package/dist/components/Table/Table.d.ts +37 -13
- package/dist/components/Table/Table.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts +2 -0
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Table/styles.css.d.ts +16 -1
- package/dist/components/Table/styles.css.d.ts.map +1 -1
- package/dist/components/Table/tanstack-augmentation.d.ts +13 -0
- package/dist/components/Table/tanstack-augmentation.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2293 -2160
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Table/FromTanstack.tsx +159 -0
- package/src/components/Table/Table.stories.tsx +164 -0
- package/src/components/Table/Table.tsx +204 -42
- package/src/components/Table/styles.css.ts +45 -7
- package/src/components/Table/tanstack-augmentation.ts +28 -0
- package/src/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@duro-app/tokens": "^0.
|
|
56
|
+
"@duro-app/tokens": "^0.34.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {type ReactNode} from 'react'
|
|
2
|
+
import {type Row, type Table as TanstackTable, flexRender} from '@tanstack/react-table'
|
|
3
|
+
import {
|
|
4
|
+
Body,
|
|
5
|
+
Cell,
|
|
6
|
+
Header,
|
|
7
|
+
HeaderCell,
|
|
8
|
+
Root,
|
|
9
|
+
Row as TableRow,
|
|
10
|
+
type TableSize,
|
|
11
|
+
type TableVariant,
|
|
12
|
+
} from './Table'
|
|
13
|
+
import {Pagination} from './Pagination'
|
|
14
|
+
import {SortChip, type SortValue} from './SortChip'
|
|
15
|
+
|
|
16
|
+
// ColumnMeta is augmented in `./tanstack-augmentation` so the types
|
|
17
|
+
// register independently of whether the consumer imports FromTanstack.
|
|
18
|
+
|
|
19
|
+
// Base props shared by both clickable and non-clickable variants.
|
|
20
|
+
interface FromTanstackBaseProps<TData> {
|
|
21
|
+
readonly table: TanstackTable<TData>
|
|
22
|
+
readonly variant?: TableVariant
|
|
23
|
+
readonly size?: TableSize
|
|
24
|
+
readonly responsive?: boolean
|
|
25
|
+
/** Render a SortChip above the grid (stack-mode only). Options derive from
|
|
26
|
+
* columns where `column.getCanSort()` returns true. Wires value/onChange
|
|
27
|
+
* through the TanStack sorting state automatically. */
|
|
28
|
+
readonly sortChip?: boolean
|
|
29
|
+
/** Render Pagination below the grid. */
|
|
30
|
+
readonly pagination?: boolean
|
|
31
|
+
/** Key extractor; defaults to TanStack's row.id. */
|
|
32
|
+
readonly rowKey?: (row: Row<TData>) => string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Discriminated union: rowAriaLabel is required when onRowClick is set so
|
|
36
|
+
// keyboard / screen-reader users always get an accessible name for the
|
|
37
|
+
// interactive row.
|
|
38
|
+
export type FromTanstackProps<TData> = FromTanstackBaseProps<TData> &
|
|
39
|
+
(
|
|
40
|
+
| {
|
|
41
|
+
readonly onRowClick: (row: Row<TData>) => void
|
|
42
|
+
/** Accessible name for each clickable row. Required because the row's
|
|
43
|
+
* visible cell content alone is rarely a useful accessible name. */
|
|
44
|
+
readonly rowAriaLabel: (row: Row<TData>) => string
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
readonly onRowClick?: undefined
|
|
48
|
+
readonly rowAriaLabel?: undefined
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Renders a styled Table directly from a TanStack table instance.
|
|
54
|
+
*
|
|
55
|
+
* Labels for stack mode are derived in this order:
|
|
56
|
+
* 1. `column.columnDef.meta?.stackLabel` (explicit override)
|
|
57
|
+
* 2. `column.columnDef.header` when it's a string
|
|
58
|
+
* 3. Empty (use only for the actions column or when meta.stackLabel is set)
|
|
59
|
+
*
|
|
60
|
+
* Mark the actions column with `meta: { isActions: true }` to apply the
|
|
61
|
+
* stack-mode footer styling to its body cells.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* <Table.FromTanstack table={tanstackTable} sortChip pagination onRowClick={(r) => nav(r)} />
|
|
65
|
+
*/
|
|
66
|
+
export function FromTanstack<TData>({
|
|
67
|
+
table,
|
|
68
|
+
variant = 'default',
|
|
69
|
+
size = 'md',
|
|
70
|
+
responsive = true,
|
|
71
|
+
sortChip,
|
|
72
|
+
pagination,
|
|
73
|
+
rowKey,
|
|
74
|
+
onRowClick,
|
|
75
|
+
rowAriaLabel,
|
|
76
|
+
}: FromTanstackProps<TData>) {
|
|
77
|
+
const sortChipNode: ReactNode = sortChip ? buildSortChip(table) : undefined
|
|
78
|
+
const paginationNode: ReactNode = pagination ? <Pagination table={table} /> : undefined
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Root
|
|
82
|
+
variant={variant}
|
|
83
|
+
size={size}
|
|
84
|
+
responsive={responsive}
|
|
85
|
+
sortChip={sortChipNode}
|
|
86
|
+
pagination={paginationNode}
|
|
87
|
+
>
|
|
88
|
+
<Header>
|
|
89
|
+
{table.getHeaderGroups().map((group) => (
|
|
90
|
+
<TableRow key={group.id}>
|
|
91
|
+
{group.headers.map((header) => {
|
|
92
|
+
const def = header.column.columnDef
|
|
93
|
+
const meta = def.meta
|
|
94
|
+
const labelFromHeader = typeof def.header === 'string' ? def.header : undefined
|
|
95
|
+
const label = meta?.stackLabel ?? labelFromHeader ?? ''
|
|
96
|
+
return (
|
|
97
|
+
<HeaderCell
|
|
98
|
+
key={header.id}
|
|
99
|
+
label={label}
|
|
100
|
+
aria-label={meta?.isActions ? label || 'Actions' : undefined}
|
|
101
|
+
>
|
|
102
|
+
{header.isPlaceholder ? null : flexRender(def.header, header.getContext())}
|
|
103
|
+
</HeaderCell>
|
|
104
|
+
)
|
|
105
|
+
})}
|
|
106
|
+
</TableRow>
|
|
107
|
+
))}
|
|
108
|
+
</Header>
|
|
109
|
+
<Body>
|
|
110
|
+
{table.getRowModel().rows.map((row) => {
|
|
111
|
+
const key = rowKey?.(row) ?? row.id
|
|
112
|
+
// Row handles its own click/keyboard/focus affordance — no outer
|
|
113
|
+
// wrapper, so the role="rowgroup" → role="row" ARIA hierarchy
|
|
114
|
+
// stays intact, and clicks inside interactive cell content (e.g.
|
|
115
|
+
// an action button) are correctly attributed to the inner widget.
|
|
116
|
+
return (
|
|
117
|
+
<TableRow
|
|
118
|
+
key={key}
|
|
119
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
120
|
+
aria-label={rowAriaLabel?.(row)}
|
|
121
|
+
>
|
|
122
|
+
{row.getVisibleCells().map((cell) => (
|
|
123
|
+
<Cell key={cell.id} isActions={cell.column.columnDef.meta?.isActions === true}>
|
|
124
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
125
|
+
</Cell>
|
|
126
|
+
))}
|
|
127
|
+
</TableRow>
|
|
128
|
+
)
|
|
129
|
+
})}
|
|
130
|
+
</Body>
|
|
131
|
+
</Root>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
FromTanstack.displayName = 'FromTanstack'
|
|
135
|
+
|
|
136
|
+
// --- helpers ---
|
|
137
|
+
|
|
138
|
+
function buildSortChip<TData>(table: TanstackTable<TData>): ReactNode {
|
|
139
|
+
const options = table
|
|
140
|
+
.getAllColumns()
|
|
141
|
+
.filter((c) => c.getCanSort())
|
|
142
|
+
.map((c) => {
|
|
143
|
+
const fromHeader = typeof c.columnDef.header === 'string' ? c.columnDef.header : c.id
|
|
144
|
+
return {id: c.id, label: c.columnDef.meta?.stackLabel ?? fromHeader}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
if (options.length === 0) return null
|
|
148
|
+
|
|
149
|
+
const current = table.getState().sorting[0]
|
|
150
|
+
const value: SortValue | null = current ? {id: current.id, desc: current.desc} : null
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<SortChip
|
|
154
|
+
options={options}
|
|
155
|
+
value={value}
|
|
156
|
+
onChange={(next) => table.setSorting(next ? [{id: next.id, desc: next.desc}] : [])}
|
|
157
|
+
/>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -968,3 +968,167 @@ export const Responsive: Story = {
|
|
|
968
968
|
await expect(canvas.getAllByText('Principal').length).toBeGreaterThanOrEqual(2)
|
|
969
969
|
},
|
|
970
970
|
}
|
|
971
|
+
|
|
972
|
+
// --- Auto from TanStack: the one-liner replacement for ~25 lines of header/body ceremony ---
|
|
973
|
+
|
|
974
|
+
const autoColHelper = createColumnHelper<User>()
|
|
975
|
+
const autoCols = [
|
|
976
|
+
autoColHelper.accessor('name', {header: 'Name', enableSorting: true}),
|
|
977
|
+
autoColHelper.accessor('role', {header: 'Role', enableSorting: true}),
|
|
978
|
+
autoColHelper.accessor('email', {header: 'Email'}),
|
|
979
|
+
autoColHelper.accessor('status', {
|
|
980
|
+
header: 'Status',
|
|
981
|
+
cell: ({getValue}) => <Badge variant="info">{getValue()}</Badge>,
|
|
982
|
+
}),
|
|
983
|
+
autoColHelper.display({
|
|
984
|
+
id: 'actions',
|
|
985
|
+
header: 'Actions',
|
|
986
|
+
meta: {isActions: true, stackLabel: 'Actions'},
|
|
987
|
+
cell: ({row}) => (
|
|
988
|
+
<Button
|
|
989
|
+
size="small"
|
|
990
|
+
variant="secondary"
|
|
991
|
+
// Marker so the play assertion can verify the row's onClick
|
|
992
|
+
// doesn't fire when the inner button is the click target.
|
|
993
|
+
aria-label={`Edit ${row.original.name}`}
|
|
994
|
+
>
|
|
995
|
+
Edit
|
|
996
|
+
</Button>
|
|
997
|
+
),
|
|
998
|
+
}),
|
|
999
|
+
]
|
|
1000
|
+
|
|
1001
|
+
// Module-level capture array so the play assertion can read what
|
|
1002
|
+
// onRowClick recorded without juggling render-scoped refs.
|
|
1003
|
+
const _rowClickLog: string[] = []
|
|
1004
|
+
|
|
1005
|
+
function FromTanstackDemo() {
|
|
1006
|
+
const table = useDataTable<User>(generateUsers(12), autoCols, {
|
|
1007
|
+
pagination: {pageSize: 5},
|
|
1008
|
+
enableSorting: true,
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
return (
|
|
1012
|
+
<Table.FromTanstack
|
|
1013
|
+
table={table}
|
|
1014
|
+
variant="striped"
|
|
1015
|
+
sortChip
|
|
1016
|
+
pagination
|
|
1017
|
+
onRowClick={(row) => _rowClickLog.push(row.original.email)}
|
|
1018
|
+
rowAriaLabel={(row) => `Open ${row.original.name}`}
|
|
1019
|
+
/>
|
|
1020
|
+
)
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export const AutoFromTanstack: Story = {
|
|
1024
|
+
render: () => <FromTanstackDemo />,
|
|
1025
|
+
parameters: {
|
|
1026
|
+
docs: {
|
|
1027
|
+
description: {
|
|
1028
|
+
story:
|
|
1029
|
+
'Renders a styled Table directly from a TanStack table instance. ' +
|
|
1030
|
+
'Mark the actions column with `meta: { isActions: true }`; pass ' +
|
|
1031
|
+
'`sortChip` and `pagination` as booleans. `onRowClick` makes the ' +
|
|
1032
|
+
'row focusable with Enter/Space activation; clicks on interactive ' +
|
|
1033
|
+
'cell content (buttons, links) are attributed to the inner widget, ' +
|
|
1034
|
+
'not the row.',
|
|
1035
|
+
},
|
|
1036
|
+
},
|
|
1037
|
+
},
|
|
1038
|
+
play: async ({canvas, userEvent}) => {
|
|
1039
|
+
// 1. Shape: 5 columns including actions, 5 rows of data.
|
|
1040
|
+
await expect(canvas.getByRole('table')).toBeInTheDocument()
|
|
1041
|
+
await expect(canvas.getAllByRole('columnheader').length).toBe(5)
|
|
1042
|
+
await expect(canvas.getAllByRole('row').length).toBe(6) // 1 header + 5 body
|
|
1043
|
+
await expect(canvas.getAllByRole('cell').length).toBe(25)
|
|
1044
|
+
|
|
1045
|
+
// 2. Clickable rows: each body row is focusable + has the aria-label
|
|
1046
|
+
// from `rowAriaLabel`. The header row stays non-interactive.
|
|
1047
|
+
const bodyRows = canvas.getAllByRole('row').slice(1)
|
|
1048
|
+
await expect(bodyRows[0]).toHaveAttribute('tabindex', '0')
|
|
1049
|
+
await expect(bodyRows[0].getAttribute('aria-label')).toMatch(/^Open /)
|
|
1050
|
+
|
|
1051
|
+
// 3. Click bubbling: clicking a row fires onRowClick; clicking the
|
|
1052
|
+
// action button inside a row does NOT fire onRowClick.
|
|
1053
|
+
_rowClickLog.length = 0
|
|
1054
|
+
await userEvent.click(bodyRows[0])
|
|
1055
|
+
await expect(_rowClickLog.length).toBe(1)
|
|
1056
|
+
|
|
1057
|
+
_rowClickLog.length = 0
|
|
1058
|
+
const editButtons = canvas.getAllByRole('button', {name: /^Edit /})
|
|
1059
|
+
await userEvent.click(editButtons[0])
|
|
1060
|
+
await expect(_rowClickLog.length).toBe(0)
|
|
1061
|
+
|
|
1062
|
+
// 4. Keyboard activation: Enter fires onRowClick.
|
|
1063
|
+
_rowClickLog.length = 0
|
|
1064
|
+
bodyRows[0].focus()
|
|
1065
|
+
await userEvent.keyboard('{Enter}')
|
|
1066
|
+
await expect(_rowClickLog.length).toBe(1)
|
|
1067
|
+
},
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// --- Slot props on Root: manual JSX, no Container wrap needed ---
|
|
1071
|
+
|
|
1072
|
+
export const SlotPropsOnRoot: Story = {
|
|
1073
|
+
render: () => {
|
|
1074
|
+
const data = generateUsers(8)
|
|
1075
|
+
const [sort, setSort] = useState<{id: string; desc: boolean} | null>(null)
|
|
1076
|
+
|
|
1077
|
+
return (
|
|
1078
|
+
<html.div style={storyStyles.stack}>
|
|
1079
|
+
<html.div>
|
|
1080
|
+
<html.span style={storyStyles.label}>
|
|
1081
|
+
<html.code>Table.Root</html.code> with <html.code>sortChip</html.code> +{' '}
|
|
1082
|
+
<html.code>pagination</html.code> slots — no <html.code>Container</html.code> wrap
|
|
1083
|
+
</html.span>
|
|
1084
|
+
</html.div>
|
|
1085
|
+
<Table.Root
|
|
1086
|
+
variant="striped"
|
|
1087
|
+
sortChip={
|
|
1088
|
+
<Table.SortChip
|
|
1089
|
+
options={[
|
|
1090
|
+
{id: 'name', label: 'Name'},
|
|
1091
|
+
{id: 'role', label: 'Role'},
|
|
1092
|
+
]}
|
|
1093
|
+
value={sort}
|
|
1094
|
+
onChange={setSort}
|
|
1095
|
+
/>
|
|
1096
|
+
}
|
|
1097
|
+
>
|
|
1098
|
+
<Table.Header>
|
|
1099
|
+
<Table.Row>
|
|
1100
|
+
<Table.HeaderCell>Name</Table.HeaderCell>
|
|
1101
|
+
<Table.HeaderCell>Role</Table.HeaderCell>
|
|
1102
|
+
<Table.HeaderCell>Email</Table.HeaderCell>
|
|
1103
|
+
</Table.Row>
|
|
1104
|
+
</Table.Header>
|
|
1105
|
+
<Table.Body>
|
|
1106
|
+
{data.slice(0, 5).map((u) => (
|
|
1107
|
+
<Table.Row key={u.email}>
|
|
1108
|
+
<Table.Cell>{u.name}</Table.Cell>
|
|
1109
|
+
<Table.Cell>{u.role}</Table.Cell>
|
|
1110
|
+
<Table.Cell>{u.email}</Table.Cell>
|
|
1111
|
+
</Table.Row>
|
|
1112
|
+
))}
|
|
1113
|
+
</Table.Body>
|
|
1114
|
+
</Table.Root>
|
|
1115
|
+
</html.div>
|
|
1116
|
+
)
|
|
1117
|
+
},
|
|
1118
|
+
parameters: {
|
|
1119
|
+
docs: {
|
|
1120
|
+
description: {
|
|
1121
|
+
story:
|
|
1122
|
+
'`Table.Root` accepts `sortChip` and `pagination` as slot props. ' +
|
|
1123
|
+
'No outer wrapper is needed — Root sets up the container query ' +
|
|
1124
|
+
'itself. Narrow the canvas to see the responsive stack mode.',
|
|
1125
|
+
},
|
|
1126
|
+
},
|
|
1127
|
+
},
|
|
1128
|
+
play: async ({canvas}) => {
|
|
1129
|
+
await expect(canvas.getByRole('table')).toBeInTheDocument()
|
|
1130
|
+
// Note no Table.HeaderCell label — children are plain strings, so the
|
|
1131
|
+
// label fallback uses extractText. Verify the rendered text appears.
|
|
1132
|
+
await expect(canvas.getByText('Name')).toBeInTheDocument()
|
|
1133
|
+
},
|
|
1134
|
+
}
|