@duro-app/ui 0.45.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.
Files changed (37) hide show
  1. package/dist/components/Table/FromTanstack.d.ts +2 -2
  2. package/dist/components/Table/FromTanstack.d.ts.map +1 -1
  3. package/dist/components/Table/SortChip.d.ts +1 -8
  4. package/dist/components/Table/SortChip.d.ts.map +1 -1
  5. package/dist/components/Table/Table.d.ts +6 -6
  6. package/dist/components/Table/Table.d.ts.map +1 -1
  7. package/dist/components/Table/Table.stories.d.ts +3 -2
  8. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  9. package/dist/components/Table/styles.css.d.ts +5 -40
  10. package/dist/components/Table/styles.css.d.ts.map +1 -1
  11. package/dist/components/Tag/Tag.d.ts +9 -1
  12. package/dist/components/Tag/Tag.d.ts.map +1 -1
  13. package/dist/components/Tag/Tag.stories.d.ts +1 -0
  14. package/dist/components/Tag/Tag.stories.d.ts.map +1 -1
  15. package/dist/components/Tag/styles.css.d.ts +7 -0
  16. package/dist/components/Tag/styles.css.d.ts.map +1 -1
  17. package/dist/components/VirtualTable/VirtualTable.d.ts +11 -1
  18. package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -1
  19. package/dist/components/VirtualTable/VirtualTable.stories.d.ts +1 -0
  20. package/dist/components/VirtualTable/VirtualTable.stories.d.ts.map +1 -1
  21. package/dist/components/VirtualTable/styles.css.d.ts +40 -0
  22. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  23. package/dist/index.css +1 -1
  24. package/dist/index.js +2633 -2623
  25. package/dist/index.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/components/Table/FromTanstack.tsx +2 -2
  28. package/src/components/Table/SortChip.tsx +2 -15
  29. package/src/components/Table/Table.stories.tsx +91 -75
  30. package/src/components/Table/Table.tsx +35 -103
  31. package/src/components/Table/styles.css.ts +38 -65
  32. package/src/components/Tag/Tag.stories.tsx +22 -0
  33. package/src/components/Tag/Tag.tsx +10 -0
  34. package/src/components/Tag/styles.css.ts +12 -0
  35. package/src/components/VirtualTable/VirtualTable.stories.tsx +29 -0
  36. package/src/components/VirtualTable/VirtualTable.tsx +152 -58
  37. package/src/components/VirtualTable/styles.css.ts +43 -0
@@ -3,13 +3,15 @@ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
3
  import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
  import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
6
+ import {breakpoints} from '@duro-app/tokens/tokens/breakpoints.css'
6
7
 
7
- // Container-query thresholds. Picked for typical 5-7 column admin tables —
8
- // see Table.stories.tsx "Responsive" for the visual reasoning. Defined
9
- // once here so it's clear they're a design-system constant, not a magic
10
- // number sprinkled across files.
11
- const COMPACT_BP = '720px'
12
- const STACK_BP = '440px'
8
+ // Container-query thresholds, from the shared breakpoint scale. `sm` (640px)
9
+ // is the single "card up" line used across every table (Table + VirtualTable);
10
+ // below it dense tables become cards, above it they stay tabular and scroll
11
+ // horizontally. `md` (768px) is the compact band (tighter padding, nowrap
12
+ // headers). These are inlined into the @container query text at build time.
13
+ const COMPACT_BP = breakpoints.md
14
+ const STACK_BP = breakpoints.sm
13
15
 
