@duro-app/ui 0.45.0 → 0.46.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.
Files changed (31) hide show
  1. package/dist/components/Table/FromTanstack.d.ts +1 -4
  2. package/dist/components/Table/FromTanstack.d.ts.map +1 -1
  3. package/dist/components/Table/Table.d.ts +2 -10
  4. package/dist/components/Table/Table.d.ts.map +1 -1
  5. package/dist/components/Table/Table.stories.d.ts +1 -0
  6. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  7. package/dist/components/Tag/Tag.d.ts +9 -1
  8. package/dist/components/Tag/Tag.d.ts.map +1 -1
  9. package/dist/components/Tag/Tag.stories.d.ts +1 -0
  10. package/dist/components/Tag/Tag.stories.d.ts.map +1 -1
  11. package/dist/components/Tag/styles.css.d.ts +7 -0
  12. package/dist/components/Tag/styles.css.d.ts.map +1 -1
  13. package/dist/components/VirtualTable/VirtualTable.d.ts +12 -1
  14. package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -1
  15. package/dist/components/VirtualTable/VirtualTable.stories.d.ts +1 -0
  16. package/dist/components/VirtualTable/VirtualTable.stories.d.ts.map +1 -1
  17. package/dist/components/VirtualTable/styles.css.d.ts +40 -0
  18. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  19. package/dist/index.css +1 -1
  20. package/dist/index.js +2189 -2090
  21. package/dist/index.js.map +1 -1
  22. package/package.json +2 -2
  23. package/src/components/Table/FromTanstack.tsx +0 -5
  24. package/src/components/Table/Table.stories.tsx +61 -4
  25. package/src/components/Table/Table.tsx +67 -22
  26. package/src/components/Tag/Tag.stories.tsx +22 -0
  27. package/src/components/Tag/Tag.tsx +10 -0
  28. package/src/components/Tag/styles.css.ts +12 -0
  29. package/src/components/VirtualTable/VirtualTable.stories.tsx +29 -0
  30. package/src/components/VirtualTable/VirtualTable.tsx +152 -58
  31. package/src/components/VirtualTable/styles.css.ts +43 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@tanstack/react-virtual": "^3.14.6",
