@duro-app/ui 0.45.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.
- package/dist/components/Table/FromTanstack.d.ts +1 -4
- package/dist/components/Table/FromTanstack.d.ts.map +1 -1
- package/dist/components/Table/Table.d.ts +2 -10
- package/dist/components/Table/Table.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts +1 -0
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Tag/Tag.d.ts +9 -1
- package/dist/components/Tag/Tag.d.ts.map +1 -1
- package/dist/components/Tag/Tag.stories.d.ts +1 -0
- package/dist/components/Tag/Tag.stories.d.ts.map +1 -1
- package/dist/components/Tag/styles.css.d.ts +7 -0
- package/dist/components/Tag/styles.css.d.ts.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.d.ts +12 -1
- package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.stories.d.ts +1 -0
- package/dist/components/VirtualTable/VirtualTable.stories.d.ts.map +1 -1
- package/dist/components/VirtualTable/styles.css.d.ts +40 -0
- package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +2189 -2090
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Table/FromTanstack.tsx +0 -5
- package/src/components/Table/Table.stories.tsx +61 -4
- package/src/components/Table/Table.tsx +67 -22
- package/src/components/Tag/Tag.stories.tsx +22 -0
- package/src/components/Tag/Tag.tsx +10 -0
- package/src/components/Tag/styles.css.ts +12 -0
- package/src/components/VirtualTable/VirtualTable.stories.tsx +29 -0
- package/src/components/VirtualTable/VirtualTable.tsx +152 -58
- 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.
|
|
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.
|
|
58
|
+
"@duro-app/tokens": "^0.46.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -22,9 +22,6 @@ interface FromTanstackBaseProps<TData> {
|
|
|
22
22
|
readonly variant?: TableVariant
|
|
23
23
|
readonly size?: TableSize
|
|
24
24
|
readonly responsive?: boolean
|
|
25
|
-
/** Min comfortable column width (px) before the table cards up. Forwarded
|
|
26
|
-
* to Table.Root — see its `minColumnWidth`. Default 128. */
|
|
27
|
-
readonly minColumnWidth?: number
|
|
28
25
|
/** Render a SortChip above the grid (stack-mode only). Options derive from
|
|
29
26
|
* columns where `column.getCanSort()` returns true. Wires value/onChange
|
|
30
27
|
* through the TanStack sorting state automatically. */
|
|
@@ -71,7 +68,6 @@ export function FromTanstack<TData>({
|
|
|
71
68
|
variant = 'default',
|
|
72
69
|
size = 'md',
|
|
73
70
|
responsive = true,
|
|
74
|
-
minColumnWidth,
|
|
75
71
|
sortChip,
|
|
76
72
|
pagination,
|
|
77
73
|
rowKey,
|
|
@@ -86,7 +82,6 @@ export function FromTanstack<TData>({
|
|
|
86
82
|
variant={variant}
|
|
87
83
|
size={size}
|
|
88
84
|
responsive={responsive}
|
|
89
|
-
minColumnWidth={minColumnWidth}
|
|
90
85
|
sortChip={sortChipNode}
|
|
91
86
|
pagination={paginationNode}
|
|
92
87
|
>
|
|
@@ -1142,10 +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
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
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
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},
|
|
1149
1153
|
})
|
|
1150
1154
|
|
|
1151
1155
|
const LONG_EMAIL = 'a-really-long-service-account.address@subdomain.example-company.com'
|
|
@@ -1212,7 +1216,9 @@ export const AutoStacksWhenCramped: Story = {
|
|
|
1212
1216
|
<Table.HeaderCell>Email</Table.HeaderCell>
|
|
1213
1217
|
<Table.HeaderCell>Status</Table.HeaderCell>
|
|
1214
1218
|
<Table.HeaderCell>Owner</Table.HeaderCell>
|
|
1215
|
-
|
|
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>
|
|
1216
1222
|
</Table.Row>
|
|
1217
1223
|
</Table.Header>
|
|
1218
1224
|
<Table.Body>
|
|
@@ -1258,3 +1264,54 @@ export const AutoStacksWhenCramped: Story = {
|
|
|
1258
1264
|
await expect(getComputedStyle(ownerLabel).display).toBe('block')
|
|
1259
1265
|
},
|
|
1260
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
|
+
}
|
|
@@ -49,6 +49,31 @@ interface TableContextValue {
|
|
|
49
49
|
// the @container CSS covers true mobile without JS.
|
|
50
50
|
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
|
|
51
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
|
+
}
|
|
76
|
+
|
|
52
77
|
const TableContext = createContext<TableContextValue | null>(null)
|
|
53
78
|
|
|
54
79
|
function useTable() {
|
|
@@ -188,16 +213,8 @@ interface RootProps {
|
|
|
188
213
|
children: ReactNode
|
|
189
214
|
variant?: TableVariant
|
|
190
215
|
size?: TableSize
|
|
191
|
-
/** Opt out of responsive container
|
|
216
|
+
/** Opt out of responsive behavior (auto-stacking + container query). Default true. */
|
|
192
217
|
responsive?: boolean
|
|
193
|
-
/**
|
|
194
|
-
* Minimum comfortable width (px) for a single column. When responsive,
|
|
195
|
-
* Root measures its container and switches to the stacked card layout as
|
|
196
|
-
* soon as `containerWidth < columnCount × minColumnWidth` — i.e. before
|
|
197
|
-
* cells get crushed, not only at the fixed mobile breakpoint. Raise it to
|
|
198
|
-
* card up sooner, lower it to keep the grid at tighter widths. Default 128.
|
|
199
|
-
*/
|
|
200
|
-
minColumnWidth?: number
|
|
201
218
|
/**
|
|
202
219
|
* Optional sort UI rendered above the grid. Visible only in stack mode
|
|
203
220
|
* (SortChip carries its own `display: none → inline-flex` rule). Typical
|
|
@@ -216,7 +233,6 @@ export function Root({
|
|
|
216
233
|
variant = 'default',
|
|
217
234
|
size = 'md',
|
|
218
235
|
responsive = true,
|
|
219
|
-
minColumnWidth = 128,
|
|
220
236
|
sortChip,
|
|
221
237
|
pagination,
|
|
222
238
|
}: RootProps) {
|
|
@@ -226,38 +242,67 @@ export function Root({
|
|
|
226
242
|
inferredTemplateRef.current = template
|
|
227
243
|
}
|
|
228
244
|
|
|
229
|
-
// Content-aware stacking
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
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.
|
|
235
257
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
258
|
+
const gridRef = useRef<HTMLDivElement>(null)
|
|
236
259
|
const [stacked, setStacked] = useState(false)
|
|
260
|
+
const stackedAtWidthRef = useRef(0)
|
|
237
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.
|
|
238
265
|
useIsoLayoutEffect(() => {
|
|
239
|
-
if (!responsive
|
|
266
|
+
if (!responsive) {
|
|
240
267
|
setStacked(false)
|
|
241
268
|
return
|
|
242
269
|
}
|
|
243
270
|
const el = containerRef.current
|
|
244
271
|
if (!el || typeof ResizeObserver === 'undefined') return
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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)
|
|
248
291
|
const ro = new ResizeObserver((entries) => {
|
|
249
292
|
const entry = entries[0]
|
|
250
293
|
if (!entry) return
|
|
251
294
|
const box = entry.contentBoxSize?.[0]
|
|
252
|
-
|
|
295
|
+
measure(box ? box.inlineSize : entry.contentRect.width)
|
|
253
296
|
})
|
|
254
297
|
ro.observe(el)
|
|
255
298
|
return () => ro.disconnect()
|
|
256
|
-
|
|
299
|
+
// labels.length re-runs detection when the column set changes.
|
|
300
|
+
}, [responsive, labels.length, stacked])
|
|
257
301
|
|
|
258
302
|
const grid = (
|
|
259
303
|
<html.div
|
|
260
304
|
role="table"
|
|
305
|
+
ref={gridRef}
|
|
261
306
|
style={[
|
|
262
307
|
styles.root,
|
|
263
308
|
// When responsive, use gridColumnsResponsive so the explicit column
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {useEffect, useRef, useState, type ReactNode} from 'react'
|
|
1
|
+
import {useEffect, useLayoutEffect, useRef, useState, type ReactNode} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {
|
|
4
4
|
useReactTable,
|
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
import {useVirtualizer} from '@tanstack/react-virtual'
|
|
14
14
|
import {styles} from './styles.css'
|
|
15
15
|
|
|
16
|
+
// Measure + commit before paint on the client; no-op-safe on the server.
|
|
17
|
+
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
|
|
18
|
+
|
|
16
19
|
export interface VirtualTableRange {
|
|
17
20
|
/** 1-based index of the first visible row (0 when empty). */
|
|
18
21
|
from: number
|
|
@@ -56,6 +59,17 @@ interface VirtualTableProps<TData> {
|
|
|
56
59
|
maxHeight?: number | string
|
|
57
60
|
/** Render every row (no windowing) at or below this count. Default 150. */
|
|
58
61
|
virtualizeThreshold?: number
|
|
62
|
+
/**
|
|
63
|
+
* 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.
|
|
69
|
+
*/
|
|
70
|
+
stackBelow?: number
|
|
71
|
+
/** Opt out of the card-up-on-narrow behavior. Default true. */
|
|
72
|
+
responsive?: boolean
|
|
59
73
|
emptyLabel?: ReactNode
|
|
60
74
|
}
|
|
61
75
|
|
|
@@ -84,6 +98,8 @@ export function VirtualTable<TData>({
|
|
|
84
98
|
estimateRowHeight = 44,
|
|
85
99
|
maxHeight = '70vh',
|
|
86
100
|
virtualizeThreshold = 150,
|
|
101
|
+
stackBelow = 640,
|
|
102
|
+
responsive = true,
|
|
87
103
|
emptyLabel,
|
|
88
104
|
}: VirtualTableProps<TData>) {
|
|
89
105
|
// Support both controlled sorting (SortChip in the caller) and standalone use.
|
|
@@ -102,7 +118,35 @@ export function VirtualTable<TData>({
|
|
|
102
118
|
})
|
|
103
119
|
|
|
104
120
|
const rows = table.getRowModel().rows
|
|
105
|
-
|
|
121
|
+
|
|
122
|
+
// Card-up on narrow containers. Flex cells truncate rather than overflow, so
|
|
123
|
+
// there's no content-overflow signal to measure (as in Table) — we use a
|
|
124
|
+
// deliberate phone-width breakpoint. Cards render every row (no windowing),
|
|
125
|
+
// so `virtualize` is forced off while stacked.
|
|
126
|
+
const wrapRef = useRef<HTMLDivElement>(null)
|
|
127
|
+
const [stacked, setStacked] = useState(false)
|
|
128
|
+
useIsoLayoutEffect(() => {
|
|
129
|
+
if (!responsive) {
|
|
130
|
+
setStacked(false)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
const el = wrapRef.current
|
|
134
|
+
if (!el || typeof ResizeObserver === 'undefined') return
|
|
135
|
+
const apply = (width: number) => {
|
|
136
|
+
if (width > 0) setStacked(width < stackBelow)
|
|
137
|
+
}
|
|
138
|
+
apply(el.getBoundingClientRect().width)
|
|
139
|
+
const ro = new ResizeObserver((entries) => {
|
|
140
|
+
const entry = entries[0]
|
|
141
|
+
if (!entry) return
|
|
142
|
+
const box = entry.contentBoxSize?.[0]
|
|
143
|
+
apply(box ? box.inlineSize : entry.contentRect.width)
|
|
144
|
+
})
|
|
145
|
+
ro.observe(el)
|
|
146
|
+
return () => ro.disconnect()
|
|
147
|
+
}, [responsive, stackBelow])
|
|
148
|
+
|
|
149
|
+
const virtualize = rows.length > virtualizeThreshold && !stacked
|
|
106
150
|
|
|
107
151
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
108
152
|
const rv = useVirtualizer({
|
|
@@ -174,82 +218,132 @@ export function VirtualTable<TData>({
|
|
|
174
218
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
175
219
|
}, [curPage, virtualize])
|
|
176
220
|
|
|
221
|
+
// Stack-mode label for a column: explicit meta override, else the string
|
|
222
|
+
// header, else nothing (value renders label-less, e.g. an actions column).
|
|
223
|
+
const columnStackLabel = (columnDef: {
|
|
224
|
+
header?: unknown
|
|
225
|
+
meta?: {stackLabel?: string; label?: string}
|
|
226
|
+
}): string => {
|
|
227
|
+
const meta = columnDef.meta
|
|
228
|
+
if (typeof meta?.stackLabel === 'string') return meta.stackLabel
|
|
229
|
+
if (typeof meta?.label === 'string') return meta.label
|
|
230
|
+
return typeof columnDef.header === 'string' ? columnDef.header : ''
|
|
231
|
+
}
|
|
232
|
+
|
|
177
233
|
const renderRow = (
|
|
178
234
|
row: Row<TData>,
|
|
179
235
|
index: number,
|
|
180
236
|
positioned?: ReturnType<typeof styles.rowAbsolute>,
|
|
181
237
|
measureRef?: (node: Element | null) => void,
|
|
182
|
-
) =>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
238
|
+
) =>
|
|
239
|
+
stacked ? (
|
|
240
|
+
<html.div
|
|
241
|
+
key={row.id}
|
|
242
|
+
role="row"
|
|
243
|
+
aria-label={rowLabel?.(row.original)}
|
|
244
|
+
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
|
|
245
|
+
style={[styles.stackRow, Boolean(onRowClick) && styles.rowClickable]}
|
|
246
|
+
>
|
|
247
|
+
{row.getVisibleCells().map((cell) => {
|
|
248
|
+
const lbl = columnStackLabel(cell.column.columnDef)
|
|
249
|
+
return (
|
|
250
|
+
<html.div key={cell.id} role="cell" style={styles.stackCell}>
|
|
251
|
+
{lbl ? <html.span style={styles.stackCellLabel}>{lbl}</html.span> : null}
|
|
252
|
+
<html.div style={styles.stackCellValue}>
|
|
253
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
254
|
+
</html.div>
|
|
255
|
+
</html.div>
|
|
256
|
+
)
|
|
257
|
+
})}
|
|
258
|
+
</html.div>
|
|
259
|
+
) : (
|
|
260
|
+
<html.div
|
|
261
|
+
key={row.id}
|
|
262
|
+
ref={measureRef}
|
|
263
|
+
data-index={index}
|
|
264
|
+
role="row"
|
|
265
|
+
aria-label={rowLabel?.(row.original)}
|
|
266
|
+
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
|
|
267
|
+
style={[styles.row, Boolean(onRowClick) && styles.rowClickable, positioned]}
|
|
268
|
+
>
|
|
269
|
+
{row.getVisibleCells().map((cell) => (
|
|
270
|
+
<html.div
|
|
271
|
+
key={cell.id}
|
|
272
|
+
role="cell"
|
|
273
|
+
style={[styles.cell, styles.cellFlex(cell.column.getSize())]}
|
|
274
|
+
>
|
|
275
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
276
|
+
</html.div>
|
|
277
|
+
))}
|
|
278
|
+
</html.div>
|
|
279
|
+
)
|
|
203
280
|
|
|
204
281
|
const label =
|
|
205
282
|
rangeLabel?.(range) ?? (rows.length ? `${range.from}–${range.to} / ${range.total}` : '')
|
|
206
283
|
|
|
207
284
|
return (
|
|
208
|
-
<html.div style={styles.wrap}>
|
|
285
|
+
<html.div ref={wrapRef} style={styles.wrap}>
|
|
209
286
|
<html.div
|
|
210
287
|
ref={scrollRef}
|
|
211
288
|
role="table"
|
|
212
|
-
style={[
|
|
289
|
+
style={[
|
|
290
|
+
stacked ? styles.stackScroll : styles.scroll,
|
|
291
|
+
virtualize && styles.scrollMax(maxHeight),
|
|
292
|
+
]}
|
|
213
293
|
>
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
{headerGroup.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
294
|
+
{stacked ? null : (
|
|
295
|
+
<html.div role="rowgroup" style={styles.head}>
|
|
296
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
297
|
+
<html.div key={headerGroup.id} role="row" style={styles.headRow}>
|
|
298
|
+
{headerGroup.headers.map((header) => {
|
|
299
|
+
const col = header.column
|
|
300
|
+
const sorted = col.getIsSorted()
|
|
301
|
+
return (
|
|
302
|
+
<html.div
|
|
303
|
+
key={header.id}
|
|
304
|
+
role="columnheader"
|
|
305
|
+
aria-sort={
|
|
306
|
+
sorted === 'asc'
|
|
307
|
+
? 'ascending'
|
|
308
|
+
: sorted === 'desc'
|
|
309
|
+
? 'descending'
|
|
310
|
+
: undefined
|
|
311
|
+
}
|
|
312
|
+
onClick={col.getCanSort() ? col.getToggleSortingHandler() : undefined}
|
|
313
|
+
style={[
|
|
314
|
+
styles.headCell,
|
|
315
|
+
col.getCanSort() && styles.headCellSortable,
|
|
316
|
+
styles.cellFlex(header.getSize()),
|
|
317
|
+
]}
|
|
318
|
+
>
|
|
319
|
+
{header.isPlaceholder
|
|
320
|
+
? null
|
|
321
|
+
: flexRender(col.columnDef.header, header.getContext())}
|
|
322
|
+
{sorted ? (
|
|
323
|
+
<html.span style={styles.sortMark}>
|
|
324
|
+
{sorted === 'asc' ? '↑' : '↓'}
|
|
325
|
+
</html.span>
|
|
326
|
+
) : null}
|
|
327
|
+
</html.div>
|
|
328
|
+
)
|
|
329
|
+
})}
|
|
330
|
+
</html.div>
|
|
331
|
+
))}
|
|
332
|
+
</html.div>
|
|
333
|
+
)}
|
|
246
334
|
|
|
247
335
|
{rows.length === 0 ? (
|
|
248
336
|
<html.div style={styles.empty}>{emptyLabel}</html.div>
|
|
249
337
|
) : (
|
|
250
338
|
<html.div
|
|
251
339
|
role="rowgroup"
|
|
252
|
-
style={
|
|
340
|
+
style={
|
|
341
|
+
stacked
|
|
342
|
+
? styles.stackBody
|
|
343
|
+
: virtualize
|
|
344
|
+
? styles.bodyTotal(rv.getTotalSize())
|
|
345
|
+
: undefined
|
|
346
|
+
}
|
|
253
347
|
>
|
|
254
348
|
{virtualize
|
|
255
349
|
? vItems.map((vi) =>
|