@duro-app/ui 0.46.0 → 0.48.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.
@@ -3,10 +3,8 @@ import {
3
3
  type ReactNode,
4
4
  type MutableRefObject,
5
5
  createContext,
6
- cloneElement,
7
6
  useContext,
8
7
  useEffect,
9
- useLayoutEffect,
10
8
  useRef,
11
9
  useState,
12
10
  Children,
@@ -35,43 +33,6 @@ interface TableContextValue {
35
33
  labels: ReadonlyArray<string>
36
34
  /** Mutable ref: header Row writes inferred template, Root reads it */
37
35
  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
75
36
  }
76
37
 
77
38
  const TableContext = createContext<TableContextValue | null>(null)
@@ -148,7 +109,10 @@ function unwrapElementType(t: unknown): unknown {
148
109
  return current
149
110
  }
150
111
 
151
- function extractColumnMeta(children: ReactNode): {
112
+ function extractColumnMeta(
113
+ children: ReactNode,
114
+ minColumnWidth: number,
115
+ ): {
152
116
  template: string
153
117
  compactTemplate: string
154
118
  labels: string[]
@@ -167,14 +131,14 @@ function extractColumnMeta(children: ReactNode): {
167
131
  (unwrappedType as {name?: string} | null)?.name ||
168
132
  ''
169
133
  if (unwrappedType === HeaderCell || displayName === 'HeaderCell') {
170
- // Default to minmax(0, 1fr) instead of plain '1fr' so each column
171
- // can shrink below its content's min-content size. A plain '1fr'
172
- // track has an implicit min of `auto` (= min-content), which makes
173
- // the column refuse to shrink and the whole table overflow when a
174
- // cell's text is wider than 1/N of the container. With minmax(0,
175
- // 1fr), the cell's compact-mode `min-width: 0` + `overflow:
176
- // hidden` + `text-overflow: ellipsis` can actually truncate.
177
- const width = props.width || 'minmax(0, 1fr)'
134
+ // Default each column to `minmax(<minColumnWidth>px, 1fr)`: columns
135
+ // share the row evenly (1fr) but never shrink below a readable floor.
136
+ // That floor is what makes a dense table overflow its container
137
+ // instead of crushing — Root's scroll wrapper then scrolls sideways.
138
+ // Narrow columns (a checkbox) or naturally-wide ones (an actions
139
+ // cell) override this with an explicit `width` (e.g. '40px',
140
+ // 'max-content').
141
+ const width = props.width || `minmax(${minColumnWidth}px, 1fr)`
178
142
  widths.push(width)
179
143
  // `compactWidth` lets the consumer switch to a content-aware
180
144
  // layout in the compact band while staying evenly distributed
@@ -207,14 +171,71 @@ function extractColumnMeta(children: ReactNode): {
207
171
  }
208
172
  }
209
173
 
174
+ // --- ScrollFrame ---
175
+ //
176
+ // Wraps the grid in the horizontal-scroll port and paints a soft fade at
177
+ // whichever edge still has hidden columns, so a scrollable table advertises
178
+ // that there's more to see (and stops advertising once you reach the end).
179
+ // This is the ONLY JS the responsive Table uses, and it's purely cosmetic —
180
+ // it reads scroll position and toggles fade opacity; it never changes layout,
181
+ // so none of the flicker/oscillation risk of the old auto-stacking applies.
182
+ function ScrollFrame({children}: {children: ReactNode}) {
183
+ const scrollRef = useRef<HTMLDivElement>(null)
184
+ const [edges, setEdges] = useState({start: false, end: false})
185
+
186
+ useEffect(() => {
187
+ const el = scrollRef.current
188
+ if (!el) return
189
+ const update = () => {
190
+ const overflowing = el.scrollWidth - el.clientWidth > 1
191
+ setEdges({
192
+ start: overflowing && el.scrollLeft > 1,
193
+ end: overflowing && el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
194
+ })
195
+ }
196
+ update()
197
+ el.addEventListener('scroll', update, {passive: true})
198
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null
199
+ ro?.observe(el)
200
+ return () => {
201
+ el.removeEventListener('scroll', update)
202
+ ro?.disconnect()
203
+ }
204
+ }, [])
205
+
206
+ return (
207
+ <html.div style={styles.scrollFrame}>
208
+ <html.div ref={scrollRef} style={styles.scrollX}>
209
+ {children}
210
+ </html.div>
211
+ <html.div
212
+ aria-hidden
213
+ style={[styles.edgeFade, styles.edgeFadeLeft, edges.start && styles.edgeFadeVisible]}
214
+ />
215
+ <html.div
216
+ aria-hidden
217
+ style={[styles.edgeFade, styles.edgeFadeRight, edges.end && styles.edgeFadeVisible]}
218
+ />
219
+ </html.div>
220
+ )
221
+ }
222
+
210
223
  // --- Root ---
211
224
 
212
225
  interface RootProps {
213
226
  children: ReactNode
214
227
  variant?: TableVariant
215
228
  size?: TableSize
216
- /** Opt out of responsive behavior (auto-stacking + container query). Default true. */
229
+ /** Opt out of responsive behavior (horizontal scroll + card breakpoint). Default true. */
217
230
  responsive?: boolean
231
+ /**
232
+ * Minimum width (px) each flexible column keeps before the table scrolls
233
+ * horizontally rather than crushing its cells. Columns still share the row
234
+ * evenly (`minmax(<minColumnWidth>px, 1fr)`); this is only the floor. Raise
235
+ * it for roomier columns (more scrolling), lower it to fit more before
236
+ * scrolling. Columns with an explicit `width` are unaffected. Default 120.
237
+ */
238
+ minColumnWidth?: number
218
239
  /**
219
240
  * Optional sort UI rendered above the grid. Visible only in stack mode
220
241
  * (SortChip carries its own `display: none → inline-flex` rule). Typical
@@ -233,76 +254,19 @@ export function Root({
233
254
  variant = 'default',
234
255
  size = 'md',
235
256
  responsive = true,
257
+ minColumnWidth = 120,
236
258
  sortChip,
237
259
  pagination,
238
260
  }: RootProps) {
239
261
  const inferredTemplateRef = useRef<string | null>(null)
240
- const {template, compactTemplate, labels} = extractColumnMeta(children)
262
+ const {template, compactTemplate, labels} = extractColumnMeta(children, minColumnWidth)
241
263
  if (template) {
242
264
  inferredTemplateRef.current = template
243
265
  }
244
266
 
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
-
302
267
  const grid = (
303
268
  <html.div
304
269
  role="table"
305
- ref={gridRef}
306
270
  style={[
307
271
  styles.root,
308
272
  // When responsive, use gridColumnsResponsive so the explicit column
@@ -317,45 +281,33 @@ export function Root({
317
281
  : styles.gridColumns(template)
318
282
  : undefined,
319
283
  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,
323
284
  ]}
324
285
  >
325
286
  {children}
326
287
  </html.div>
327
288
  )
328
289
 
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
290
+ // Above the stack breakpoint the grid keeps each column ≥ minColumnWidth, so
291
+ // a dense table overflows this wrapper and scrolls sideways instead of
292
+ // crushing. Below it (@container ≤ sm) the grid is one column and there's
293
+ // nothing to scroll. Non-responsive tables opt out of the frame entirely.
294
+ const framedGrid = responsive ? <ScrollFrame>{grid}</ScrollFrame> : grid
336
295
 
337
296
  // Slots render unconditionally — `responsive=false` still wants its sort/
338
297
  // pagination chrome. Only the containerType:inline-size wrapper is
339
- // conditional, since it only matters when @container queries fire.
298
+ // conditional, since it only matters when @container queries fire. SortChip
299
+ // reveals itself in card mode via its own @container rule — no JS needed.
340
300
  const body = (
341
301
  <>
342
- {sortChipEl}
343
- {grid}
302
+ {sortChip}
303
+ {framedGrid}
344
304
  {pagination}
345
305
  </>
346
306
  )
347
307
 
348
308
  return (
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
- )}
309
+ <TableContext.Provider value={{variant, size, responsive, labels, inferredTemplateRef}}>
310
+ {responsive ? <html.div style={styles.rootContainer}>{body}</html.div> : body}
359
311
  </TableContext.Provider>
360
312
  )
361
313
  }
@@ -363,10 +315,9 @@ export function Root({
363
315
  // --- Header ---
364
316
 
365
317
  export function Header({children}: {children: ReactNode}) {
366
- const {stacked} = useTable()
367
318
  return (
368
319
  <HeaderContext.Provider value={true}>
369
- <html.div role="rowgroup" style={[styles.header, stacked && styles.headerStacked]}>
320
+ <html.div role="rowgroup" style={styles.header}>
370
321
  {children}
371
322
  </html.div>
372
323
  </HeaderContext.Provider>
@@ -430,7 +381,7 @@ const INTERACTIVE_SELECTOR =
430
381
  'button, a, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="switch"], [role="tab"], [contenteditable="true"]'
431
382
 
432
383
  export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
433
- const {variant, stacked} = useTable()
384
+ const {variant} = useTable()
434
385
  const isHeader = useContext(HeaderContext)
435
386
  const rowIndex = useContext(RowIndexContext)
436
387
  const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
@@ -472,9 +423,6 @@ export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
472
423
  !isHeader && styles.bodyRow,
473
424
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
474
425
  isClickable && styles.clickableRow,
475
- stacked && styles.rowStacked,
476
- // bodyRowStacked last so its card background wins over stripedEven.
477
- !isHeader && stacked && styles.bodyRowStacked,
478
426
  ]}
479
427
  >
480
428
  {childArray.map((child, index) => (
@@ -563,7 +511,7 @@ HeaderCell.displayName = 'HeaderCell'
563
511
  // isActions=true — actions footers don't show a label.
564
512
 
565
513
  export function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
566
- const {size, variant, labels, responsive, stacked} = useTable()
514
+ const {size, variant, labels, responsive} = useTable()
567
515
  const {index, total} = useContext(CellIndexContext)
568
516
  const isLast = variant === 'bordered' && index === total - 1
569
517
  const label = labels[index] ?? ''
@@ -577,20 +525,10 @@ export function Cell({children, isActions}: {children: ReactNode; isActions?: bo
577
525
  variant === 'bordered' && styles.borderedCell,
578
526
  isLast && styles.borderedCellLast,
579
527
  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,
588
528
  ]}
589
529
  >
590
530
  {responsive && !isActions && label !== '' ? (
591
- <html.span style={[styles.cellLabel, stacked && styles.cellLabelStacked]}>
592
- {label}
593
- </html.span>
531
+ <html.span style={styles.cellLabel}>{label}</html.span>
594
532
  ) : null}
595
533
  {isActions ? children : <html.div style={styles.cellValue}>{children}</html.div>}
596
534
  </html.div>
@@ -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,55 @@ 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,
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',
357
348
  },
358
- cellStacked: {
359
- display: 'grid',
360
- gridTemplateColumns: '1fr 2fr',
361
- gap: spacing.sm,
349
+
350
+ // Positioning context for the scroll-affordance edge fades, which sit on
351
+ // top of the scroll port (not inside it, so they don't scroll away).
352
+ scrollFrame: {
353
+ position: 'relative',
362
354
  },
363
- cellLabelStacked: {
364
- display: 'block',
355
+ // A soft fade pinned to a scroll edge that hints "there's more this way".
356
+ // It fades the content into the surface colour, so it reads in any theme.
357
+ // Toggled by opacity from JS scroll position; pointer-events off so it
358
+ // never eats clicks. Sits just inside the rounded frame border.
359
+ edgeFade: {
360
+ position: 'absolute',
361
+ top: 1,
362
+ bottom: 1,
363
+ width: spacing.xl,
364
+ pointerEvents: 'none',
365
+ opacity: 0,
366
+ transitionProperty: 'opacity',
367
+ transitionDuration: duration.fast,
368
+ transitionTimingFunction: easing.standard,
365
369
  },
366
- cellActionsStacked: {
367
- gridTemplateColumns: '1fr',
368
- justifyContent: 'flex-end',
369
- marginTop: spacing.sm,
370
- paddingTop: spacing.sm,
371
- borderTopWidth: 1,
370
+ edgeFadeLeft: {
371
+ left: 1,
372
+ borderTopLeftRadius: radii.md,
373
+ borderBottomLeftRadius: radii.md,
374
+ backgroundImage: `linear-gradient(to left, transparent, ${colors.bgCard})`,
372
375
  },
373
- borderedCellStacked: {
374
- borderRightWidth: 0,
376
+ edgeFadeRight: {
377
+ right: 1,
378
+ borderTopRightRadius: radii.md,
379
+ borderBottomRightRadius: radii.md,
380
+ backgroundImage: `linear-gradient(to right, transparent, ${colors.bgCard})`,
375
381
  },
376
- sortChipStacked: {
377
- display: 'inline-flex',
382
+ edgeFadeVisible: {
383
+ opacity: 1,
378
384
  },
379
385
 
380
386
  // Dynamic: grid columns applied on Root (non-responsive path only).
@@ -389,15 +395,18 @@ export const styles = css.create({
389
395
  // earlier classes (the LAST style wins per property key).
390
396
  //
391
397
  // - 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
398
+ // - compact band (STACK_BP < width ≤ COMPACT_BP): consumer-supplied
399
+ // "compact" template. This is written as an explicit RANGE, not just
400
+ // `max-width: COMPACT_BP`, so it does NOT also match below STACK_BP —
401
+ // otherwise both the compact and the stack rule would set
402
+ // gridTemplateColumns at card widths and StyleX's ordering could let the
403
+ // (wider, 4-column) compact template shadow the single-column stack one.
395
404
  // - @container (max-width: STACK_BP): collapse to a single column so
396
405
  // each row becomes a card
397
406
  gridColumnsResponsive: (template: string, compactTemplate: string) => ({
398
407
  gridTemplateColumns: {
399
408
  default: template,
400
- [`@container (max-width: ${COMPACT_BP})`]: compactTemplate,
409
+ [`@container (${STACK_BP} < width <= ${COMPACT_BP})`]: compactTemplate,
401
410
  [`@container (max-width: ${STACK_BP})`]: '1fr',
402
411
  },
403
412
  }),
@@ -11,6 +11,7 @@ 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
 
16
17
  // Measure + commit before paint on the client; no-op-safe on the server.
@@ -61,11 +62,10 @@ interface VirtualTableProps<TData> {
61
62
  virtualizeThreshold?: number
62
63
  /**
63
64
  * 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.
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
69
  */
70
70
  stackBelow?: number
71
71
  /** Opt out of the card-up-on-narrow behavior. Default true. */
@@ -98,7 +98,7 @@ export function VirtualTable<TData>({
98
98
  estimateRowHeight = 44,
99
99
  maxHeight = '70vh',
100
100
  virtualizeThreshold = 150,
101
- stackBelow = 640,
101
+ stackBelow = breakpointsPx.sm,
102
102
  responsive = true,
103
103
  emptyLabel,
104
104
  }: VirtualTableProps<TData>) {