58
- "@duro-app/tokens": "^0.45.0"
58
+ "@duro-app/tokens": "^0.46.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@babel/preset-typescript": "^7.28.0",
@@ -22,9 +22,6 @@ interface FromTanstackBaseProps<TData> {
22
22
  readonly variant?: TableVariant
23
23
  readonly size?: TableSize
24
24
  readonly responsive?: boolean
25
- /** Min comfortable column width (px) before the table cards up. Forwarded
26
- * to Table.Root — see its `minColumnWidth`. Default 128. */
27
- readonly minColumnWidth?: number
28
25
  /** Render a SortChip above the grid (stack-mode only). Options derive from
29
26
  * columns where `column.getCanSort()` returns true. Wires value/onChange
30
27
  * through the TanStack sorting state automatically. */
@@ -71,7 +68,6 @@ export function FromTanstack<TData>({
71
68
  variant = 'default',
72
69
  size = 'md',
73
70
  responsive = true,
74
- minColumnWidth,
75
71
  sortChip,
76
72
  pagination,
77
73
  rowKey,
@@ -86,7 +82,6 @@ export function FromTanstack<TData>({
86
82
  variant={variant}
87
83
  size={size}
88
84
  responsive={responsive}
89
- minColumnWidth={minColumnWidth}
90
85
  sortChip={sortChipNode}
91
86
  pagination={paginationNode}
92
87
  >
@@ -1142,10 +1142,14 @@ export const SlotPropsOnRoot: Story = {
1142
1142
  // grid track into the next column. Cells now shrink to their track and wrap.
1143
1143
  const overlapWrapStyles = css.create({
1144
1144
  wide: {width: 760},
1145
- // 6 columns in 560px → each column would get < 128px (the min comfortable
1146
- // width), well above the 440px mobile breakpoint, so only the JS
1147
- // ResizeObserver — not the @container query — can catch the cramping.
1145
+ // 6 columns in 560px, above the 440px mobile breakpoint, so the @container
1146
+ // query never fires — only the JS overflow check catches that the action
1147
+ // buttons no longer fit their squeezed track.
1148
1148
  cramped: {width: 560},
1149
+ // 8 short-text columns with room to breathe: nothing overflows, so a
1150
+ // wide "grants-style" table must stay tabular even though it has many
1151
+ // columns (regression guard against the old column-count heuristic).
1152
+ roomy: {width: 1120},
1149
1153
  })
1150
1154
 
1151
1155
  const LONG_EMAIL = 'a-really-long-service-account.address@subdomain.example-company.com'
@@ -1212,7 +1216,9 @@ export const AutoStacksWhenCramped: Story = {
1212
1216
  <Table.HeaderCell>Email</Table.HeaderCell>
1213
1217
  <Table.HeaderCell>Status</Table.HeaderCell>
1214
1218
  <Table.HeaderCell>Owner</Table.HeaderCell>
1215
- <Table.HeaderCell width="max-content">Actions</Table.HeaderCell>
1219
+ {/* Normal 1fr track (no max-content) so the buttons get squeezed
1220
+ and overflow — the exact condition the auto-stack detects. */}
1221
+ <Table.HeaderCell>Actions</Table.HeaderCell>
1216
1222
  </Table.Row>
1217
1223
  </Table.Header>
1218
1224
  <Table.Body>
@@ -1258,3 +1264,54 @@ export const AutoStacksWhenCramped: Story = {
1258
1264
  await expect(getComputedStyle(ownerLabel).display).toBe('block')
1259
1265
  },
1260
1266
  }
1267
+
1268
+ // Regression guard for the grants over-stacking bug: a table with MANY
1269
+ // columns (8) that all fit comfortably must stay tabular. The old heuristic
1270
+ // stacked purely on column count (8 × 128px = 1024px threshold), carding up a
1271
+ // table that visually fits on a laptop. Overflow-based detection keeps it
1272
+ // tabular because nothing actually overflows.
1273
+ export const ManyColumnsStayTabularWhenFitting: Story = {
1274
+ name: 'Many columns stay tabular when they fit',
1275
+ render: () => (
1276
+ <html.div style={overlapWrapStyles.roomy}>
1277
+ <Table.Root>
1278
+ <Table.Header>
1279
+ <Table.Row>
1280
+ <Table.HeaderCell>Principal</Table.HeaderCell>
1281
+ <Table.HeaderCell>Application</Table.HeaderCell>
1282
+ <Table.HeaderCell>Role</Table.HeaderCell>
1283
+ <Table.HeaderCell>Entitlement</Table.HeaderCell>
1284
+ <Table.HeaderCell>Resource</Table.HeaderCell>
1285
+ <Table.HeaderCell>Granted by</Table.HeaderCell>
1286
+ <Table.HeaderCell>Expires</Table.HeaderCell>
1287
+ <Table.HeaderCell>Status</Table.HeaderCell>
1288
+ </Table.Row>
1289
+ </Table.Header>
1290
+ <Table.Body>
1291
+ <Table.Row>
1292
+ <Table.Cell>alice</Table.Cell>
1293
+ <Table.Cell>Immich</Table.Cell>
1294
+ <Table.Cell>Editor</Table.Cell>
1295
+ <Table.Cell>library</Table.Cell>
1296
+ <Table.Cell>all</Table.Cell>
1297
+ <Table.Cell>daddy</Table.Cell>
1298
+ <Table.Cell>Never</Table.Cell>
1299
+ <Table.Cell>
1300
+ <Badge variant="success">Active</Badge>
1301
+ </Table.Cell>
1302
+ </Table.Row>
1303
+ </Table.Body>
1304
+ </Table.Root>
1305
+ </html.div>
1306
+ ),
1307
+ play: async ({canvas}) => {
1308
+ const grid = canvas.getByRole('table')
1309
+ // Give the ResizeObserver a chance to (not) fire, then assert it stayed
1310
+ // tabular: 8 header cells still in the a11y tree, grid keeps 8 tracks.
1311
+ await waitFor(async () => {
1312
+ await expect(canvas.queryAllByRole('columnheader')).toHaveLength(8)
1313
+ })
1314
+ const tracks = getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/)
1315
+ await expect(tracks.length).toBe(8)
1316
+ },
1317
+ }
@@ -49,6 +49,31 @@ interface TableContextValue {
49
49
  // the @container CSS covers true mobile without JS.
50
50
  const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
51
51
 
52
+ // Does any column's content no longer fit its box? We sample the header row
53
+ // plus the first body row (bounded cost). Free text wraps rather than
54
+ // overflowing (min-width:0 + overflow-wrap), so this only trips on content
55
+ // that genuinely can't shrink — action buttons, badges, or nowrap header
56
+ // labels — i.e. the real "columns won't fit" signal, independent of how many
57
+ // columns there are. Structural overflow is consistent down a column, so the
58
+ // first body row is a faithful sample.
59
+ function gridOverflows(grid: HTMLDivElement | null): boolean {
60
+ if (!grid) return false
61
+ const TOL = 2 // sub-pixel layout rounding slack
62
+ const over = (el: Element) => el.scrollWidth - el.clientWidth > TOL
63
+ for (const h of grid.querySelectorAll('[role="columnheader"]')) {
64
+ if (over(h)) return true
65
+ }
66
+ const rowgroups = grid.querySelectorAll('[role="rowgroup"]')
67
+ const body = rowgroups[rowgroups.length - 1] // header is first, body last
68
+ const firstRow = body?.querySelector('[role="row"]')
69
+ if (firstRow) {
70
+ for (const c of firstRow.querySelectorAll('[role="cell"]')) {
71
+ if (over(c)) return true
72
+ }
73
+ }
74
+ return false
75
+ }
76
+
52
77
  const TableContext = createContext<TableContextValue | null>(null)
53
78
 
54
79
  function useTable() {
@@ -188,16 +213,8 @@ interface RootProps {
188
213
  children: ReactNode
189
214
  variant?: TableVariant
190
215
  size?: TableSize
191
- /** Opt out of responsive container-query behavior. Default true. */
216
+ /** Opt out of responsive behavior (auto-stacking + container query). Default true. */
192
217
  responsive?: boolean
193
- /**
194
- * Minimum comfortable width (px) for a single column. When responsive,
195
- * Root measures its container and switches to the stacked card layout as
196
- * soon as `containerWidth < columnCount × minColumnWidth` — i.e. before
197
- * cells get crushed, not only at the fixed mobile breakpoint. Raise it to
198
- * card up sooner, lower it to keep the grid at tighter widths. Default 128.
199
- */
200
- minColumnWidth?: number
201
218
  /**
202
219
  * Optional sort UI rendered above the grid. Visible only in stack mode
203
220
  * (SortChip carries its own `display: none → inline-flex` rule). Typical
@@ -216,7 +233,6 @@ export function Root({
216
233
  variant = 'default',
217
234
  size = 'md',
218
235
  responsive = true,
219
- minColumnWidth = 128,
220
236
  sortChip,
221
237
  pagination,
222
238
  }: RootProps) {
@@ -226,38 +242,67 @@ export function Root({
226
242
  inferredTemplateRef.current = template
227
243
  }
228
244
 
229
- // Content-aware stacking. A container query can't see the column count, so
230
- // measure the container and card up as soon as each column would fall
231
- // below `minColumnWidth`. Keyed on labels.length so it tracks the actual
232
- // number of columns. The @container base (styles.*: STACK_BP) still fires
233
- // independently for genuinely narrow widths without needing JS.
234
- const columnCount = labels.length
245
+ // Content-aware stacking driven by ACTUAL overflow, not a width heuristic.
246
+ // Column count alone is a poor proxy — a table with 8 short-text columns
247
+ // fits fine on a laptop, while 4 columns of buttons + long emails can be
248
+ // crushed. So instead of guessing, we render tabular and check whether any
249
+ // cell's content actually overflows its box (scrollWidth > clientWidth);
250
+ // when it does, the columns can't fit and we card up. The @container base
251
+ // (styles.*: STACK_BP) still stacks true-mobile widths without JS.
252
+ //
253
+ // Oscillation guard: stacking changes the layout (cards never overflow), so
254
+ // we can't re-measure overflow while stacked. We latch the container width
255
+ // at the moment we stacked and only return to tabular once the container
256
+ // has grown comfortably past it (hysteresis), then re-check overflow fresh.
235
257
  const containerRef = useRef<HTMLDivElement>(null)
258
+ const gridRef = useRef<HTMLDivElement>(null)
236
259
  const [stacked, setStacked] = useState(false)
260
+ const stackedAtWidthRef = useRef(0)
237
261
 
262
+ // Re-runs whenever `stacked` flips: after we drop back to tabular the
263
+ // container width is unchanged, so the ResizeObserver won't fire — the
264
+ // effect re-running is what re-checks overflow in the fresh tabular layout.
238
265
  useIsoLayoutEffect(() => {
239
- if (!responsive || columnCount === 0) {
266
+ if (!responsive) {
240
267
  setStacked(false)
241
268
  return
242
269
  }
243
270
  const el = containerRef.current
244
271
  if (!el || typeof ResizeObserver === 'undefined') return
245
- const threshold = columnCount * minColumnWidth
246
- const apply = (width: number) => setStacked(width > 0 && width < threshold)
247
- apply(el.getBoundingClientRect().width)
272
+
273
+ // Grows-back margin: only leave stack mode once we're well clear of the
274
+ // width that triggered it, so a 1px jiggle at the boundary can't flip-flop.
275
+ const HYSTERESIS = 48
276
+
277
+ const measure = (width: number) => {
278
+ if (width <= 0) return
279
+ if (!stacked) {
280
+ if (gridOverflows(gridRef.current)) {
281
+ stackedAtWidthRef.current = width
282
+ setStacked(true)
283
+ }
284
+ } else if (width > stackedAtWidthRef.current + HYSTERESIS) {
285
+ // Drop back to tabular; this effect re-runs and re-checks overflow.
286
+ setStacked(false)
287
+ }
288
+ }
289
+
290
+ measure(el.getBoundingClientRect().width)
248
291
  const ro = new ResizeObserver((entries) => {
249
292
  const entry = entries[0]
250
293
  if (!entry) return
251
294
  const box = entry.contentBoxSize?.[0]
252
- apply(box ? box.inlineSize : entry.contentRect.width)
295
+ measure(box ? box.inlineSize : entry.contentRect.width)
253
296
  })
254
297
  ro.observe(el)
255
298
  return () => ro.disconnect()
256
- }, [responsive, columnCount, minColumnWidth])
299
+ // labels.length re-runs detection when the column set changes.
300
+ }, [responsive, labels.length, stacked])
257
301
 
258
302
  const grid = (
259
303
  <html.div
260
304
  role="table"
305
+ ref={gridRef}
261
306
  style={[
262
307
  styles.root,
263
308
  // When responsive, use gridColumnsResponsive so the explicit column
@@ -49,8 +49,30 @@ export const Small: Story = {
49
49
  const layoutStyles = css.create({
50
50
  row: {display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap'},
51
51
  stack: {display: 'flex', flexDirection: 'column', gap: 16},
52
+ // Narrow column that a long identifier tag would overflow without `wrap`.
53
+ narrowCol: {width: 120, borderWidth: 1, borderStyle: 'solid', borderColor: '#333', padding: 8},
52
54
  })
53
55
 
56
+ const LONG_CAP = 'lldap.group.member.remove'
57
+
58
+ // A long identifier-style tag in a narrow column: with `wrap` it breaks
59
+ // within the column instead of overflowing (its scrollWidth stays within the
60
+ // box), fixing the "capability leaks into the next column" bug.
61
+ export const WrapLongLabel: Story = {
62
+ render: () => (
63
+ <html.div style={layoutStyles.narrowCol}>
64
+ <Tag size="sm" wrap>
65
+ {LONG_CAP}
66
+ </Tag>
67
+ </html.div>
68
+ ),
69
+ play: async ({canvas}) => {
70
+ const tag = canvas.getByText(LONG_CAP)
71
+ // Content wraps inside the tag; the tag doesn't spill past its column.
72
+ await expect(tag.scrollWidth).toBeLessThanOrEqual(tag.clientWidth + 2)
73
+ },
74
+ }
75
+
54
76
  export const AllVariants: Story = {
55
77
  render: () => (
56
78
  <html.div style={layoutStyles.stack}>
@@ -16,6 +16,14 @@ interface TagProps {
16
16
  /** Called when remove button is clicked. Auto-wired to TagGroup when inside one. */
17
17
  onRemove?: () => void
18
18
  disabled?: boolean
19
+ /**
20
+ * Allow the label to wrap / break instead of staying on one line. Tags are
21
+ * `white-space: nowrap` by default (they read as pills); set this for long
22
+ * identifier-style values (e.g. `lldap.group.member.remove`) sitting in a
23
+ * constrained column, so they break within their cell instead of leaking
24
+ * into the next one.
25
+ */
26
+ wrap?: boolean
19
27
  children: ReactNode
20
28
  }
21
29
 
@@ -36,6 +44,7 @@ export function Tag({
36
44
  removable: removableProp,
37
45
  onRemove: onRemoveProp,
38
46
  disabled: disabledProp = false,
47
+ wrap = false,
39
48
  children,
40
49
  }: TagProps) {
41
50
  const group = useTagGroup()
@@ -133,6 +142,7 @@ export function Tag({
133
142
  removable && removableSizeMap[size],
134
143
  styles[variant],
135
144
  disabled && styles.disabled,
145
+ wrap && styles.wrap,
136
146
  ]}
137
147
  >
138
148
  {tagContent}
@@ -13,6 +13,7 @@ export const styles = css.create({
13
13
  lineHeight: 1,
14
14
  borderRadius: 4,
15
15
  whiteSpace: 'nowrap',
16
+ minWidth: 0,
16
17
  borderWidth: 1,
17
18
  borderStyle: 'solid',
18
19
  borderColor: colors.border,
@@ -47,6 +48,17 @@ export const styles = css.create({
47
48
  paddingRight: spacing.sm,
48
49
  fontSize: typography.fontSizeXs,
49
50
  },
51
+ // Opt-in wrapping for long identifier-style labels. Overrides the base
52
+ // nowrap so the text breaks within its container (e.g. a table cell)
53
+ // instead of overflowing into the next column. `anywhere` also lets the
54
+ // tag shrink below its content's intrinsic width so a flex/grid parent can
55
+ // actually give it a narrow track.
56
+ wrap: {
57
+ whiteSpace: 'normal',
58
+ overflowWrap: 'anywhere',
59
+ lineHeight: 1.35,
60
+ textAlign: 'start',
61
+ },
50
62
  // Reduce right padding when removable to keep visual balance with the button
51
63
  removableMd: {
52
64
  paddingRight: spacing.sm,
@@ -1,8 +1,14 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {expect, fn, waitFor} from 'storybook/test'
3
3
  import {createColumnHelper} from '@tanstack/react-table'
4
+ import {css, html} from 'react-strict-dom'
4
5
  import {VirtualTable} from './VirtualTable'
5
6
 
7
+ const storyStyles = css.create({
8
+ // Phone-width wrapper: below VirtualTable's 640px stackBelow default.
9
+ narrow: {width: 400},
10
+ })
11
+
6
12
  interface Item {
7
13
  id: string
8
14
  name: string
@@ -87,3 +93,26 @@ export const ScrollReportsPage: Story = {
87
93
  await expect(maxPage).toBeGreaterThan(1)
88
94
  },
89
95
  }
96
+
97
+ // In a narrow (phone-width) container the table cards up: header hides and
98
+ // each row becomes a label/value card, like Table's stack mode.
99
+ export const StacksOnNarrow: Story = {
100
+ args: {data: makeData(6), onRowClick: fn(), rowLabel: (r: Item) => r.name},
101
+ decorators: [
102
+ (Story) => (
103
+ <html.div style={storyStyles.narrow}>
104
+ <Story />
105
+ </html.div>
106
+ ),
107
+ ],
108
+ play: async ({canvas}) => {
109
+ // Header group not rendered → no column headers in the a11y tree.
110
+ await waitFor(async () => {
111
+ await expect(canvas.queryAllByRole('columnheader')).toHaveLength(0)
112
+ })
113
+ // Each card surfaces the column labels ('Name'/'Value') next to values.
114
+ await expect(canvas.getAllByText('Name').length).toBe(6)
115
+ await expect(canvas.getByText('Row 0')).toBeInTheDocument()
116
+ await expect(canvas.getByText('Row 5')).toBeInTheDocument()
117
+ },
118
+ }
@@ -1,4 +1,4 @@
1
- import {useEffect, useRef, useState, type ReactNode} from 'react'
1
+ import {useEffect, useLayoutEffect, useRef, useState, type ReactNode} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {
4
4
  useReactTable,
@@ -13,6 +13,9 @@ import {
13
13
  import {useVirtualizer} from '@tanstack/react-virtual'
14
14
  import {styles} from './styles.css'
15
15
 
16
+ // Measure + commit before paint on the client; no-op-safe on the server.
17
+ const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
18
+
16
19
  export interface VirtualTableRange {
17
20
  /** 1-based index of the first visible row (0 when empty). */
18
21
  from: number
@@ -56,6 +59,17 @@ interface VirtualTableProps<TData> {
56
59
  maxHeight?: number | string
57
60
  /** Render every row (no windowing) at or below this count. Default 150. */
58
61
  virtualizeThreshold?: number
62
+ /**
63
+ * Below this container width (px) the table cards up: the header hides and
64
+ * each row becomes a label/value card (like Table's stack mode). Since the
65
+ * flex columns truncate rather than overflow, this is a deliberate
66
+ * phone-width breakpoint, not an auto content measurement. Cards disable
67
+ * windowing (variable heights), so keep phone lists reasonably short.
68
+ * Default 640. Set `responsive={false}` to disable entirely.
69
+ */
70
+ stackBelow?: number
71
+ /** Opt out of the card-up-on-narrow behavior. Default true. */
72
+ responsive?: boolean
59
73
  emptyLabel?: ReactNode
60
74
  }
61
75
 
@@ -84,6 +98,8 @@ export function VirtualTable<TData>({
84
98
  estimateRowHeight = 44,
85
99
  maxHeight = '70vh',
86
100
  virtualizeThreshold = 150,
101
+ stackBelow = 640,
102
+ responsive = true,
87
103
  emptyLabel,
88
104
  }: VirtualTableProps<TData>) {
89
105
  // Support both controlled sorting (SortChip in the caller) and standalone use.
@@ -102,7 +118,35 @@ export function VirtualTable<TData>({
102
118
  })
103
119
 
104
120
  const rows = table.getRowModel().rows
105
- const virtualize = rows.length > virtualizeThreshold
121
+
122
+ // Card-up on narrow containers. Flex cells truncate rather than overflow, so
123
+ // there's no content-overflow signal to measure (as in Table) — we use a
124
+ // deliberate phone-width breakpoint. Cards render every row (no windowing),
125
+ // so `virtualize` is forced off while stacked.
126
+ const wrapRef = useRef<HTMLDivElement>(null)
127
+ const [stacked, setStacked] = useState(false)
128
+ useIsoLayoutEffect(() => {
129
+ if (!responsive) {
130
+ setStacked(false)
131
+ return
132
+ }
133
+ const el = wrapRef.current
134
+ if (!el || typeof ResizeObserver === 'undefined') return
135
+ const apply = (width: number) => {
136
+ if (width > 0) setStacked(width < stackBelow)
137
+ }
138
+ apply(el.getBoundingClientRect().width)
139
+ const ro = new ResizeObserver((entries) => {
140
+ const entry = entries[0]
141
+ if (!entry) return
142
+ const box = entry.contentBoxSize?.[0]
143
+ apply(box ? box.inlineSize : entry.contentRect.width)
144
+ })
145
+ ro.observe(el)
146
+ return () => ro.disconnect()
147
+ }, [responsive, stackBelow])
148
+
149
+ const virtualize = rows.length > virtualizeThreshold && !stacked
106
150
 
107
151
  const scrollRef = useRef<HTMLDivElement>(null)
108
152
  const rv = useVirtualizer({
@@ -174,82 +218,132 @@ export function VirtualTable<TData>({
174
218
  // eslint-disable-next-line react-hooks/exhaustive-deps
175
219
  }, [curPage, virtualize])
176
220
 
221
+ // Stack-mode label for a column: explicit meta override, else the string
222
+ // header, else nothing (value renders label-less, e.g. an actions column).
223
+ const columnStackLabel = (columnDef: {
224
+ header?: unknown
225
+ meta?: {stackLabel?: string; label?: string}
226
+ }): string => {
227
+ const meta = columnDef.meta
228
+ if (typeof meta?.stackLabel === 'string') return meta.stackLabel
229
+ if (typeof meta?.label === 'string') return meta.label
230
+ return typeof columnDef.header === 'string' ? columnDef.header : ''
231
+ }
232
+
177
233
  const renderRow = (
178
234
  row: Row<TData>,
179
235
  index: number,
180
236
  positioned?: ReturnType<typeof styles.rowAbsolute>,
181
237
  measureRef?: (node: Element | null) => void,
182
- ) => (
183
- <html.div
184
- key={row.id}
185
- ref={measureRef}
186
- data-index={index}
187
- role="row"
188
- aria-label={rowLabel?.(row.original)}
189
- onClick={onRowClick ? () => onRowClick(row.original) : undefined}
190
- style={[styles.row, Boolean(onRowClick) && styles.rowClickable, positioned]}
191
- >
192
- {row.getVisibleCells().map((cell) => (
193
- <html.div
194
- key={cell.id}
195
- role="cell"
196
- style={[styles.cell, styles.cellFlex(cell.column.getSize())]}
197
- >
198
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
199
- </html.div>
200
- ))}
201
- </html.div>
202
- )
238
+ ) =>
239
+ stacked ? (
240
+ <html.div
241
+ key={row.id}
242
+ role="row"
243
+ aria-label={rowLabel?.(row.original)}
244
+ onClick={onRowClick ? () => onRowClick(row.original) : undefined}
245
+ style={[styles.stackRow, Boolean(onRowClick) && styles.rowClickable]}
246
+ >
247
+ {row.getVisibleCells().map((cell) => {
248
+ const lbl = columnStackLabel(cell.column.columnDef)
249
+ return (
250
+ <html.div key={cell.id} role="cell" style={styles.stackCell}>
251
+ {lbl ? <html.span style={styles.stackCellLabel}>{lbl}</html.span> : null}
252
+ <html.div style={styles.stackCellValue}>
253
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
254
+ </html.div>
255
+ </html.div>
256
+ )
257
+ })}
258
+ </html.div>
259
+ ) : (
260
+ <html.div
261
+ key={row.id}
262
+ ref={measureRef}
263
+ data-index={index}
264
+ role="row"
265
+ aria-label={rowLabel?.(row.original)}
266
+ onClick={onRowClick ? () => onRowClick(row.original) : undefined}
267
+ style={[styles.row, Boolean(onRowClick) && styles.rowClickable, positioned]}
268
+ >
269
+ {row.getVisibleCells().map((cell) => (
270
+ <html.div
271
+ key={cell.id}
272
+ role="cell"
273
+ style={[styles.cell, styles.cellFlex(cell.column.getSize())]}
274
+ >
275
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
276
+ </html.div>
277
+ ))}
278
+ </html.div>
279
+ )
203
280
 
204
281
  const label =
205
282
  rangeLabel?.(range) ?? (rows.length ? `${range.from}–${range.to} / ${range.total}` : '')
206
283
 
207
284
  return (
208
- <html.div style={styles.wrap}>
285
+ <html.div ref={wrapRef} style={styles.wrap}>
209
286
  <html.div
210
287
  ref={scrollRef}
211
288
  role="table"
212
- style={[styles.scroll, virtualize && styles.scrollMax(maxHeight)]}
289
+ style={[
290
+ stacked ? styles.stackScroll : styles.scroll,
291
+ virtualize && styles.scrollMax(maxHeight),
292
+ ]}
213
293
  >
214
- <html.div role="rowgroup" style={styles.head}>
215
- {table.getHeaderGroups().map((headerGroup) => (
216
- <html.div key={headerGroup.id} role="row" style={styles.headRow}>
217
- {headerGroup.headers.map((header) => {
218
- const col = header.column
219
- const sorted = col.getIsSorted()
220
- return (
221
- <html.div
222
- key={header.id}
223
- role="columnheader"
224
- aria-sort={
225
- sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : undefined
226
- }
227
- onClick={col.getCanSort() ? col.getToggleSortingHandler() : undefined}
228
- style={[
229
- styles.headCell,
230
- col.getCanSort() && styles.headCellSortable,
231
- styles.cellFlex(header.getSize()),
232
- ]}
233
- >
234
- {header.isPlaceholder
235
- ? null
236
- : flexRender(col.columnDef.header, header.getContext())}
237
- {sorted ? (
238
- <html.span style={styles.sortMark}>{sorted === 'asc' ? '↑' : '↓'}</html.span>
239
- ) : null}
240
- </html.div>
241
- )
242
- })}
243
- </html.div>
244
- ))}
245
- </html.div>
294
+ {stacked ? null : (
295
+ <html.div role="rowgroup" style={styles.head}>
296
+ {table.getHeaderGroups().map((headerGroup) => (
297
+ <html.div key={headerGroup.id} role="row" style={styles.headRow}>
298
+ {headerGroup.headers.map((header) => {
299
+ const col = header.column
300
+ const sorted = col.getIsSorted()
301
+ return (
302
+ <html.div
303
+ key={header.id}
304
+ role="columnheader"
305
+ aria-sort={
306
+ sorted === 'asc'
307
+ ? 'ascending'
308
+ : sorted === 'desc'
309
+ ? 'descending'
310
+ : undefined
311
+ }
312
+ onClick={col.getCanSort() ? col.getToggleSortingHandler() : undefined}
313
+ style={[
314
+ styles.headCell,
315
+ col.getCanSort() && styles.headCellSortable,
316
+ styles.cellFlex(header.getSize()),
317
+ ]}
318
+ >
319
+ {header.isPlaceholder
320
+ ? null
321
+ : flexRender(col.columnDef.header, header.getContext())}
322
+ {sorted ? (
323
+ <html.span style={styles.sortMark}>
324
+ {sorted === 'asc' ? '↑' : '↓'}
325
+ </html.span>
326
+ ) : null}
327
+ </html.div>
328
+ )
329
+ })}
330
+ </html.div>
331
+ ))}
332
+ </html.div>
333
+ )}
246
334
 
247
335
  {rows.length === 0 ? (
248
336
  <html.div style={styles.empty}>{emptyLabel}</html.div>
249
337
  ) : (
250
338
  <html.div
251
339
  role="rowgroup"
252
- style={virtualize ? styles.bodyTotal(rv.getTotalSize()) : undefined}
340
+ style={
341
+ stacked
342
+ ? styles.stackBody
343
+ : virtualize
344
+ ? styles.bodyTotal(rv.getTotalSize())
345
+ : undefined
346
+ }
253
347
  >
254
348
  {virtualize
255
349
  ? vItems.map((vi) =>