@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.
- package/dist/components/Table/FromTanstack.d.ts +4 -1
- package/dist/components/Table/FromTanstack.d.ts.map +1 -1
- package/dist/components/Table/SortChip.d.ts +1 -8
- package/dist/components/Table/SortChip.d.ts.map +1 -1
- package/dist/components/Table/Table.d.ts +10 -2
- package/dist/components/Table/Table.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts +3 -3
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Table/styles.css.d.ts +31 -37
- package/dist/components/Table/styles.css.d.ts.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.d.ts +4 -5
- package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +1979 -1989
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Table/FromTanstack.tsx +5 -0
- package/src/components/Table/SortChip.tsx +2 -15
- package/src/components/Table/Table.stories.tsx +105 -132
- package/src/components/Table/Table.tsx +87 -149
- package/src/components/Table/styles.css.ts +69 -60
- package/src/components/VirtualTable/VirtualTable.tsx +6 -6
|
@@ -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(
|
|
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(
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
const width = props.width ||
|
|
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 (
|
|
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
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
{
|
|
343
|
-
{
|
|
302
|
+
{sortChip}
|
|
303
|
+
{framedGrid}
|
|
344
304
|
{pagination}
|
|
345
305
|
</>
|
|
346
306
|
)
|
|
347
307
|
|
|
348
308
|
return (
|
|
349
|
-
<TableContext.Provider
|
|
350
|
-
|
|
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={
|
|
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
|
|
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
|
|
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={
|
|
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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
const
|
|
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
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
// -
|
|
393
|
-
// template
|
|
394
|
-
//
|
|
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 (
|
|
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).
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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 =
|
|
101
|
+
stackBelow = breakpointsPx.sm,
|
|
102
102
|
responsive = true,
|
|
103
103
|
emptyLabel,
|
|
104
104
|
}: VirtualTableProps<TData>) {
|