@duro-app/ui 0.44.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 (34) hide show
  1. package/dist/components/Table/SortChip.d.ts +8 -1
  2. package/dist/components/Table/SortChip.d.ts.map +1 -1
  3. package/dist/components/Table/Table.d.ts +1 -1
  4. package/dist/components/Table/Table.d.ts.map +1 -1
  5. package/dist/components/Table/Table.stories.d.ts +2 -0
  6. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  7. package/dist/components/Table/styles.css.d.ts +41 -0
  8. package/dist/components/Table/styles.css.d.ts.map +1 -1
  9. package/dist/components/Tag/Tag.d.ts +9 -1
  10. package/dist/components/Tag/Tag.d.ts.map +1 -1
  11. package/dist/components/Tag/Tag.stories.d.ts +1 -0
  12. package/dist/components/Tag/Tag.stories.d.ts.map +1 -1
  13. package/dist/components/Tag/styles.css.d.ts +7 -0
  14. package/dist/components/Tag/styles.css.d.ts.map +1 -1
  15. package/dist/components/VirtualTable/VirtualTable.d.ts +12 -1
  16. package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -1
  17. package/dist/components/VirtualTable/VirtualTable.stories.d.ts +1 -0
  18. package/dist/components/VirtualTable/VirtualTable.stories.d.ts.map +1 -1
  19. package/dist/components/VirtualTable/styles.css.d.ts +40 -0
  20. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  21. package/dist/index.css +1 -1
  22. package/dist/index.js +2793 -2607
  23. package/dist/index.js.map +1 -1
  24. package/package.json +2 -2
  25. package/src/components/Table/SortChip.tsx +15 -2
  26. package/src/components/Table/Table.stories.tsx +125 -1
  27. package/src/components/Table/Table.tsx +140 -8
  28. package/src/components/Table/styles.css.ts +57 -0
  29. package/src/components/Tag/Tag.stories.tsx +22 -0
  30. package/src/components/Tag/Tag.tsx +10 -0
  31. package/src/components/Tag/styles.css.ts +12 -0
  32. package/src/components/VirtualTable/VirtualTable.stories.tsx +29 -0
  33. package/src/components/VirtualTable/VirtualTable.tsx +152 -58
  34. 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.44.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.44.0"
58
+ "@duro-app/tokens": "^0.46.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@babel/preset-typescript": "^7.28.0",
@@ -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,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, 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
+ 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},
1145
1153
  })
1146
1154
 
1147
1155
  const LONG_EMAIL = 'a-really-long-service-account.address@subdomain.example-company.com'
@@ -1191,3 +1199,119 @@ export const ManyColumnsWrapNoOverlap: Story = {
1191
1199
  await expect(emailCell.scrollWidth).toBeLessThanOrEqual(emailCell.clientWidth + 2)
1192
1200
  },
1193
1201
  }
