@duro-app/ui 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Table/SortChip.d.ts +8 -1
- package/dist/components/Table/SortChip.d.ts.map +1 -1
- package/dist/components/Table/Table.d.ts +1 -1
- package/dist/components/Table/Table.d.ts.map +1 -1
- package/dist/components/Table/Table.stories.d.ts +2 -0
- package/dist/components/Table/Table.stories.d.ts.map +1 -1
- package/dist/components/Table/styles.css.d.ts +41 -0
- package/dist/components/Table/styles.css.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 +2793 -2607
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Table/SortChip.tsx +15 -2
- package/src/components/Table/Table.stories.tsx +125 -1
- package/src/components/Table/Table.tsx +140 -8
- package/src/components/Table/styles.css.ts +57 -0
- 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",
|
|
@@ -13,6 +13,13 @@ export interface SortChipProps {
|
|
|
13
13
|
readonly onChange: (next: SortValue | null) => void
|
|
14
14
|
/** Visible label preceding the sort field, e.g. "Sort by". */
|
|
15
15
|
readonly label?: string
|
|
16
|
+
/**
|
|
17
|
+
* Reveal the chip regardless of the container query. Injected by
|
|
18
|
+
* `Table.Root` when it force-stacks a cramped medium-width table (headers
|
|
19
|
+
* hidden but container wider than the mobile breakpoint, so the chip's own
|
|
20
|
+
* `@container` rule hasn't fired). Not part of the public API.
|
|
21
|
+
*/
|
|
22
|
+
readonly forceShow?: boolean
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
/**
|
|
@@ -24,12 +31,18 @@ export interface SortChipProps {
|
|
|
24
31
|
* couple to @tanstack/react-table — the consumer wires `value` and
|
|
25
32
|
* `onChange` from whatever sort state they manage.
|
|
26
33
|
*/
|
|
27
|
-
export function SortChip({
|
|
34
|
+
export function SortChip({
|
|
35
|
+
options,
|
|
36
|
+
value,
|
|
37
|
+
onChange,
|
|
38
|
+
label = 'Sort by',
|
|
39
|
+
forceShow = false,
|
|
40
|
+
}: SortChipProps) {
|
|
28
41
|
const current = value ? options.find((o) => o.id === value.id) : null
|
|
29
42
|
const triggerLabel = current ? `${label}: ${current.label} ${value!.desc ? '↓' : '↑'}` : label
|
|
30
43
|
|
|
31
44
|
return (
|
|
32
|
-
<html.div style={styles.sortChip}>
|
|
45
|
+
<html.div style={[styles.sortChip, forceShow && styles.sortChipStacked]}>
|
|
33
46
|
<Menu.Root>
|
|
34
47
|
<Menu.Trigger>{triggerLabel}</Menu.Trigger>
|
|
35
48
|
<Menu.Popup>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {expect} from 'storybook/test'
|
|
2
|
+
import {expect, waitFor} from 'storybook/test'
|
|
3
3
|
import {css, html} from 'react-strict-dom'
|
|
4
4
|
import {useState} from 'react'
|
|
5
5
|
import {Table} from './Table'
|
|
@@ -1142,6 +1142,14 @@ export const SlotPropsOnRoot: Story = {
|
|
|
1142
1142
|
// grid track into the next column. Cells now shrink to their track and wrap.
|
|
1143
1143
|
const overlapWrapStyles = css.create({
|
|
1144
1144
|
wide: {width: 760},
|
|
1145
|
+
// 6 columns in 560px, above the 440px mobile breakpoint, so the @container
|
|
1146
|
+
// query never fires — only the JS overflow check catches that the action
|
|
1147
|
+
// buttons no longer fit their squeezed track.
|
|
1148
|
+
cramped: {width: 560},
|
|
1149
|
+
// 8 short-text columns with room to breathe: nothing overflows, so a
|
|
1150
|
+
// wide "grants-style" table must stay tabular even though it has many
|
|
1151
|
+
// columns (regression guard against the old column-count heuristic).
|
|
1152
|
+
roomy: {width: 1120},
|
|
1145
1153
|
})
|
|
1146
1154
|
|
|
1147
1155
|
const LONG_EMAIL = 'a-really-long-service-account.address@subdomain.example-company.com'
|
|
@@ -1191,3 +1199,119 @@ export const ManyColumnsWrapNoOverlap: Story = {
|
|
|
1191
1199
|
await expect(emailCell.scrollWidth).toBeLessThanOrEqual(emailCell.clientWidth + 2)
|
|
1192
1200
|
},
|
|
1193
1201
|
}
|
|
1202
|
+
|
|
1203
|
+
// A dense table wider than the 440px mobile breakpoint but too narrow to give
|
|
1204
|
+
// each column a comfortable width. The @container query never fires (560 >
|
|
1205
|
+
// 440), so this asserts the JS ResizeObserver force-stacks it into cards:
|
|
1206
|
+
// 6 columns × 128px default = 768px threshold > 560px container.
|
|
1207
|
+
export const AutoStacksWhenCramped: Story = {
|
|
1208
|
+
name: 'Auto-stacks when columns get cramped',
|
|
1209
|
+
render: () => (
|
|
1210
|
+
<html.div style={overlapWrapStyles.cramped}>
|
|
1211
|
+
<Table.Root>
|
|
1212
|
+
<Table.Header>
|
|
1213
|
+
<Table.Row>
|
|
1214
|
+
<Table.HeaderCell>Display Name</Table.HeaderCell>
|
|
1215
|
+
<Table.HeaderCell>Type</Table.HeaderCell>
|
|
1216
|
+
<Table.HeaderCell>Email</Table.HeaderCell>
|
|
1217
|
+
<Table.HeaderCell>Status</Table.HeaderCell>
|
|
1218
|
+
<Table.HeaderCell>Owner</Table.HeaderCell>
|
|
1219
|
+
{/* Normal 1fr track (no max-content) so the buttons get squeezed
|
|
1220
|
+
and overflow — the exact condition the auto-stack detects. */}
|
|
1221
|
+
<Table.HeaderCell>Actions</Table.HeaderCell>
|
|
1222
|
+
</Table.Row>
|
|
1223
|
+
</Table.Header>
|
|
1224
|
+
<Table.Body>
|
|
1225
|
+
<Table.Row>
|
|
1226
|
+
<Table.Cell>SMTP submission service account</Table.Cell>
|
|
1227
|
+
<Table.Cell>
|
|
1228
|
+
<Badge>User</Badge>
|
|
1229
|
+
</Table.Cell>
|
|
1230
|
+
<Table.Cell>{LONG_EMAIL}</Table.Cell>
|
|
1231
|
+
<Table.Cell>
|
|
1232
|
+
<Badge variant="warning">No certs</Badge>
|
|
1233
|
+
</Table.Cell>
|
|
1234
|
+
<Table.Cell>daddy</Table.Cell>
|
|
1235
|
+
<Table.Cell isActions>
|
|
1236
|
+
<Button size="small">Send Cert</Button>
|
|
1237
|
+
<Button size="small" variant="secondary">
|
|
1238
|
+
Revoke
|
|
1239
|
+
</Button>
|
|
1240
|
+
</Table.Cell>
|
|
1241
|
+
</Table.Row>
|
|
1242
|
+
</Table.Body>
|
|
1243
|
+
</Table.Root>
|
|
1244
|
+
</html.div>
|
|
1245
|
+
),
|
|
1246
|
+
play: async ({canvas}) => {
|
|
1247
|
+
const grid = canvas.getByRole('table')
|
|
1248
|
+
// ResizeObserver fires post-layout; wait for the stack decision to commit.
|
|
1249
|
+
await waitFor(async () => {
|
|
1250
|
+
// The grid collapses to a single column — one resolved track, no spaces.
|
|
1251
|
+
const tracks = getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/)
|
|
1252
|
+
await expect(tracks.length).toBe(1)
|
|
1253
|
+
})
|
|
1254
|
+
// Header group is display:none, so it drops out of the accessibility tree:
|
|
1255
|
+
// no column headers remain queryable, leaving only the body rowgroup.
|
|
1256
|
+
await expect(canvas.queryAllByRole('columnheader')).toHaveLength(0)
|
|
1257
|
+
await expect(canvas.getAllByRole('rowgroup')).toHaveLength(1)
|
|
1258
|
+
// Each body cell now surfaces its label span (label|value card rows). The
|
|
1259
|
+
// hidden header 'Owner' stays in the DOM, so pick the visible <span>.
|
|
1260
|
+
const ownerLabel = canvas
|
|
1261
|
+
.getAllByText('Owner')
|
|
1262
|
+
.find((el) => el.tagName.toLowerCase() === 'span') as HTMLElement
|
|
1263
|
+
await expect(ownerLabel).toBeTruthy()
|
|
1264
|
+
await expect(getComputedStyle(ownerLabel).display).toBe('block')
|
|
1265
|
+
},
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Regression guard for the grants over-stacking bug: a table with MANY
|
|
1269
|
+
// columns (8) that all fit comfortably must stay tabular. The old heuristic
|
|
1270
|
+
// stacked purely on column count (8 × 128px = 1024px threshold), carding up a
|
|
1271
|
+
// table that visually fits on a laptop. Overflow-based detection keeps it
|
|
1272
|
+
// tabular because nothing actually overflows.
|
|
1273
|
+
export const ManyColumnsStayTabularWhenFitting: Story = {
|
|
1274
|
+
name: 'Many columns stay tabular when they fit',
|
|
1275
|
+
render: () => (
|
|
1276
|
+
<html.div style={overlapWrapStyles.roomy}>
|
|
1277
|
+
<Table.Root>
|
|
1278
|
+
<Table.Header>
|
|
1279
|
+
<Table.Row>
|
|
1280
|
+
<Table.HeaderCell>Principal</Table.HeaderCell>
|
|
1281
|
+
<Table.HeaderCell>Application</Table.HeaderCell>
|
|
1282
|
+
<Table.HeaderCell>Role</Table.HeaderCell>
|
|
1283
|
+
<Table.HeaderCell>Entitlement</Table.HeaderCell>
|
|
1284
|
+
<Table.HeaderCell>Resource</Table.HeaderCell>
|
|
1285
|
+
<Table.HeaderCell>Granted by</Table.HeaderCell>
|
|
1286
|
+
<Table.HeaderCell>Expires</Table.HeaderCell>
|
|
1287
|
+
<Table.HeaderCell>Status</Table.HeaderCell>
|
|
1288
|
+
</Table.Row>
|
|
1289
|
+
</Table.Header>
|
|
1290
|
+
<Table.Body>
|
|
1291
|
+
<Table.Row>
|
|
1292
|
+
<Table.Cell>alice</Table.Cell>
|
|
1293
|
+
<Table.Cell>Immich</Table.Cell>
|
|
1294
|
+
<Table.Cell>Editor</Table.Cell>
|
|
1295
|
+
<Table.Cell>library</Table.Cell>
|
|
1296
|
+
<Table.Cell>all</Table.Cell>
|
|
1297
|
+
<Table.Cell>daddy</Table.Cell>
|
|
1298
|
+
<Table.Cell>Never</Table.Cell>
|
|
1299
|
+
<Table.Cell>
|
|
1300
|
+
<Badge variant="success">Active</Badge>
|
|
1301
|
+
</Table.Cell>
|
|
1302
|
+
</Table.Row>
|
|
1303
|
+
</Table.Body>
|
|
1304
|
+
</Table.Root>
|
|
1305
|
+
</html.div>
|
|
1306
|
+
),
|
|
1307
|
+
play: async ({canvas}) => {
|
|
1308
|
+
const grid = canvas.getByRole('table')
|
|
1309
|
+
// Give the ResizeObserver a chance to (not) fire, then assert it stayed
|
|
1310
|
+
// tabular: 8 header cells still in the a11y tree, grid keeps 8 tracks.
|
|
1311
|
+
await waitFor(async () => {
|
|
1312
|
+
await expect(canvas.queryAllByRole('columnheader')).toHaveLength(8)
|
|
1313
|
+
})
|
|
1314
|
+
const tracks = getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/)
|
|
1315
|
+
await expect(tracks.length).toBe(8)
|
|
1316
|
+
},
|
|
1317
|
+
}
|
|
@@ -3,8 +3,12 @@ import {
|
|
|
3
3
|
type ReactNode,
|
|
4
4
|
type MutableRefObject,
|
|
5
5
|
createContext,
|
|
6
|
+
cloneElement,
|
|
6
7
|
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useLayoutEffect,
|
|
7
10
|
useRef,
|
|
11
|
+
useState,
|
|
8
12
|
Children,
|
|
9
13
|
isValidElement,
|
|
10
14
|
} from 'react'
|
|
@@ -31,6 +35,43 @@ interface TableContextValue {
|
|
|
31
35
|
labels: ReadonlyArray<string>
|
|
32
36
|
/** Mutable ref: header Row writes inferred template, Root reads it */
|
|
33
37
|
inferredTemplateRef: MutableRefObject<string | null>
|
|
38
|
+
/** JS-measured force-stack flag. True when Root's ResizeObserver finds the
|
|
39
|
+
* container too narrow for its column count (cells would be crushed), so
|
|
40
|
+
* every cell/row/header also applies its `*Stacked` variant. Independent
|
|
41
|
+
* of the @container base, which still handles genuinely narrow widths. */
|
|
42
|
+
stacked: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// useLayoutEffect measures + commits the stack decision before the browser
|
|
46
|
+
// paints, so a client-rendered narrow table shows cards on first frame
|
|
47
|
+
// rather than flashing tabular. On the server it would warn (no layout), so
|
|
48
|
+
// fall back to useEffect there — SSR always emits the non-stacked markup and
|
|
49
|
+
// the @container CSS covers true mobile without JS.
|
|
50
|
+
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
|
|
51
|
+
|
|
52
|
+
// Does any column's content no longer fit its box? We sample the header row
|
|
53
|
+
// plus the first body row (bounded cost). Free text wraps rather than
|
|
54
|
+
// overflowing (min-width:0 + overflow-wrap), so this only trips on content
|
|
55
|
+
// that genuinely can't shrink — action buttons, badges, or nowrap header
|
|
56
|
+
// labels — i.e. the real "columns won't fit" signal, independent of how many
|
|
57
|
+
// columns there are. Structural overflow is consistent down a column, so the
|
|
58
|
+
// first body row is a faithful sample.
|
|
59
|
+
function gridOverflows(grid: HTMLDivElement | null): boolean {
|
|
60
|
+
if (!grid) return false
|
|
61
|
+
const TOL = 2 // sub-pixel layout rounding slack
|
|
62
|
+
const over = (el: Element) => el.scrollWidth - el.clientWidth > TOL
|
|
63
|
+
for (const h of grid.querySelectorAll('[role="columnheader"]')) {
|
|
64
|
+
if (over(h)) return true
|
|
65
|
+
}
|
|
66
|
+
const rowgroups = grid.querySelectorAll('[role="rowgroup"]')
|
|
67
|
+
const body = rowgroups[rowgroups.length - 1] // header is first, body last
|
|
68
|
+
const firstRow = body?.querySelector('[role="row"]')
|
|
69
|
+
if (firstRow) {
|
|
70
|
+
for (const c of firstRow.querySelectorAll('[role="cell"]')) {
|
|
71
|
+
if (over(c)) return true
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return false
|
|
34
75
|
}
|
|
35
76
|
|
|
36
77
|
const TableContext = createContext<TableContextValue | null>(null)
|
|
@@ -172,7 +213,7 @@ interface RootProps {
|
|
|
172
213
|
children: ReactNode
|
|
173
214
|
variant?: TableVariant
|
|
174
215
|
size?: TableSize
|
|
175
|
-
/** Opt out of responsive container
|
|
216
|
+
/** Opt out of responsive behavior (auto-stacking + container query). Default true. */
|
|
176
217
|
responsive?: boolean
|
|
177
218
|
/**
|
|
178
219
|
* Optional sort UI rendered above the grid. Visible only in stack mode
|
|
@@ -201,9 +242,67 @@ export function Root({
|
|
|
201
242
|
inferredTemplateRef.current = template
|
|
202
243
|
}
|
|
203
244
|
|
|
245
|
+
// Content-aware stacking driven by ACTUAL overflow, not a width heuristic.
|
|
246
|
+
// Column count alone is a poor proxy — a table with 8 short-text columns
|
|
247
|
+
// fits fine on a laptop, while 4 columns of buttons + long emails can be
|
|
248
|
+
// crushed. So instead of guessing, we render tabular and check whether any
|
|
249
|
+
// cell's content actually overflows its box (scrollWidth > clientWidth);
|
|
250
|
+
// when it does, the columns can't fit and we card up. The @container base
|
|
251
|
+
// (styles.*: STACK_BP) still stacks true-mobile widths without JS.
|
|
252
|
+
//
|
|
253
|
+
// Oscillation guard: stacking changes the layout (cards never overflow), so
|
|
254
|
+
// we can't re-measure overflow while stacked. We latch the container width
|
|
255
|
+
// at the moment we stacked and only return to tabular once the container
|
|
256
|
+
// has grown comfortably past it (hysteresis), then re-check overflow fresh.
|
|
257
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
258
|
+
const gridRef = useRef<HTMLDivElement>(null)
|
|
259
|
+
const [stacked, setStacked] = useState(false)
|
|
260
|
+
const stackedAtWidthRef = useRef(0)
|
|
261
|
+
|
|
262
|
+
// Re-runs whenever `stacked` flips: after we drop back to tabular the
|
|
263
|
+
// container width is unchanged, so the ResizeObserver won't fire — the
|
|
264
|
+
// effect re-running is what re-checks overflow in the fresh tabular layout.
|
|
265
|
+
useIsoLayoutEffect(() => {
|
|
266
|
+
if (!responsive) {
|
|
267
|
+
setStacked(false)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
const el = containerRef.current
|
|
271
|
+
if (!el || typeof ResizeObserver === 'undefined') return
|
|
272
|
+
|
|
273
|
+
// Grows-back margin: only leave stack mode once we're well clear of the
|
|
274
|
+
// width that triggered it, so a 1px jiggle at the boundary can't flip-flop.
|
|
275
|
+
const HYSTERESIS = 48
|
|
276
|
+
|
|
277
|
+
const measure = (width: number) => {
|
|
278
|
+
if (width <= 0) return
|
|
279
|
+
if (!stacked) {
|
|
280
|
+
if (gridOverflows(gridRef.current)) {
|
|
281
|
+
stackedAtWidthRef.current = width
|
|
282
|
+
setStacked(true)
|
|
283
|
+
}
|
|
284
|
+
} else if (width > stackedAtWidthRef.current + HYSTERESIS) {
|
|
285
|
+
// Drop back to tabular; this effect re-runs and re-checks overflow.
|
|
286
|
+
setStacked(false)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
measure(el.getBoundingClientRect().width)
|
|
291
|
+
const ro = new ResizeObserver((entries) => {
|
|
292
|
+
const entry = entries[0]
|
|
293
|
+
if (!entry) return
|
|
294
|
+
const box = entry.contentBoxSize?.[0]
|
|
295
|
+
measure(box ? box.inlineSize : entry.contentRect.width)
|
|
296
|
+
})
|
|
297
|
+
ro.observe(el)
|
|
298
|
+
return () => ro.disconnect()
|
|
299
|
+
// labels.length re-runs detection when the column set changes.
|
|
300
|
+
}, [responsive, labels.length, stacked])
|
|
301
|
+
|
|
204
302
|
const grid = (
|
|
205
303
|
<html.div
|
|
206
304
|
role="table"
|
|
305
|
+
ref={gridRef}
|
|
207
306
|
style={[
|
|
208
307
|
styles.root,
|
|
209
308
|
// When responsive, use gridColumnsResponsive so the explicit column
|
|
@@ -218,26 +317,45 @@ export function Root({
|
|
|
218
317
|
: styles.gridColumns(template)
|
|
219
318
|
: undefined,
|
|
220
319
|
responsive && styles.rootResponsive,
|
|
320
|
+
// Force-stack override — collapses the grid to one column. Applied
|
|
321
|
+
// last so its gridTemplateColumns wins over gridColumnsResponsive.
|
|
322
|
+
responsive && stacked && styles.rootStacked,
|
|
221
323
|
]}
|
|
222
324
|
>
|
|
223
325
|
{children}
|
|
224
326
|
</html.div>
|
|
225
327
|
)
|
|
226
328
|
|
|
329
|
+
// The sort chip is normally revealed only by the @container query. When
|
|
330
|
+
// JS force-stacks (headers hidden, but container wider than STACK_BP) the
|
|
331
|
+
// chip's own query hasn't fired, so inject forceShow to reveal it.
|
|
332
|
+
const sortChipEl =
|
|
333
|
+
stacked && isValidElement(sortChip)
|
|
334
|
+
? cloneElement(sortChip as ReactElement<{forceShow?: boolean}>, {forceShow: true})
|
|
335
|
+
: sortChip
|
|
336
|
+
|
|
227
337
|
// Slots render unconditionally — `responsive=false` still wants its sort/
|
|
228
338
|
// pagination chrome. Only the containerType:inline-size wrapper is
|
|
229
339
|
// conditional, since it only matters when @container queries fire.
|
|
230
340
|
const body = (
|
|
231
341
|
<>
|
|
232
|
-
{
|
|
342
|
+
{sortChipEl}
|
|
233
343
|
{grid}
|
|
234
344
|
{pagination}
|
|
235
345
|
</>
|
|
236
346
|
)
|
|
237
347
|
|
|
238
348
|
return (
|
|
239
|
-
<TableContext.Provider
|
|
240
|
-
{responsive
|
|
349
|
+
<TableContext.Provider
|
|
350
|
+
value={{variant, size, responsive, labels, inferredTemplateRef, stacked}}
|
|
351
|
+
>
|
|
352
|
+
{responsive ? (
|
|
353
|
+
<html.div ref={containerRef} style={styles.rootContainer}>
|
|
354
|
+
{body}
|
|
355
|
+
</html.div>
|
|
356
|
+
) : (
|
|
357
|
+
body
|
|
358
|
+
)}
|
|
241
359
|
</TableContext.Provider>
|
|
242
360
|
)
|
|
243
361
|
}
|
|
@@ -245,9 +363,10 @@ export function Root({
|
|
|
245
363
|
// --- Header ---
|
|
246
364
|
|
|
247
365
|
export function Header({children}: {children: ReactNode}) {
|
|
366
|
+
const {stacked} = useTable()
|
|
248
367
|
return (
|
|
249
368
|
<HeaderContext.Provider value={true}>
|
|
250
|
-
<html.div role="rowgroup" style={styles.header}>
|
|
369
|
+
<html.div role="rowgroup" style={[styles.header, stacked && styles.headerStacked]}>
|
|
251
370
|
{children}
|
|
252
371
|
</html.div>
|
|
253
372
|
</HeaderContext.Provider>
|
|
@@ -311,7 +430,7 @@ const INTERACTIVE_SELECTOR =
|
|
|
311
430
|
'button, a, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="switch"], [role="tab"], [contenteditable="true"]'
|
|
312
431
|
|
|
313
432
|
export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
|
|
314
|
-
const {variant} = useTable()
|
|
433
|
+
const {variant, stacked} = useTable()
|
|
315
434
|
const isHeader = useContext(HeaderContext)
|
|
316
435
|
const rowIndex = useContext(RowIndexContext)
|
|
317
436
|
const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
|
|
@@ -353,6 +472,9 @@ export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
|
|
|
353
472
|
!isHeader && styles.bodyRow,
|
|
354
473
|
!isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
|
|
355
474
|
isClickable && styles.clickableRow,
|
|
475
|
+
stacked && styles.rowStacked,
|
|
476
|
+
// bodyRowStacked last so its card background wins over stripedEven.
|
|
477
|
+
!isHeader && stacked && styles.bodyRowStacked,
|
|
356
478
|
]}
|
|
357
479
|
>
|
|
358
480
|
{childArray.map((child, index) => (
|
|
@@ -441,7 +563,7 @@ HeaderCell.displayName = 'HeaderCell'
|
|
|
441
563
|
// isActions=true — actions footers don't show a label.
|
|
442
564
|
|
|
443
565
|
export function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
|
|
444
|
-
const {size, variant, labels, responsive} = useTable()
|
|
566
|
+
const {size, variant, labels, responsive, stacked} = useTable()
|
|
445
567
|
const {index, total} = useContext(CellIndexContext)
|
|
446
568
|
const isLast = variant === 'bordered' && index === total - 1
|
|
447
569
|
const label = labels[index] ?? ''
|
|
@@ -455,10 +577,20 @@ export function Cell({children, isActions}: {children: ReactNode; isActions?: bo
|
|
|
455
577
|
variant === 'bordered' && styles.borderedCell,
|
|
456
578
|
isLast && styles.borderedCellLast,
|
|
457
579
|
isActions && styles.cellActions,
|
|
580
|
+
// Force-stack: cells become a label|value grid; the actions cell then
|
|
581
|
+
// overrides its template to a single track and turns into the card's
|
|
582
|
+
// right-aligned footer. Mirrors the @container (STACK_BP) branch, so
|
|
583
|
+
// cellStacked applies for every cell and cellActionsStacked layers on
|
|
584
|
+
// top for actions (its gridTemplateColumns wins as the later entry).
|
|
585
|
+
stacked && styles.cellStacked,
|
|
586
|
+
isActions && stacked && styles.cellActionsStacked,
|
|
587
|
+
variant === 'bordered' && stacked && styles.borderedCellStacked,
|
|
458
588
|
]}
|
|
459
589
|
>
|
|
460
590
|
{responsive && !isActions && label !== '' ? (
|
|
461
|
-
<html.span style={styles.cellLabel}>
|
|
591
|
+
<html.span style={[styles.cellLabel, stacked && styles.cellLabelStacked]}>
|
|
592
|
+
{label}
|
|
593
|
+
</html.span>
|
|
462
594
|
) : null}
|
|
463
595
|
{isActions ? children : <html.div style={styles.cellValue}>{children}</html.div>}
|
|
464
596
|
</html.div>
|
|
@@ -320,6 +320,63 @@ export const styles = css.create({
|
|
|
320
320
|
alignSelf: 'flex-start',
|
|
321
321
|
},
|
|
322
322
|
|
|
323
|
+
// --- JS-measured force-stack variants ---
|
|
324
|
+
//
|
|
325
|
+
// A container query can only test the container's WIDTH — it can't know
|
|
326
|
+
// "6 columns won't fit here". So a dense table can be wider than STACK_BP
|
|
327
|
+
// yet still crush every cell to a few characters. Table.Root measures the
|
|
328
|
+
// container with a ResizeObserver and, when `width < columnCount ×
|
|
329
|
+
// minColumnWidth`, sets `stacked` in context; each component then also
|
|
330
|
+
// applies its `*Stacked` variant here. These mirror the values in the
|
|
331
|
+
// `@container (max-width: ${STACK_BP})` branches above, so JS-forced and
|
|
332
|
+
// CSS-driven stacking render identically. The @container base is kept as
|
|
333
|
+
// the SSR-safe path for genuinely narrow (mobile) widths — no JS, no
|
|
334
|
+
// hydration flash — while these handle the cramped medium-width case.
|
|
335
|
+
rootStacked: {
|
|
336
|
+
gridTemplateColumns: '1fr',
|
|
337
|
+
borderWidth: 0,
|
|
338
|
+
backgroundColor: 'transparent',
|
|
339
|
+
overflow: 'visible',
|
|
340
|
+
rowGap: spacing.sm,
|
|
341
|
+
},
|
|
342
|
+
headerStacked: {
|
|
343
|
+
display: 'none',
|
|
344
|
+
},
|
|
345
|
+
rowStacked: {
|
|
346
|
+
gridTemplateColumns: '1fr',
|
|
347
|
+
borderBottomWidth: 0,
|
|
348
|
+
},
|
|
349
|
+
bodyRowStacked: {
|
|
350
|
+
backgroundColor: {
|
|
351
|
+
default: colors.bgCard,
|
|
352
|
+
':hover': colors.bgCardHover,
|
|
353
|
+
},
|
|
354
|
+
padding: spacing.sm,
|
|
355
|
+
borderRadius: radii.sm,
|
|
356
|
+
borderWidth: 1,
|
|
357
|
+
},
|
|
358
|
+
cellStacked: {
|
|
359
|
+
display: 'grid',
|
|
360
|
+
gridTemplateColumns: '1fr 2fr',
|
|
361
|
+
gap: spacing.sm,
|
|
362
|
+
},
|
|
363
|
+
cellLabelStacked: {
|
|
364
|
+
display: 'block',
|
|
365
|
+
},
|
|
366
|
+
cellActionsStacked: {
|
|
367
|
+
gridTemplateColumns: '1fr',
|
|
368
|
+
justifyContent: 'flex-end',
|
|
369
|
+
marginTop: spacing.sm,
|
|
370
|
+
paddingTop: spacing.sm,
|
|
371
|
+
borderTopWidth: 1,
|
|
372
|
+
},
|
|
373
|
+
borderedCellStacked: {
|
|
374
|
+
borderRightWidth: 0,
|
|
375
|
+
},
|
|
376
|
+
sortChipStacked: {
|
|
377
|
+
display: 'inline-flex',
|
|
378
|
+
},
|
|
379
|
+
|
|
323
380
|
// Dynamic: grid columns applied on Root (non-responsive path only).
|
|
324
381
|
gridColumns: (template: string) => ({
|
|
325
382
|
gridTemplateColumns: template,
|
|
@@ -49,8 +49,30 @@ export const Small: Story = {
|
|
|
49
49
|
const layoutStyles = css.create({
|
|
50
50
|
row: {display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap'},
|
|
51
51
|
stack: {display: 'flex', flexDirection: 'column', gap: 16},
|
|
52
|
+
// Narrow column that a long identifier tag would overflow without `wrap`.
|
|
53
|
+
narrowCol: {width: 120, borderWidth: 1, borderStyle: 'solid', borderColor: '#333', padding: 8},
|
|
52
54
|
})
|
|
53
55
|
|
|
56
|
+
const LONG_CAP = 'lldap.group.member.remove'
|
|
57
|
+
|
|
58
|
+
// A long identifier-style tag in a narrow column: with `wrap` it breaks
|
|
59
|
+
// within the column instead of overflowing (its scrollWidth stays within the
|
|
60
|
+
// box), fixing the "capability leaks into the next column" bug.
|
|
61
|
+
export const WrapLongLabel: Story = {
|
|
62
|
+
render: () => (
|
|
63
|
+
<html.div style={layoutStyles.narrowCol}>
|
|
64
|
+
<Tag size="sm" wrap>
|
|
65
|
+
{LONG_CAP}
|
|
66
|
+
</Tag>
|
|
67
|
+
</html.div>
|
|
68
|
+
),
|
|
69
|
+
play: async ({canvas}) => {
|
|
70
|
+
const tag = canvas.getByText(LONG_CAP)
|
|
71
|
+
// Content wraps inside the tag; the tag doesn't spill past its column.
|
|
72
|
+
await expect(tag.scrollWidth).toBeLessThanOrEqual(tag.clientWidth + 2)
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
54
76
|
export const AllVariants: Story = {
|
|
55
77
|
render: () => (
|
|
56
78
|
<html.div style={layoutStyles.stack}>
|
|
@@ -16,6 +16,14 @@ interface TagProps {
|
|
|
16
16
|
/** Called when remove button is clicked. Auto-wired to TagGroup when inside one. */
|
|
17
17
|
onRemove?: () => void
|
|
18
18
|
disabled?: boolean
|
|
19
|
+
/**
|
|
20
|
+
* Allow the label to wrap / break instead of staying on one line. Tags are
|
|
21
|
+
* `white-space: nowrap` by default (they read as pills); set this for long
|
|
22
|
+
* identifier-style values (e.g. `lldap.group.member.remove`) sitting in a
|
|
23
|
+
* constrained column, so they break within their cell instead of leaking
|
|
24
|
+
* into the next one.
|
|
25
|
+
*/
|
|
26
|
+
wrap?: boolean
|
|
19
27
|
children: ReactNode
|
|
20
28
|
}
|
|
21
29
|
|
|
@@ -36,6 +44,7 @@ export function Tag({
|
|
|
36
44
|
removable: removableProp,
|
|
37
45
|
onRemove: onRemoveProp,
|
|
38
46
|
disabled: disabledProp = false,
|
|
47
|
+
wrap = false,
|
|
39
48
|
children,
|
|
40
49
|
}: TagProps) {
|
|
41
50
|
const group = useTagGroup()
|
|
@@ -133,6 +142,7 @@ export function Tag({
|
|
|
133
142
|
removable && removableSizeMap[size],
|
|
134
143
|
styles[variant],
|
|
135
144
|
disabled && styles.disabled,
|
|
145
|
+
wrap && styles.wrap,
|
|
136
146
|
]}
|
|
137
147
|
>
|
|
138
148
|
{tagContent}
|
|
@@ -13,6 +13,7 @@ export const styles = css.create({
|
|
|
13
13
|
lineHeight: 1,
|
|
14
14
|
borderRadius: 4,
|
|
15
15
|
whiteSpace: 'nowrap',
|
|
16
|
+
minWidth: 0,
|
|
16
17
|
borderWidth: 1,
|
|
17
18
|
borderStyle: 'solid',
|
|
18
19
|
borderColor: colors.border,
|
|
@@ -47,6 +48,17 @@ export const styles = css.create({
|
|
|
47
48
|
paddingRight: spacing.sm,
|
|
48
49
|
fontSize: typography.fontSizeXs,
|
|
49
50
|
},
|
|
51
|
+
// Opt-in wrapping for long identifier-style labels. Overrides the base
|
|
52
|
+
// nowrap so the text breaks within its container (e.g. a table cell)
|
|
53
|
+
// instead of overflowing into the next column. `anywhere` also lets the
|
|
54
|
+
// tag shrink below its content's intrinsic width so a flex/grid parent can
|
|
55
|
+
// actually give it a narrow track.
|
|
56
|
+
wrap: {
|
|
57
|
+
whiteSpace: 'normal',
|
|
58
|
+
overflowWrap: 'anywhere',
|
|
59
|
+
lineHeight: 1.35,
|
|
60
|
+
textAlign: 'start',
|
|
61
|
+
},
|
|
50
62
|
// Reduce right padding when removable to keep visual balance with the button
|
|
51
63
|
removableMd: {
|
|
52
64
|
paddingRight: spacing.sm,
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
2
|
import {expect, fn, waitFor} from 'storybook/test'
|
|
3
3
|
import {createColumnHelper} from '@tanstack/react-table'
|
|
4
|
+
import {css, html} from 'react-strict-dom'
|
|
4
5
|
import {VirtualTable} from './VirtualTable'
|
|
5
6
|
|
|
7
|
+
const storyStyles = css.create({
|
|
8
|
+
// Phone-width wrapper: below VirtualTable's 640px stackBelow default.
|
|
9
|
+
narrow: {width: 400},
|
|
10
|
+
})
|
|
11
|
+
|
|
6
12
|
interface Item {
|
|
7
13
|
id: string
|
|
8
14
|
name: string
|
|
@@ -87,3 +93,26 @@ export const ScrollReportsPage: Story = {
|
|
|
87
93
|
await expect(maxPage).toBeGreaterThan(1)
|
|
88
94
|
},
|
|
89
95
|
}
|
|
96
|
+
|
|
97
|
+
// In a narrow (phone-width) container the table cards up: header hides and
|
|
98
|
+
// each row becomes a label/value card, like Table's stack mode.
|
|
99
|
+
export const StacksOnNarrow: Story = {
|
|
100
|
+
args: {data: makeData(6), onRowClick: fn(), rowLabel: (r: Item) => r.name},
|
|
101
|
+
decorators: [
|
|
102
|
+
(Story) => (
|
|
103
|
+
<html.div style={storyStyles.narrow}>
|
|
104
|
+
<Story />
|
|
105
|
+
</html.div>
|
|
106
|
+
),
|
|
107
|
+
],
|
|
108
|
+
play: async ({canvas}) => {
|
|
109
|
+
// Header group not rendered → no column headers in the a11y tree.
|
|
110
|
+
await waitFor(async () => {
|
|
111
|
+
await expect(canvas.queryAllByRole('columnheader')).toHaveLength(0)
|
|
112
|
+
})
|
|
113
|
+
// Each card surfaces the column labels ('Name'/'Value') next to values.
|
|
114
|
+
await expect(canvas.getAllByText('Name').length).toBe(6)
|
|
115
|
+
await expect(canvas.getByText('Row 0')).toBeInTheDocument()
|
|
116
|
+
await expect(canvas.getByText('Row 5')).toBeInTheDocument()
|
|
117
|
+
},
|
|
118
|
+
}
|