14
16
  export const styles = css.create({
15
17
  // Outer wrapper that hosts the @container query. Wraps SortChip + Root
@@ -68,6 +70,16 @@ export const styles = css.create({
68
70
  // always wins, so combining gridColumns + rootResponsive in one array would
69
71
  // silently drop the column template).
70
72
  rootResponsive: {
73
+ // Size the grid to its content so it can OVERFLOW the scroll wrapper
74
+ // rather than shrink: `min-content` here is the sum of each column's
75
+ // minimum track (the minmax() floor / its content), so a dense table
76
+ // grows past the container and Root's `scrollX` wrapper scrolls sideways.
77
+ // In stack mode the grid is a single column, so drop the floor to let the
78
+ // card fill the width instead of forcing a horizontal scrollbar.
79
+ minWidth: {
80
+ default: 'min-content',
81
+ [`@container (max-width: ${STACK_BP})`]: 0,
82
+ },
71
83
  // The card-list look needs the outer border to disappear in stack
72
84
  // mode — each row paints its own border.
73
85
  borderWidth: {
@@ -320,61 +332,19 @@ export const styles = css.create({
320
332
  alignSelf: 'flex-start',
321
333
  },
322
334
 
323
- // --- JS-measured force-stack variants ---
324
- //
325
- // A container query can only test the container's WIDTH — it can't know
326
- // "6 columns won't fit here". So a dense table can be wider than STACK_BP
327
- // yet still crush every cell to a few characters. Table.Root measures the
328
- // container with a ResizeObserver and, when `width < columnCount ×
329
- // minColumnWidth`, sets `stacked` in context; each component then also
330
- // applies its `*Stacked` variant here. These mirror the values in the
331
- // `@container (max-width: ${STACK_BP})` branches above, so JS-forced and
332
- // CSS-driven stacking render identically. The @container base is kept as
333
- // the SSR-safe path for genuinely narrow (mobile) widths — no JS, no
334
- // hydration flash — while these handle the cramped medium-width case.
335
- rootStacked: {
336
- gridTemplateColumns: '1fr',
337
- borderWidth: 0,
338
- backgroundColor: 'transparent',
339
- overflow: 'visible',
340
- rowGap: spacing.sm,
341
- },
342
- headerStacked: {
343
- display: 'none',
344
- },
345
- rowStacked: {
346
- gridTemplateColumns: '1fr',
347
- borderBottomWidth: 0,
348
- },
349
- bodyRowStacked: {
350
- backgroundColor: {
351
- default: colors.bgCard,
352
- ':hover': colors.bgCardHover,
353
- },
354
- padding: spacing.sm,
355
- borderRadius: radii.sm,
356
- borderWidth: 1,
357
- },
358
- cellStacked: {
359
- display: 'grid',
360
- gridTemplateColumns: '1fr 2fr',
361
- gap: spacing.sm,
362
- },
363
- cellLabelStacked: {
364
- display: 'block',
365
- },
366
- cellActionsStacked: {
367
- gridTemplateColumns: '1fr',
368
- justifyContent: 'flex-end',
369
- marginTop: spacing.sm,
370
- paddingTop: spacing.sm,
371
- borderTopWidth: 1,
372
- },
373
- borderedCellStacked: {
374
- borderRightWidth: 0,
375
- },
376
- sortChipStacked: {
377
- display: 'inline-flex',
335
+ // Horizontal-scroll frame around the grid (responsive path). Above the
336
+ // stack breakpoint the grid keeps each column at least `minColumnWidth`
337
+ // wide (see the minmax() template), so a dense table overflows and this
338
+ // wrapper scrolls sideways instead of crushing its cells — the standard
339
+ // data-table behavior. In stack mode the grid collapses to one column, so
340
+ // nothing overflows and no scrollbar appears.
341
+ scrollX: {
342
+ overflowX: 'auto',
343
+ // Contain the scroll to this axis; vertical growth (rows) is natural.
344
+ overflowY: 'visible',
345
+ // Momentum scroll on touch + a stable gutter so the layout doesn't jump
346
+ // when the scrollbar shows.
347
+ WebkitOverflowScrolling: 'touch',
378
348
  },
379
349
 
380
350
  // Dynamic: grid columns applied on Root (non-responsive path only).
@@ -389,15 +359,18 @@ export const styles = css.create({
389
359
  // earlier classes (the LAST style wins per property key).
390
360
  //
391
361
  // - default: balanced multi-column layout for desktop widths
392
- // - @container (max-width: COMPACT_BP): consumer-supplied "compact"
393
- // template, e.g. give a badge column max-content and let the action
394
- // column absorb the slack so its hint text fits on one line
362
+ // - compact band (STACK_BP < width ≤ COMPACT_BP): consumer-supplied
363
+ // "compact" template. This is written as an explicit RANGE, not just
364
+ // `max-width: COMPACT_BP`, so it does NOT also match below STACK_BP —
365
+ // otherwise both the compact and the stack rule would set
366
+ // gridTemplateColumns at card widths and StyleX's ordering could let the
367
+ // (wider, 4-column) compact template shadow the single-column stack one.
395
368
  // - @container (max-width: STACK_BP): collapse to a single column so
396
369
  // each row becomes a card
397
370
  gridColumnsResponsive: (template: string, compactTemplate: string) => ({
398
371
  gridTemplateColumns: {
399
372
  default: template,
400
- [`@container (max-width: ${COMPACT_BP})`]: compactTemplate,
373
+ [`@container (${STACK_BP} < width <= ${COMPACT_BP})`]: compactTemplate,
401
374
  [`@container (max-width: ${STACK_BP})`]: '1fr',
402
375
  },
403
376
  }),
@@ -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,
@@ -11,8 +11,12 @@ import {
11
11
  type Row,
12
12
  } from '@tanstack/react-table'
13
13
  import {useVirtualizer} from '@tanstack/react-virtual'
14
+ import {breakpointsPx} from '@duro-app/tokens/tokens/breakpoints.css'
14
15
  import {styles} from './styles.css'
15
16
 
17
+ // Measure + commit before paint on the client; no-op-safe on the server.
18
+ const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
19
+
16
20
  export interface VirtualTableRange {
17
21
  /** 1-based index of the first visible row (0 when empty). */
18
22
  from: number
@@ -56,6 +60,16 @@ interface VirtualTableProps<TData> {
56
60
  maxHeight?: number | string
57
61
  /** Render every row (no windowing) at or below this count. Default 150. */
58
62
  virtualizeThreshold?: number
63
+ /**
64
+ * Below this container width (px) the table cards up: the header hides and
65
+ * each row becomes a label/value card (like Table's stack mode). Defaults to
66
+ * the shared `sm` breakpoint (640px) so every table cards up at the same
67
+ * line. Cards disable windowing (variable heights), so keep phone lists
68
+ * reasonably short. 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 = breakpointsPx.sm,
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) =>
@@ -105,6 +105,49 @@ export const styles = css.create({
105
105
  flexBasis: 0,
106
106
  minWidth: 0,
107
107
  }),
108
+ // --- Stack (card) mode: narrow containers ---
109
+ // The scroll frame drops its border/background so each row card stands on
110
+ // its own; windowing is off in this mode so no capped viewport.
111
+ stackScroll: {
112
+ position: 'relative',
113
+ width: '100%',
114
+ backgroundColor: 'transparent',
115
+ },
116
+ stackBody: {
117
+ display: 'flex',
118
+ flexDirection: 'column',
119
+ gap: spacing.sm,
120
+ },
121
+ stackRow: {
122
+ display: 'flex',
123
+ flexDirection: 'column',
124
+ gap: spacing.xs,
125
+ width: '100%',
126
+ padding: spacing.sm,
127
+ borderWidth: 1,
128
+ borderStyle: 'solid',
129
+ borderColor: colors.border,
130
+ borderRadius: radii.sm,
131
+ backgroundColor: colors.bgCard,
132
+ },
133
+ stackCell: {
134
+ display: 'grid',
135
+ gridTemplateColumns: '1fr 2fr',
136
+ gap: spacing.sm,
137
+ alignItems: 'center',
138
+ fontFamily: typography.fontFamily,
139
+ fontSize: typography.fontSizeSm,
140
+ color: colors.text,
141
+ },
142
+ stackCellLabel: {
143
+ color: colors.textMuted,
144
+ fontWeight: typography.fontWeightMedium,
145
+ fontSize: typography.fontSizeXs,
146
+ },
147
+ stackCellValue: {
148
+ minWidth: 0,
149
+ overflowWrap: 'break-word',
150
+ },
108
151
  empty: {
109
152
  paddingTop: spacing.xl,
110
153
  paddingBottom: spacing.xl,