1202
+
1203
+ // A dense table wider than the 440px mobile breakpoint but too narrow to give
1204
+ // each column a comfortable width. The @container query never fires (560 >
1205
+ // 440), so this asserts the JS ResizeObserver force-stacks it into cards:
1206
+ // 6 columns × 128px default = 768px threshold > 560px container.
1207
+ export const AutoStacksWhenCramped: Story = {
1208
+ name: 'Auto-stacks when columns get cramped',
1209
+ render: () => (
1210
+ <html.div style={overlapWrapStyles.cramped}>
1211
+ <Table.Root>
1212
+ <Table.Header>
1213
+ <Table.Row>
1214
+ <Table.HeaderCell>Display Name</Table.HeaderCell>
1215
+ <Table.HeaderCell>Type</Table.HeaderCell>
1216
+ <Table.HeaderCell>Email</Table.HeaderCell>
1217
+ <Table.HeaderCell>Status</Table.HeaderCell>
1218
+ <Table.HeaderCell>Owner</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>
1222
+ </Table.Row>
1223
+ </Table.Header>
1224
+ <Table.Body>
1225
+ <Table.Row>
1226
+ <Table.Cell>SMTP submission service account</Table.Cell>
1227
+ <Table.Cell>
1228
+ <Badge>User</Badge>
1229
+ </Table.Cell>
1230
+ <Table.Cell>{LONG_EMAIL}</Table.Cell>
1231
+ <Table.Cell>
1232
+ <Badge variant="warning">No certs</Badge>
1233
+ </Table.Cell>
1234
+ <Table.Cell>daddy</Table.Cell>
1235
+ <Table.Cell isActions>
1236
+ <Button size="small">Send Cert</Button>
1237
+ <Button size="small" variant="secondary">
1238
+ Revoke
1239
+ </Button>
1240
+ </Table.Cell>
1241
+ </Table.Row>
1242
+ </Table.Body>
1243
+ </Table.Root>
1244
+ </html.div>
1245
+ ),
1246
+ play: async ({canvas}) => {
1247
+ const grid = canvas.getByRole('table')
1248
+ // ResizeObserver fires post-layout; wait for the stack decision to commit.
1249
+ await waitFor(async () => {
1250
+ // The grid collapses to a single column — one resolved track, no spaces.
1251
+ const tracks = getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/)
1252
+ await expect(tracks.length).toBe(1)
1253
+ })
1254
+ // Header group is display:none, so it drops out of the accessibility tree:
1255
+ // no column headers remain queryable, leaving only the body rowgroup.
1256
+ await expect(canvas.queryAllByRole('columnheader')).toHaveLength(0)
1257
+ await expect(canvas.getAllByRole('rowgroup')).toHaveLength(1)
1258
+ // Each body cell now surfaces its label span (label|value card rows). The
1259
+ // hidden header 'Owner' stays in the DOM, so pick the visible <span>.
1260
+ const ownerLabel = canvas
1261
+ .getAllByText('Owner')
1262
+ .find((el) => el.tagName.toLowerCase() === 'span') as HTMLElement
1263
+ await expect(ownerLabel).toBeTruthy()
1264
+ await expect(getComputedStyle(ownerLabel).display).toBe('block')
1265
+ },
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
+ }
@@ -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,6 +35,43 @@ 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
43
+ }
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
+
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
34
75
  }
35
76
 
36
77
  const TableContext = createContext<TableContextValue | null>(null)
