@duro-app/ui 0.46.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.
@@ -3,12 +3,8 @@ import {
3
3
  type ReactNode,
4
4
  type MutableRefObject,
5
5
  createContext,
6
- cloneElement,
7
6
  useContext,
8
- useEffect,
9
- useLayoutEffect,
10
7
  useRef,
11
- useState,
12
8
  Children,
13
9
  isValidElement,
14
10
  } from 'react'
@@ -35,43 +31,6 @@ interface TableContextValue {
35
31
  labels: ReadonlyArray<string>
36
32
  /** Mutable ref: header Row writes inferred template, Root reads it */
37
33
  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
34
  }
76
35
 
77
36
  const TableContext = createContext<TableContextValue | null>(null)
@@ -148,7 +107,10 @@ function unwrapElementType(t: unknown): unknown {
148
107
  return current
149
108
  }
150
109
 
151
- function extractColumnMeta(children: ReactNode): {
110
+ function extractColumnMeta(
111
+ children: ReactNode,
112
+ minColumnWidth: number,
113
+ ): {
152
114
  template: string
153
115
  compactTemplate: string
154
116
  labels: string[]
@@ -167,14 +129,14 @@ function extractColumnMeta(children: ReactNode): {
167
129
  (unwrappedType as {name?: string} | null)?.name ||
168
130
  ''
169
131
  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)'
132
+ // Default each column to `minmax(<minColumnWidth>px, 1fr)`: columns
133
+ // share the row evenly (1fr) but never shrink below a readable floor.
134
+ // That floor is what makes a dense table overflow its container
135
+ // instead of crushing — Root's scroll wrapper then scrolls sideways.
136
+ // Narrow columns (a checkbox) or naturally-wide ones (an actions
137
+ // cell) override this with an explicit `width` (e.g. '40px',
138
+ // 'max-content').
139
+ const width = props.width || `minmax(${minColumnWidth}px, 1fr)`
178
140
  widths.push(width)
179
141
  // `compactWidth` lets the consumer switch to a content-aware
180
142
  // layout in the compact band while staying evenly distributed
@@ -213,8 +175,16 @@ interface RootProps {
213
175
  children: ReactNode
214
176
  variant?: TableVariant
215
177
  size?: TableSize
216
- /** Opt out of responsive behavior (auto-stacking + container query). Default true. */
178
+ /** Opt out of responsive behavior (horizontal scroll + card breakpoint). Default true. */
217
179
  responsive?: boolean
180
+ /**
181
+ * Minimum width (px) each flexible column keeps before the table scrolls
182
+ * horizontally rather than crushing its cells. Columns still share the row
183
+ * evenly (`minmax(<minColumnWidth>px, 1fr)`); this is only the floor. Raise
184
+ * it for roomier columns (more scrolling), lower it to fit more before
185
+ * scrolling. Columns with an explicit `width` are unaffected. Default 120.
186
+ */
187
+ minColumnWidth?: number
218
188
  /**
219
189
  * Optional sort UI rendered above the grid. Visible only in stack mode
220
190
  * (SortChip carries its own `display: none → inline-flex` rule). Typical
@@ -233,76 +203,19 @@ export function Root({
233
203
  variant = 'default',
234
204
  size = 'md',
235
205
  responsive = true,
206
+ minColumnWidth = 120,
236
207
  sortChip,
237
208
  pagination,
238
209
  }: RootProps) {
239
210
  const inferredTemplateRef = useRef<string | null>(null)
240
- const {template, compactTemplate, labels} = extractColumnMeta(children)
211
+ const {template, compactTemplate, labels} = extractColumnMeta(children, minColumnWidth)
241
212
  if (template) {
242
213
  inferredTemplateRef.current = template
243
214
  }
244
215
 
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
216
  const grid = (
303
217
  <html.div
304
218
  role="table"
305
- ref={gridRef}
306
219
  style={[
307
220
  styles.root,
308
221
  // When responsive, use gridColumnsResponsive so the explicit column
@@ -317,45 +230,33 @@ export function Root({
317
230
  : styles.gridColumns(template)
318
231
  : undefined,
319
232
  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
233
  ]}
324
234
  >
325
235
  {children}
326
236
  </html.div>
327
237
  )
328
238
 
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
239
+ // Above the stack breakpoint the grid keeps each column ≥ minColumnWidth, so
240
+ // a dense table overflows this wrapper and scrolls sideways instead of
241
+ // crushing. Below it (@container ≤ sm) the grid is one column and there's
242
+ // nothing to scroll. Non-responsive tables opt out of the frame entirely.
243
+ const framedGrid = responsive ? <html.div style={styles.scrollX}>{grid}</html.div> : grid
336
244
 
337
245
  // Slots render unconditionally — `responsive=false` still wants its sort/
338
246
  // pagination chrome. Only the containerType:inline-size wrapper is
339
- // conditional, since it only matters when @container queries fire.
247
+ // conditional, since it only matters when @container queries fire. SortChip
248
+ // reveals itself in card mode via its own @container rule — no JS needed.
340
249
  const body = (
341
250
  <>
342
- {sortChipEl}
343
- {grid}
251
+ {sortChip}
252
+ {framedGrid}
344
253
  {pagination}
345
254
  </>
346
255
  )
347
256
 
348
257
  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
- )}
258
+ <TableContext.Provider value={{variant, size, responsive, labels, inferredTemplateRef}}>
259
+ {responsive ? <html.div style={styles.rootContainer}>{body}</html.div> : body}
359
260
  </TableContext.Provider>
360
261
  )
361
262
  }
@@ -363,10 +264,9 @@ export function Root({
363
264
  // --- Header ---
364
265
 
365
266
  export function Header({children}: {children: ReactNode}) {
366
- const {stacked} = useTable()
367
267
  return (
368
268
  <HeaderContext.Provider value={true}>
369
- <html.div role="rowgroup" style={[styles.header, stacked && styles.headerStacked]}>
269
+ <html.div role="rowgroup" style={styles.header}>
370
270
  {children}
371
271
  </html.div>
372
272
  </HeaderContext.Provider>
@@ -430,7 +330,7 @@ const INTERACTIVE_SELECTOR =
430
330
  'button, a, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="switch"], [role="tab"], [contenteditable="true"]'
431
331
 
432
332
  export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
433
- const {variant, stacked} = useTable()
333
+ const {variant} = useTable()
434
334
  const isHeader = useContext(HeaderContext)
435
335
  const rowIndex = useContext(RowIndexContext)
436
336
  const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
@@ -472,9 +372,6 @@ export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
472
372
  !isHeader && styles.bodyRow,
473
373
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
474
374
  isClickable && styles.clickableRow,
475
- stacked && styles.rowStacked,
476
- // bodyRowStacked last so its card background wins over stripedEven.
477
- !isHeader && stacked && styles.bodyRowStacked,
478
375
  ]}
479
376
  >
480
377
  {childArray.map((child, index) => (
@@ -563,7 +460,7 @@ HeaderCell.displayName = 'HeaderCell'
563
460
  // isActions=true — actions footers don't show a label.
564
461
 
565
462
  export function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
566
- const {size, variant, labels, responsive, stacked} = useTable()
463
+ const {size, variant, labels, responsive} = useTable()
567
464
  const {index, total} = useContext(CellIndexContext)
568
465
  const isLast = variant === 'bordered' && index === total - 1
569
466
  const label = labels[index] ?? ''
@@ -577,20 +474,10 @@ export function Cell({children, isActions}: {children: ReactNode; isActions?: bo
577
474
  variant === 'bordered' && styles.borderedCell,
578
475
  isLast && styles.borderedCellLast,
579
476
  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
477
  ]}
589
478
  >
590
479
  {responsive && !isActions && label !== '' ? (
591
- <html.span style={[styles.cellLabel, stacked && styles.cellLabelStacked]}>
592
- {label}
593
- </html.span>
480
+ <html.span style={styles.cellLabel}>{label}</html.span>
594
481
  ) : null}
595
482
  {isActions ? children : <html.div style={styles.cellValue}>{children}</html.div>}
596
483
  </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,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
  }),
@@ -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>) {