@duro-app/ui 0.44.0 → 0.45.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.44.0",
3
+ "version": "0.45.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.44.0"
58
+ "@duro-app/tokens": "^0.45.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@babel/preset-typescript": "^7.28.0",
@@ -22,6 +22,9 @@ 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
25
28
  /** Render a SortChip above the grid (stack-mode only). Options derive from
26
29
  * columns where `column.getCanSort()` returns true. Wires value/onChange
27
30
  * through the TanStack sorting state automatically. */
@@ -68,6 +71,7 @@ export function FromTanstack<TData>({
68
71
  variant = 'default',
69
72
  size = 'md',
70
73
  responsive = true,
74
+ minColumnWidth,
71
75
  sortChip,
72
76
  pagination,
73
77
  rowKey,
@@ -82,6 +86,7 @@ export function FromTanstack<TData>({
82
86
  variant={variant}
83
87
  size={size}
84
88
  responsive={responsive}
89
+ minColumnWidth={minColumnWidth}
85
90
  sortChip={sortChipNode}
86
91
  pagination={paginationNode}
87
92
  >
@@ -13,6 +13,13 @@ export interface SortChipProps {
13
13
  readonly onChange: (next: SortValue | null) => void
14
14
  /** Visible label preceding the sort field, e.g. "Sort by". */
15
15
  readonly label?: string
16
+ /**
17
+ * Reveal the chip regardless of the container query. Injected by
18
+ * `Table.Root` when it force-stacks a cramped medium-width table (headers
19
+ * hidden but container wider than the mobile breakpoint, so the chip's own
20
+ * `@container` rule hasn't fired). Not part of the public API.
21
+ */
22
+ readonly forceShow?: boolean
16
23
  }
17
24
 
18
25
  /**
@@ -24,12 +31,18 @@ export interface SortChipProps {
24
31
  * couple to @tanstack/react-table — the consumer wires `value` and
25
32
  * `onChange` from whatever sort state they manage.
26
33
  */
27
- export function SortChip({options, value, onChange, label = 'Sort by'}: SortChipProps) {
34
+ export function SortChip({
35
+ options,
36
+ value,
37
+ onChange,
38
+ label = 'Sort by',
39
+ forceShow = false,
40
+ }: SortChipProps) {
28
41
  const current = value ? options.find((o) => o.id === value.id) : null
29
42
  const triggerLabel = current ? `${label}: ${current.label} ${value!.desc ? '↓' : '↑'}` : label
30
43
 
31
44
  return (
32
- <html.div style={styles.sortChip}>
45
+ <html.div style={[styles.sortChip, forceShow && styles.sortChipStacked]}>
33
46
  <Menu.Root>
34
47
  <Menu.Trigger>{triggerLabel}</Menu.Trigger>
35
48
  <Menu.Popup>
@@ -1,5 +1,5 @@
1
1
  import type {Meta, StoryObj} from '@storybook/react'
2
- import {expect} from 'storybook/test'
2
+ import {expect, waitFor} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {useState} from 'react'
5
5
  import {Table} from './Table'
@@ -1142,6 +1142,10 @@ 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.
1148
+ cramped: {width: 560},
1145
1149
  })
1146
1150
 
1147
1151
  const LONG_EMAIL = 'a-really-long-service-account.address@subdomain.example-company.com'
@@ -1191,3 +1195,66 @@ export const ManyColumnsWrapNoOverlap: Story = {
1191
1195
  await expect(emailCell.scrollWidth).toBeLessThanOrEqual(emailCell.clientWidth + 2)
1192
1196
  },
1193
1197
  }
1198
+
1199
+ // A dense table wider than the 440px mobile breakpoint but too narrow to give
1200
+ // each column a comfortable width. The @container query never fires (560 >
1201
+ // 440), so this asserts the JS ResizeObserver force-stacks it into cards:
1202
+ // 6 columns × 128px default = 768px threshold > 560px container.
1203
+ export const AutoStacksWhenCramped: Story = {
1204
+ name: 'Auto-stacks when columns get cramped',
1205
+ render: () => (
1206
+ <html.div style={overlapWrapStyles.cramped}>
1207
+ <Table.Root>
1208
+ <Table.Header>
1209
+ <Table.Row>
1210
+ <Table.HeaderCell>Display Name</Table.HeaderCell>
1211
+ <Table.HeaderCell>Type</Table.HeaderCell>
1212
+ <Table.HeaderCell>Email</Table.HeaderCell>
1213
+ <Table.HeaderCell>Status</Table.HeaderCell>
1214
+ <Table.HeaderCell>Owner</Table.HeaderCell>
1215
+ <Table.HeaderCell width="max-content">Actions</Table.HeaderCell>
1216
+ </Table.Row>
1217
+ </Table.Header>
1218
+ <Table.Body>
1219
+ <Table.Row>
1220
+ <Table.Cell>SMTP submission service account</Table.Cell>
1221
+ <Table.Cell>
1222
+ <Badge>User</Badge>
1223
+ </Table.Cell>
1224
+ <Table.Cell>{LONG_EMAIL}</Table.Cell>
1225
+ <Table.Cell>
1226
+ <Badge variant="warning">No certs</Badge>
1227
+ </Table.Cell>
1228
+ <Table.Cell>daddy</Table.Cell>
1229
+ <Table.Cell isActions>
1230
+ <Button size="small">Send Cert</Button>
1231
+ <Button size="small" variant="secondary">
1232
+ Revoke
1233
+ </Button>
1234
+ </Table.Cell>
1235
+ </Table.Row>
1236
+ </Table.Body>
1237
+ </Table.Root>
1238
+ </html.div>
1239
+ ),
1240
+ play: async ({canvas}) => {
1241
+ const grid = canvas.getByRole('table')
1242
+ // ResizeObserver fires post-layout; wait for the stack decision to commit.
1243
+ await waitFor(async () => {
1244
+ // The grid collapses to a single column — one resolved track, no spaces.
1245
+ const tracks = getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/)
1246
+ await expect(tracks.length).toBe(1)
1247
+ })
1248
+ // Header group is display:none, so it drops out of the accessibility tree:
1249
+ // no column headers remain queryable, leaving only the body rowgroup.
1250
+ await expect(canvas.queryAllByRole('columnheader')).toHaveLength(0)
1251
+ await expect(canvas.getAllByRole('rowgroup')).toHaveLength(1)
1252
+ // Each body cell now surfaces its label span (label|value card rows). The
1253
+ // hidden header 'Owner' stays in the DOM, so pick the visible <span>.
1254
+ const ownerLabel = canvas
1255
+ .getAllByText('Owner')
1256
+ .find((el) => el.tagName.toLowerCase() === 'span') as HTMLElement
1257
+ await expect(ownerLabel).toBeTruthy()
1258
+ await expect(getComputedStyle(ownerLabel).display).toBe('block')
1259
+ },
1260
+ }
@@ -3,8 +3,12 @@ import {
3
3
  type ReactNode,
4
4
  type MutableRefObject,
5
5
  createContext,
6
+ cloneElement,
6
7
  useContext,
8
+ useEffect,
9
+ useLayoutEffect,
7
10
  useRef,
11
+ useState,
8
12
  Children,
9
13
  isValidElement,
10
14
  } from 'react'
@@ -31,8 +35,20 @@ interface TableContextValue {
31
35
  labels: ReadonlyArray<string>
32
36
  /** Mutable ref: header Row writes inferred template, Root reads it */
33
37
  inferredTemplateRef: MutableRefObject<string | null>
38
+ /** JS-measured force-stack flag. True when Root's ResizeObserver finds the
39
+ * container too narrow for its column count (cells would be crushed), so
40
+ * every cell/row/header also applies its `*Stacked` variant. Independent
41
+ * of the @container base, which still handles genuinely narrow widths. */
42
+ stacked: boolean
34
43
  }
35
44
 
45
+ // useLayoutEffect measures + commits the stack decision before the browser
46
+ // paints, so a client-rendered narrow table shows cards on first frame
47
+ // rather than flashing tabular. On the server it would warn (no layout), so
48
+ // fall back to useEffect there — SSR always emits the non-stacked markup and
49
+ // the @container CSS covers true mobile without JS.
50
+ const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
51
+
36
52
  const TableContext = createContext<TableContextValue | null>(null)
37
53
 
38
54
  function useTable() {
@@ -174,6 +190,14 @@ interface RootProps {
174
190
  size?: TableSize
175
191
  /** Opt out of responsive container-query behavior. Default true. */
176
192
  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
177
201
  /**
178
202
  * Optional sort UI rendered above the grid. Visible only in stack mode
179
203
  * (SortChip carries its own `display: none → inline-flex` rule). Typical
@@ -192,6 +216,7 @@ export function Root({
192
216
  variant = 'default',
193
217
  size = 'md',
194
218
  responsive = true,
219
+ minColumnWidth = 128,
195
220
  sortChip,
196
221
  pagination,
197
222
  }: RootProps) {
@@ -201,6 +226,35 @@ export function Root({
201
226
  inferredTemplateRef.current = template
202
227
  }
203
228
 
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
235
+ const containerRef = useRef<HTMLDivElement>(null)
236
+ const [stacked, setStacked] = useState(false)
237
+
238
+ useIsoLayoutEffect(() => {
239
+ if (!responsive || columnCount === 0) {
240
+ setStacked(false)
241
+ return
242
+ }
243
+ const el = containerRef.current
244
+ 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)
248
+ const ro = new ResizeObserver((entries) => {
249
+ const entry = entries[0]
250
+ if (!entry) return
251
+ const box = entry.contentBoxSize?.[0]
252
+ apply(box ? box.inlineSize : entry.contentRect.width)
253
+ })
254
+ ro.observe(el)
255
+ return () => ro.disconnect()
256
+ }, [responsive, columnCount, minColumnWidth])
257
+
204
258
  const grid = (
205
259
  <html.div
206
260
  role="table"
@@ -218,26 +272,45 @@ export function Root({
218
272
  : styles.gridColumns(template)
219
273
  : undefined,
220
274
  responsive && styles.rootResponsive,
275
+ // Force-stack override — collapses the grid to one column. Applied
276
+ // last so its gridTemplateColumns wins over gridColumnsResponsive.
277
+ responsive && stacked && styles.rootStacked,
221
278
  ]}
222
279
  >
223
280
  {children}
224
281
  </html.div>
225
282
  )
226
283
 
284
+ // The sort chip is normally revealed only by the @container query. When
285
+ // JS force-stacks (headers hidden, but container wider than STACK_BP) the
286
+ // chip's own query hasn't fired, so inject forceShow to reveal it.
287
+ const sortChipEl =
288
+ stacked && isValidElement(sortChip)
289
+ ? cloneElement(sortChip as ReactElement<{forceShow?: boolean}>, {forceShow: true})
290
+ : sortChip
291
+
227
292
  // Slots render unconditionally — `responsive=false` still wants its sort/
228
293
  // pagination chrome. Only the containerType:inline-size wrapper is
229
294
  // conditional, since it only matters when @container queries fire.
230
295
  const body = (
231
296
  <>
232
- {sortChip}
297
+ {sortChipEl}
233
298
  {grid}
234
299
  {pagination}
235
300
  </>
236
301
  )
237
302
 
238
303
  return (
239
- <TableContext.Provider value={{variant, size, responsive, labels, inferredTemplateRef}}>
240
- {responsive ? <html.div style={styles.rootContainer}>{body}</html.div> : body}
304
+ <TableContext.Provider
305
+ value={{variant, size, responsive, labels, inferredTemplateRef, stacked}}
306
+ >
307
+ {responsive ? (
308
+ <html.div ref={containerRef} style={styles.rootContainer}>
309
+ {body}
310
+ </html.div>
311
+ ) : (
312
+ body
313
+ )}
241
314
  </TableContext.Provider>
242
315
  )
243
316
  }
@@ -245,9 +318,10 @@ export function Root({
245
318
  // --- Header ---
246
319
 
247
320
  export function Header({children}: {children: ReactNode}) {
321
+ const {stacked} = useTable()
248
322
  return (
249
323
  <HeaderContext.Provider value={true}>
250
- <html.div role="rowgroup" style={styles.header}>
324
+ <html.div role="rowgroup" style={[styles.header, stacked && styles.headerStacked]}>
251
325
  {children}
252
326
  </html.div>
253
327
  </HeaderContext.Provider>
@@ -311,7 +385,7 @@ const INTERACTIVE_SELECTOR =
311
385
  'button, a, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="switch"], [role="tab"], [contenteditable="true"]'
312
386
 
313
387
  export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
314
- const {variant} = useTable()
388
+ const {variant, stacked} = useTable()
315
389
  const isHeader = useContext(HeaderContext)
316
390
  const rowIndex = useContext(RowIndexContext)
317
391
  const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
@@ -353,6 +427,9 @@ export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
353
427
  !isHeader && styles.bodyRow,
354
428
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
355
429
  isClickable && styles.clickableRow,
430
+ stacked && styles.rowStacked,
431
+ // bodyRowStacked last so its card background wins over stripedEven.
432
+ !isHeader && stacked && styles.bodyRowStacked,
356
433
  ]}
357
434
  >
358
435
  {childArray.map((child, index) => (
@@ -441,7 +518,7 @@ HeaderCell.displayName = 'HeaderCell'
441
518
  // isActions=true — actions footers don't show a label.
442
519
 
443
520
  export function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
444
- const {size, variant, labels, responsive} = useTable()
521
+ const {size, variant, labels, responsive, stacked} = useTable()
445
522
  const {index, total} = useContext(CellIndexContext)
446
523
  const isLast = variant === 'bordered' && index === total - 1
447
524
  const label = labels[index] ?? ''
@@ -455,10 +532,20 @@ export function Cell({children, isActions}: {children: ReactNode; isActions?: bo
455
532
  variant === 'bordered' && styles.borderedCell,
456
533
  isLast && styles.borderedCellLast,
457
534
  isActions && styles.cellActions,
535
+ // Force-stack: cells become a label|value grid; the actions cell then
536
+ // overrides its template to a single track and turns into the card's
537
+ // right-aligned footer. Mirrors the @container (STACK_BP) branch, so
538
+ // cellStacked applies for every cell and cellActionsStacked layers on
539
+ // top for actions (its gridTemplateColumns wins as the later entry).
540
+ stacked && styles.cellStacked,
541
+ isActions && stacked && styles.cellActionsStacked,
542
+ variant === 'bordered' && stacked && styles.borderedCellStacked,
458
543
  ]}
459
544
  >
460
545
  {responsive && !isActions && label !== '' ? (
461
- <html.span style={styles.cellLabel}>{label}</html.span>
546
+ <html.span style={[styles.cellLabel, stacked && styles.cellLabelStacked]}>
547
+ {label}
548
+ </html.span>
462
549
  ) : null}
463
550
  {isActions ? children : <html.div style={styles.cellValue}>{children}</html.div>}
464
551
  </html.div>
@@ -320,6 +320,63 @@ export const styles = css.create({
320
320
  alignSelf: 'flex-start',
321
321
  },
322
322
 
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',
378
+ },
379
+
323
380
  // Dynamic: grid columns applied on Root (non-responsive path only).
324
381
  gridColumns: (template: string) => ({
325
382
  gridTemplateColumns: template,