@@ -172,7 +213,7 @@ interface RootProps {
172
213
  children: ReactNode
173
214
  variant?: TableVariant
174
215
  size?: TableSize
175
- /** Opt out of responsive container-query behavior. Default true. */
216
+ /** Opt out of responsive behavior (auto-stacking + container query). Default true. */
176
217
  responsive?: boolean
177
218
  /**
178
219
  * Optional sort UI rendered above the grid. Visible only in stack mode
@@ -201,9 +242,67 @@ export function Root({
201
242
  inferredTemplateRef.current = template
202
243
  }
203
244
 
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.
257
+ const containerRef = useRef<HTMLDivElement>(null)
258
+ const gridRef = useRef<HTMLDivElement>(null)
259
+ const [stacked, setStacked] = useState(false)
260
+ const stackedAtWidthRef = useRef(0)
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.
265
+ useIsoLayoutEffect(() => {
266
+ if (!responsive) {
267
+ setStacked(false)
268
+ return
269
+ }
270
+ const el = containerRef.current
271
+ if (!el || typeof ResizeObserver === 'undefined') return
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)
291
+ const ro = new ResizeObserver((entries) => {
292
+ const entry = entries[0]
293
+ if (!entry) return
294
+ const box = entry.contentBoxSize?.[0]
295
+ measure(box ? box.inlineSize : entry.contentRect.width)
296
+ })
297
+ ro.observe(el)
298
+ return () => ro.disconnect()
299
+ // labels.length re-runs detection when the column set changes.
300
+ }, [responsive, labels.length, stacked])
301
+
204
302
  const grid = (
205
303
  <html.div
206
304
  role="table"
305
+ ref={gridRef}
207
306
  style={[
208
307
  styles.root,
209
308
  // When responsive, use gridColumnsResponsive so the explicit column
@@ -218,26 +317,45 @@ export function Root({
218
317
  : styles.gridColumns(template)
219
318
  : undefined,
220
319
  responsive && styles.rootResponsive,
320
+ // Force-stack override — collapses the grid to one column. Applied
321
+ // last so its gridTemplateColumns wins over gridColumnsResponsive.
322
+ responsive && stacked && styles.rootStacked,
221
323
  ]}
222
324
  >
223
325
  {children}
224
326
  </html.div>
225
327
  )
226
328
 
329
+ // The sort chip is normally revealed only by the @container query. When
330
+ // JS force-stacks (headers hidden, but container wider than STACK_BP) the
331
+ // chip's own query hasn't fired, so inject forceShow to reveal it.
332
+ const sortChipEl =
333
+ stacked && isValidElement(sortChip)
334
+ ? cloneElement(sortChip as ReactElement<{forceShow?: boolean}>, {forceShow: true})
335
+ : sortChip
336
+
227
337
  // Slots render unconditionally — `responsive=false` still wants its sort/
228
338
  // pagination chrome. Only the containerType:inline-size wrapper is
229
339
  // conditional, since it only matters when @container queries fire.
230
340
  const body = (
231
341
  <>
232
- {sortChip}
342
+ {sortChipEl}
233
343
  {grid}
234
344
  {pagination}
235
345
  </>
236
346
  )
237
347
 
238
348
  return (
239
- <TableContext.Provider value={{variant, size, responsive, labels, inferredTemplateRef}}>
240
- {responsive ? <html.div style={styles.rootContainer}>{body}</html.div> : body}
349
+ <TableContext.Provider
350
+ value={{variant, size, responsive, labels, inferredTemplateRef, stacked}}
351
+ >
352
+ {responsive ? (
353
+ <html.div ref={containerRef} style={styles.rootContainer}>
354
+ {body}
355
+ </html.div>
356
+ ) : (
357
+ body
358
+ )}
241
359
  </TableContext.Provider>
242
360
  )
243
361
  }
@@ -245,9 +363,10 @@ export function Root({
245
363
  // --- Header ---
246
364
 
247
365
  export function Header({children}: {children: ReactNode}) {
366
+ const {stacked} = useTable()
248
367
  return (
249
368
  <HeaderContext.Provider value={true}>
250
- <html.div role="rowgroup" style={styles.header}>
369
+ <html.div role="rowgroup" style={[styles.header, stacked && styles.headerStacked]}>
251
370
  {children}
252
371
  </html.div>
253
372
  </HeaderContext.Provider>
@@ -311,7 +430,7 @@ const INTERACTIVE_SELECTOR =
311
430
  'button, a, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="switch"], [role="tab"], [contenteditable="true"]'
312
431
 
313
432
  export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
314
- const {variant} = useTable()
433
+ const {variant, stacked} = useTable()
315
434
  const isHeader = useContext(HeaderContext)
316
435
  const rowIndex = useContext(RowIndexContext)
317
436
  const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
@@ -353,6 +472,9 @@ export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
353
472
  !isHeader && styles.bodyRow,
354
473
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
355
474
  isClickable && styles.clickableRow,
475
+ stacked && styles.rowStacked,
476
+ // bodyRowStacked last so its card background wins over stripedEven.
477
+ !isHeader && stacked && styles.bodyRowStacked,
356
478
  ]}
357
479
  >
358
480
  {childArray.map((child, index) => (
@@ -441,7 +563,7 @@ HeaderCell.displayName = 'HeaderCell'
441
563
  // isActions=true — actions footers don't show a label.
442
564
 
443
565
  export function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
444
- const {size, variant, labels, responsive} = useTable()
566
+ const {size, variant, labels, responsive, stacked} = useTable()
445
567
  const {index, total} = useContext(CellIndexContext)
446
568
  const isLast = variant === 'bordered' && index === total - 1
447
569
  const label = labels[index] ?? ''
@@ -455,10 +577,20 @@ export function Cell({children, isActions}: {children: ReactNode; isActions?: bo
455
577
  variant === 'bordered' && styles.borderedCell,
456
578
  isLast && styles.borderedCellLast,
457
579
  isActions && styles.cellActions,
580
+ // Force-stack: cells become a label|value grid; the actions cell then
581
+ // overrides its template to a single track and turns into the card's
582
+ // right-aligned footer. Mirrors the @container (STACK_BP) branch, so
583
+ // cellStacked applies for every cell and cellActionsStacked layers on
584
+ // top for actions (its gridTemplateColumns wins as the later entry).
585
+ stacked && styles.cellStacked,
586
+ isActions && stacked && styles.cellActionsStacked,
587
+ variant === 'bordered' && stacked && styles.borderedCellStacked,
458
588
  ]}
459
589
  >
460
590
  {responsive && !isActions && label !== '' ? (
461
- <html.span style={styles.cellLabel}>{label}</html.span>
591
+ <html.span style={[styles.cellLabel, stacked && styles.cellLabelStacked]}>
592
+ {label}
593
+ </html.span>
462
594
  ) : null}
463
595
  {isActions ? children : <html.div style={styles.cellValue}>{children}</html.div>}
464
596
  </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,
@@ -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
+ }