@duro-app/ui 0.32.0 → 0.34.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": "@duro-app/ui",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
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.32.0"
56
+ "@duro-app/tokens": "^0.34.0"
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
+ }
@@ -0,0 +1,52 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {styles} from './styles.css'
3
+ import {Menu} from '../Menu/Menu'
4
+
5
+ export interface SortValue {
6
+ readonly id: string
7
+ readonly desc: boolean
8
+ }
9
+
10
+ export interface SortChipProps {
11
+ readonly options: ReadonlyArray<{readonly id: string; readonly label: string}>
12
+ readonly value: SortValue | null
13
+ readonly onChange: (next: SortValue | null) => void
14
+ /** Visible label preceding the sort field, e.g. "Sort by". */
15
+ readonly label?: string
16
+ }
17
+
18
+ /**
19
+ * Sort selector for stack-mode Tables. Hidden in default/compact via
20
+ * `@container` query — see styles.sortChip. Consumers render this inside
21
+ * a `Table.Container` so the same query target reaches it.
22
+ *
23
+ * The chip is a simple controlled wrapper around `Menu`. It does NOT
24
+ * couple to @tanstack/react-table — the consumer wires `value` and
25
+ * `onChange` from whatever sort state they manage.
26
+ */
27
+ export function SortChip({options, value, onChange, label = 'Sort by'}: SortChipProps) {
28
+ const current = value ? options.find((o) => o.id === value.id) : null
29
+ const triggerLabel = current ? `${label}: ${current.label} ${value!.desc ? '↓' : '↑'}` : label
30
+
31
+ return (
32
+ <html.div style={styles.sortChip}>
33
+ <Menu.Root>
34
+ <Menu.Trigger>{triggerLabel}</Menu.Trigger>
35
+ <Menu.Popup>
36
+ {options.map((opt) => {
37
+ const isCurrent = value?.id === opt.id
38
+ const nextDesc = isCurrent ? !value!.desc : false
39
+ return (
40
+ <Menu.Item key={opt.id} onClick={() => onChange({id: opt.id, desc: nextDesc})}>
41
+ {opt.label}
42
+ {isCurrent ? ` ${value!.desc ? '↓' : '↑'}` : ''}
43
+ </Menu.Item>
44
+ )
45
+ })}
46
+ {value !== null && <Menu.Item onClick={() => onChange(null)}>Clear sort</Menu.Item>}
47
+ </Menu.Popup>
48
+ </Menu.Root>
49
+ </html.div>
50
+ )
51
+ }
52
+ SortChip.displayName = 'SortChip'
@@ -878,3 +878,257 @@ export const FullFeatured: Story = {
878
878
  await expect(canvas.getByText('Previous')).toBeInTheDocument()
879
879
  },
880
880
  }
881
+
882
+ // Demonstrates the three responsive modes. Resize the canvas (or wrap in
883
+ // a narrower container) to see Compact (~720px) and Stack (~440px).
884
+ const responsiveStyles = css.create({
885
+ resizable: {
886
+ resize: 'horizontal',
887
+ overflow: 'auto',
888
+ minWidth: '320px',
889
+ maxWidth: '100%',
890
+ width: '900px',
891
+ padding: spacing.sm,
892
+ borderWidth: 1,
893
+ borderStyle: 'dashed',
894
+ borderColor: colors.border,
895
+ },
896
+ })
897
+
898
+ export const Responsive: Story = {
899
+ name: 'Responsive (Container Queries)',
900
+ render: () => {
901
+ const grants = [
902
+ {principal: 'daddy', role: 'Admin', resource: 'all', expires: 'Never', granted_by: 'system'},
903
+ {
904
+ principal: 'alice',
905
+ role: 'Editor',
906
+ resource: 'documents/foo.md',
907
+ expires: '2026-12-31',
908
+ granted_by: 'daddy',
909
+ },
910
+ {principal: 'bob', role: 'Viewer', resource: 'all', expires: 'Never', granted_by: 'daddy'},
911
+ ]
912
+ const [sort, setSort] = useState<{id: string; desc: boolean} | null>(null)
913
+ const sortable = [
914
+ {id: 'principal', label: 'Principal'},
915
+ {id: 'role', label: 'Role'},
916
+ {id: 'expires', label: 'Expires'},
917
+ ]
918
+ return (
919
+ <html.div style={responsiveStyles.resizable}>
920
+ <Table.Container>
921
+ <Table.SortChip options={sortable} value={sort} onChange={setSort} />
922
+ <Table.Root>
923
+ <Table.Header>
924
+ <Table.Row>
925
+ <Table.HeaderCell label="Principal">Principal</Table.HeaderCell>
926
+ <Table.HeaderCell label="Role">Role</Table.HeaderCell>
927
+ <Table.HeaderCell label="Resource">Resource</Table.HeaderCell>
928
+ <Table.HeaderCell label="Expires">Expires</Table.HeaderCell>
929
+ <Table.HeaderCell label="Granted by">Granted by</Table.HeaderCell>
930
+ <Table.HeaderCell label="Actions" isActions>
931
+ Actions
932
+ </Table.HeaderCell>
933
+ </Table.Row>
934
+ </Table.Header>
935
+ <Table.Body>
936
+ {grants.map((g) => (
937
+ <Table.Row key={g.principal}>
938
+ <Table.Cell>{g.principal}</Table.Cell>
939
+ <Table.Cell>{g.role}</Table.Cell>
940
+ <Table.Cell>{g.resource}</Table.Cell>
941
+ <Table.Cell>{g.expires}</Table.Cell>
942
+ <Table.Cell>{g.granted_by}</Table.Cell>
943
+ <Table.Cell isActions>
944
+ <Button size="small" variant="secondary">
945
+ Revoke
946
+ </Button>
947
+ </Table.Cell>
948
+ </Table.Row>
949
+ ))}
950
+ </Table.Body>
951
+ </Table.Root>
952
+ </Table.Container>
953
+ </html.div>
954
+ )
955
+ },
956
+ play: async ({canvas}) => {
957
+ // At default canvas width (900px) we're in compact, headers nowrap,
958
+ // sort chip hidden. Just sanity-check the table renders the expected
959
+ // shape — visual regression is covered by Storybook + Chromatic.
960
+ const headers = canvas.getAllByRole('columnheader')
961
+ await expect(headers.length).toBe(6)
962
+ const cells = canvas.getAllByRole('cell')
963
+ // 3 rows × 6 columns = 18 cells
964
+ await expect(cells.length).toBe(18)
965
+ // Each non-actions body cell carries its label as a hidden span — we
966
+ // can't visually assert display:none in jsdom, but the text node
967
+ // exists in the DOM tree.
968
+ await expect(canvas.getAllByText('Principal').length).toBeGreaterThanOrEqual(2)
969
+ },
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
+ }