@duro-app/ui 0.43.0 → 0.45.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 +8 -1
- package/dist/components/Table/SortChip.d.ts.map +1 -1
- package/dist/components/Table/Table.d.ts +9 -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 +50 -1
- package/dist/components/Table/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +1933 -1835
- 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 +15 -2
- package/src/components/Table/Table.stories.tsx +125 -1
- package/src/components/Table/Table.tsx +95 -8
- package/src/components/Table/styles.css.ts +81 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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.45.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -22,6 +22,9 @@ 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
|
|
25
28
|
/** Render a SortChip above the grid (stack-mode only). Options derive from
|
|
26
29
|
* columns where `column.getCanSort()` returns true. Wires value/onChange
|
|
27
30
|
* through the TanStack sorting state automatically. */
|
|
@@ -68,6 +71,7 @@ export function FromTanstack<TData>({
|
|
|
68
71
|
variant = 'default',
|
|
69
72
|
size = 'md',
|
|
70
73
|
responsive = true,
|
|
74
|
+
minColumnWidth,
|
|
71
75
|
sortChip,
|
|
72
76
|
pagination,
|
|
73
77
|
rowKey,
|
|
@@ -82,6 +86,7 @@ export function FromTanstack<TData>({
|
|
|
82
86
|
variant={variant}
|
|
83
87
|
size={size}
|
|
84
88
|
responsive={responsive}
|
|
89
|
+
minColumnWidth={minColumnWidth}
|
|
85
90
|
sortChip={sortChipNode}
|
|
86
91
|
pagination={paginationNode}
|
|
87
92
|
>
|
|
@@ -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'
|
|
@@ -1134,3 +1134,127 @@ export const SlotPropsOnRoot: Story = {
|
|
|
1134
1134
|
await expect(canvas.getAllByText('Name').length).toBeGreaterThan(0)
|
|
1135
1135
|
},
|
|
1136
1136
|
}
|
|
1137
|
+
|
|
1138
|
+
// --- Regression: many columns + long content must wrap, not overlap ---
|
|
1139
|
+
//
|
|
1140
|
+
// In a wide-but-tight container, columns whose combined content exceeds the
|
|
1141
|
+
// width used to keep their intrinsic size (minWidth: auto) and spill over their
|
|
1142
|
+
// grid track into the next column. Cells now shrink to their track and wrap.
|
|
1143
|
+
const overlapWrapStyles = css.create({
|
|
1144
|
+
wide: {width: 760},
|
|
1145
|
+
// 6 columns in 560px → each column would get < 128px (the min comfortable
|
|
1146
|
+
// width), well above the 440px mobile breakpoint, so only the JS
|
|
1147
|
+
// ResizeObserver — not the @container query — can catch the cramping.
|
|
1148
|
+
cramped: {width: 560},
|
|
1149
|
+
})
|
|
1150
|
+
|
|
1151
|
+
const LONG_EMAIL = 'a-really-long-service-account.address@subdomain.example-company.com'
|
|
1152
|
+
|
|
1153
|
+
export const ManyColumnsWrapNoOverlap: Story = {
|
|
1154
|
+
render: () => (
|
|
1155
|
+
<html.div style={overlapWrapStyles.wide}>
|
|
1156
|
+
<Table.Root>
|
|
1157
|
+
<Table.Header>
|
|
1158
|
+
<Table.Row>
|
|
1159
|
+
<Table.HeaderCell>Display Name</Table.HeaderCell>
|
|
1160
|
+
<Table.HeaderCell>Type</Table.HeaderCell>
|
|
1161
|
+
<Table.HeaderCell>Email</Table.HeaderCell>
|
|
1162
|
+
<Table.HeaderCell>Status</Table.HeaderCell>
|
|
1163
|
+
<Table.HeaderCell width="max-content">Actions</Table.HeaderCell>
|
|
1164
|
+
</Table.Row>
|
|
1165
|
+
</Table.Header>
|
|
1166
|
+
<Table.Body>
|
|
1167
|
+
<Table.Row>
|
|
1168
|
+
<Table.Cell>SMTP submission service account</Table.Cell>
|
|
1169
|
+
<Table.Cell>
|
|
1170
|
+
<Badge>User</Badge>
|
|
1171
|
+
</Table.Cell>
|
|
1172
|
+
<Table.Cell>{LONG_EMAIL}</Table.Cell>
|
|
1173
|
+
<Table.Cell>
|
|
1174
|
+
<Badge variant="warning">No certs</Badge>
|
|
1175
|
+
</Table.Cell>
|
|
1176
|
+
<Table.Cell isActions>
|
|
1177
|
+
<Button size="small">Send Cert</Button>
|
|
1178
|
+
<Button size="small" variant="secondary">
|
|
1179
|
+
Certificates
|
|
1180
|
+
</Button>
|
|
1181
|
+
<Button size="small" variant="danger">
|
|
1182
|
+
Revoke All
|
|
1183
|
+
</Button>
|
|
1184
|
+
</Table.Cell>
|
|
1185
|
+
</Table.Row>
|
|
1186
|
+
</Table.Body>
|
|
1187
|
+
</Table.Root>
|
|
1188
|
+
</html.div>
|
|
1189
|
+
),
|
|
1190
|
+
play: async ({canvas}) => {
|
|
1191
|
+
const emailCell = canvas.getByText(LONG_EMAIL).closest('[role="cell"]') as HTMLElement
|
|
1192
|
+
await expect(emailCell).toBeTruthy()
|
|
1193
|
+
// The long email wraps inside its column instead of overflowing into the
|
|
1194
|
+
// next one: the cell's content is no wider than the cell box.
|
|
1195
|
+
await expect(emailCell.scrollWidth).toBeLessThanOrEqual(emailCell.clientWidth + 2)
|
|
1196
|
+
},
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// A dense table wider than the 440px mobile breakpoint but too narrow to give
|
|
1200
|
+
// each column a comfortable width. The @container query never fires (560 >
|
|
1201
|
+
// 440), so this asserts the JS ResizeObserver force-stacks it into cards:
|
|
1202
|
+
// 6 columns × 128px default = 768px threshold > 560px container.
|
|
1203
|
+
export const AutoStacksWhenCramped: Story = {
|
|
1204
|
+
name: 'Auto-stacks when columns get cramped',
|
|
1205
|
+
render: () => (
|
|
1206
|
+
<html.div style={overlapWrapStyles.cramped}>
|
|
1207
|
+
<Table.Root>
|
|
1208
|
+
<Table.Header>
|
|
1209
|
+
<Table.Row>
|
|
1210
|
+
<Table.HeaderCell>Display Name</Table.HeaderCell>
|
|
1211
|
+
<Table.HeaderCell>Type</Table.HeaderCell>
|
|
1212
|
+
<Table.HeaderCell>Email</Table.HeaderCell>
|
|
1213
|
+
<Table.HeaderCell>Status</Table.HeaderCell>
|
|
1214
|
+
<Table.HeaderCell>Owner</Table.HeaderCell>
|
|
1215
|
+
<Table.HeaderCell width="max-content">Actions</Table.HeaderCell>
|
|
1216
|
+
</Table.Row>
|
|
1217
|
+
</Table.Header>
|
|
1218
|
+
<Table.Body>
|
|
1219
|
+
<Table.Row>
|
|
1220
|
+
<Table.Cell>SMTP submission service account</Table.Cell>
|
|
1221
|
+
<Table.Cell>
|
|
1222
|
+
<Badge>User</Badge>
|
|
1223
|
+
</Table.Cell>
|
|
1224
|
+
<Table.Cell>{LONG_EMAIL}</Table.Cell>
|
|
1225
|
+
<Table.Cell>
|
|
1226
|
+
<Badge variant="warning">No certs</Badge>
|
|
1227
|
+
</Table.Cell>
|
|
1228
|
+
<Table.Cell>daddy</Table.Cell>
|
|
1229
|
+
<Table.Cell isActions>
|
|
1230
|
+
<Button size="small">Send Cert</Button>
|
|
1231
|
+
<Button size="small" variant="secondary">
|
|
1232
|
+
Revoke
|
|
1233
|
+
</Button>
|
|
1234
|
+
</Table.Cell>
|
|
1235
|
+
</Table.Row>
|
|
1236
|
+
</Table.Body>
|
|
1237
|
+
</Table.Root>
|
|
1238
|
+
</html.div>
|
|
1239
|
+
),
|
|
1240
|
+
play: async ({canvas}) => {
|
|
1241
|
+
const grid = canvas.getByRole('table')
|
|
1242
|
+
// ResizeObserver fires post-layout; wait for the stack decision to commit.
|
|
1243
|
+
await waitFor(async () => {
|
|
1244
|
+
// The grid collapses to a single column — one resolved track, no spaces.
|
|
1245
|
+
const tracks = getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/)
|
|
1246
|
+
await expect(tracks.length).toBe(1)
|
|
1247
|
+
})
|
|
1248
|
+
// Header group is display:none, so it drops out of the accessibility tree:
|
|
1249
|
+
// no column headers remain queryable, leaving only the body rowgroup.
|
|
1250
|
+
await expect(canvas.queryAllByRole('columnheader')).toHaveLength(0)
|
|
1251
|
+
await expect(canvas.getAllByRole('rowgroup')).toHaveLength(1)
|
|
1252
|
+
// Each body cell now surfaces its label span (label|value card rows). The
|
|
1253
|
+
// hidden header 'Owner' stays in the DOM, so pick the visible <span>.
|
|
1254
|
+
const ownerLabel = canvas
|
|
1255
|
+
.getAllByText('Owner')
|
|
1256
|
+
.find((el) => el.tagName.toLowerCase() === 'span') as HTMLElement
|
|
1257
|
+
await expect(ownerLabel).toBeTruthy()
|
|
1258
|
+
await expect(getComputedStyle(ownerLabel).display).toBe('block')
|
|
1259
|
+
},
|
|
1260
|
+
}
|
|
@@ -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,8 +35,20 @@ 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
|
|
34
43
|
}
|
|
35
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
|
+
|
|
36
52
|
const TableContext = createContext<TableContextValue | null>(null)
|
|
37
53
|
|
|
38
54
|
function useTable() {
|
|
@@ -174,6 +190,14 @@ interface RootProps {
|
|
|
174
190
|
size?: TableSize
|
|
175
191
|
/** Opt out of responsive container-query behavior. Default true. */
|
|
176
192
|
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
|
|
177
201
|
/**
|
|
178
202
|
* Optional sort UI rendered above the grid. Visible only in stack mode
|
|
179
203
|
* (SortChip carries its own `display: none → inline-flex` rule). Typical
|
|
@@ -192,6 +216,7 @@ export function Root({
|
|
|
192
216
|
variant = 'default',
|
|
193
217
|
size = 'md',
|
|
194
218
|
responsive = true,
|
|
219
|
+
minColumnWidth = 128,
|
|
195
220
|
sortChip,
|
|
196
221
|
pagination,
|
|
197
222
|
}: RootProps) {
|
|
@@ -201,6 +226,35 @@ export function Root({
|
|
|
201
226
|
inferredTemplateRef.current = template
|
|
202
227
|
}
|
|
203
228
|
|
|
229
|
+
// Content-aware stacking. A container query can't see the column count, so
|
|
230
|
+
// measure the container and card up as soon as each column would fall
|
|
231
|
+
// below `minColumnWidth`. Keyed on labels.length so it tracks the actual
|
|
232
|
+
// number of columns. The @container base (styles.*: STACK_BP) still fires
|
|
233
|
+
// independently for genuinely narrow widths without needing JS.
|
|
234
|
+
const columnCount = labels.length
|
|
235
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
236
|
+
const [stacked, setStacked] = useState(false)
|
|
237
|
+
|
|
238
|
+
useIsoLayoutEffect(() => {
|
|
239
|
+
if (!responsive || columnCount === 0) {
|
|
240
|
+
setStacked(false)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const el = containerRef.current
|
|
244
|
+
if (!el || typeof ResizeObserver === 'undefined') return
|
|
245
|
+
const threshold = columnCount * minColumnWidth
|
|
246
|
+
const apply = (width: number) => setStacked(width > 0 && width < threshold)
|
|
247
|
+
apply(el.getBoundingClientRect().width)
|
|
248
|
+
const ro = new ResizeObserver((entries) => {
|
|
249
|
+
const entry = entries[0]
|
|
250
|
+
if (!entry) return
|
|
251
|
+
const box = entry.contentBoxSize?.[0]
|
|
252
|
+
apply(box ? box.inlineSize : entry.contentRect.width)
|
|
253
|
+
})
|
|
254
|
+
ro.observe(el)
|
|
255
|
+
return () => ro.disconnect()
|
|
256
|
+
}, [responsive, columnCount, minColumnWidth])
|
|
257
|
+
|
|
204
258
|
const grid = (
|
|
205
259
|
<html.div
|
|
206
260
|
role="table"
|
|
@@ -218,26 +272,45 @@ export function Root({
|
|
|
218
272
|
: styles.gridColumns(template)
|
|
219
273
|
: undefined,
|
|
220
274
|
responsive && styles.rootResponsive,
|
|
275
|
+
// Force-stack override — collapses the grid to one column. Applied
|
|
276
|
+
// last so its gridTemplateColumns wins over gridColumnsResponsive.
|
|
277
|
+
responsive && stacked && styles.rootStacked,
|
|
221
278
|
]}
|
|
222
279
|
>
|
|
223
280
|
{children}
|
|
224
281
|
</html.div>
|
|
225
282
|
)
|
|
226
283
|
|
|
284
|
+
// The sort chip is normally revealed only by the @container query. When
|
|
285
|
+
// JS force-stacks (headers hidden, but container wider than STACK_BP) the
|
|
286
|
+
// chip's own query hasn't fired, so inject forceShow to reveal it.
|
|
287
|
+
const sortChipEl =
|
|
288
|
+
stacked && isValidElement(sortChip)
|
|
289
|
+
? cloneElement(sortChip as ReactElement<{forceShow?: boolean}>, {forceShow: true})
|
|
290
|
+
: sortChip
|
|
291
|
+
|
|
227
292
|
// Slots render unconditionally — `responsive=false` still wants its sort/
|
|
228
293
|
// pagination chrome. Only the containerType:inline-size wrapper is
|
|
229
294
|
// conditional, since it only matters when @container queries fire.
|
|
230
295
|
const body = (
|
|
231
296
|
<>
|
|
232
|
-
{
|
|
297
|
+
{sortChipEl}
|
|
233
298
|
{grid}
|
|
234
299
|
{pagination}
|
|
235
300
|
</>
|
|
236
301
|
)
|
|
237
302
|
|
|
238
303
|
return (
|
|
239
|
-
<TableContext.Provider
|
|
240
|
-
{responsive
|
|
304
|
+
<TableContext.Provider
|
|
305
|
+
value={{variant, size, responsive, labels, inferredTemplateRef, stacked}}
|
|
306
|
+
>
|
|
307
|
+
{responsive ? (
|
|
308
|
+
<html.div ref={containerRef} style={styles.rootContainer}>
|
|
309
|
+
{body}
|
|
310
|
+
</html.div>
|
|
311
|
+
) : (
|
|
312
|
+
body
|
|
313
|
+
)}
|
|
241
314
|
</TableContext.Provider>
|
|
242
315
|
)
|
|
243
316
|
}
|
|
@@ -245,9 +318,10 @@ export function Root({
|
|
|
245
318
|
// --- Header ---
|
|
246
319
|
|
|
247
320
|
export function Header({children}: {children: ReactNode}) {
|
|
321
|
+
const {stacked} = useTable()
|
|
248
322
|
return (
|
|
249
323
|
<HeaderContext.Provider value={true}>
|
|
250
|
-
<html.div role="rowgroup" style={styles.header}>
|
|
324
|
+
<html.div role="rowgroup" style={[styles.header, stacked && styles.headerStacked]}>
|
|
251
325
|
{children}
|
|
252
326
|
</html.div>
|
|
253
327
|
</HeaderContext.Provider>
|
|
@@ -311,7 +385,7 @@ const INTERACTIVE_SELECTOR =
|
|
|
311
385
|
'button, a, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="switch"], [role="tab"], [contenteditable="true"]'
|
|
312
386
|
|
|
313
387
|
export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
|
|
314
|
-
const {variant} = useTable()
|
|
388
|
+
const {variant, stacked} = useTable()
|
|
315
389
|
const isHeader = useContext(HeaderContext)
|
|
316
390
|
const rowIndex = useContext(RowIndexContext)
|
|
317
391
|
const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
|
|
@@ -353,6 +427,9 @@ export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
|
|
|
353
427
|
!isHeader && styles.bodyRow,
|
|
354
428
|
!isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
|
|
355
429
|
isClickable && styles.clickableRow,
|
|
430
|
+
stacked && styles.rowStacked,
|
|
431
|
+
// bodyRowStacked last so its card background wins over stripedEven.
|
|
432
|
+
!isHeader && stacked && styles.bodyRowStacked,
|
|
356
433
|
]}
|
|
357
434
|
>
|
|
358
435
|
{childArray.map((child, index) => (
|
|
@@ -441,7 +518,7 @@ HeaderCell.displayName = 'HeaderCell'
|
|
|
441
518
|
// isActions=true — actions footers don't show a label.
|
|
442
519
|
|
|
443
520
|
export function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
|
|
444
|
-
const {size, variant, labels, responsive} = useTable()
|
|
521
|
+
const {size, variant, labels, responsive, stacked} = useTable()
|
|
445
522
|
const {index, total} = useContext(CellIndexContext)
|
|
446
523
|
const isLast = variant === 'bordered' && index === total - 1
|
|
447
524
|
const label = labels[index] ?? ''
|
|
@@ -455,12 +532,22 @@ export function Cell({children, isActions}: {children: ReactNode; isActions?: bo
|
|
|
455
532
|
variant === 'bordered' && styles.borderedCell,
|
|
456
533
|
isLast && styles.borderedCellLast,
|
|
457
534
|
isActions && styles.cellActions,
|
|
535
|
+
// Force-stack: cells become a label|value grid; the actions cell then
|
|
536
|
+
// overrides its template to a single track and turns into the card's
|
|
537
|
+
// right-aligned footer. Mirrors the @container (STACK_BP) branch, so
|
|
538
|
+
// cellStacked applies for every cell and cellActionsStacked layers on
|
|
539
|
+
// top for actions (its gridTemplateColumns wins as the later entry).
|
|
540
|
+
stacked && styles.cellStacked,
|
|
541
|
+
isActions && stacked && styles.cellActionsStacked,
|
|
542
|
+
variant === 'bordered' && stacked && styles.borderedCellStacked,
|
|
458
543
|
]}
|
|
459
544
|
>
|
|
460
545
|
{responsive && !isActions && label !== '' ? (
|
|
461
|
-
<html.span style={styles.cellLabel}>
|
|
546
|
+
<html.span style={[styles.cellLabel, stacked && styles.cellLabelStacked]}>
|
|
547
|
+
{label}
|
|
548
|
+
</html.span>
|
|
462
549
|
) : null}
|
|
463
|
-
{children}
|
|
550
|
+
{isActions ? children : <html.div style={styles.cellValue}>{children}</html.div>}
|
|
464
551
|
</html.div>
|
|
465
552
|
)
|
|
466
553
|
}
|
|
@@ -154,6 +154,10 @@ export const styles = css.create({
|
|
|
154
154
|
fontWeight: typography.fontWeightSemibold,
|
|
155
155
|
color: colors.textMuted,
|
|
156
156
|
textAlign: 'start',
|
|
157
|
+
// Match the body cell: shrink inside the track + wrap, so header labels
|
|
158
|
+
// don't overlap when many columns are cramped.
|
|
159
|
+
minWidth: 0,
|
|
160
|
+
overflowWrap: 'break-word',
|
|
157
161
|
whiteSpace: {
|
|
158
162
|
default: 'normal',
|
|
159
163
|
[`@container (max-width: ${COMPACT_BP})`]: 'nowrap',
|
|
@@ -176,14 +180,13 @@ export const styles = css.create({
|
|
|
176
180
|
[`@container (max-width: ${STACK_BP})`]: 'grid',
|
|
177
181
|
},
|
|
178
182
|
alignItems: 'center',
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
},
|
|
183
|
+
// Always let the cell shrink inside its `minmax(0, 1fr)` track. Previously
|
|
184
|
+
// this was `auto` in the default (wide) band on the assumption there's
|
|
185
|
+
// "enough room" — but a table with many columns (or wide content) whose
|
|
186
|
+
// combined intrinsic width exceeds the container then can't shrink, so
|
|
187
|
+
// content spills its track and overlaps the next column. `0` + the
|
|
188
|
+
// wrapping below keeps every cell inside its column at any width.
|
|
189
|
+
minWidth: 0,
|
|
187
190
|
overflow: {
|
|
188
191
|
// Safety against pathological unbreakable content (long URLs,
|
|
189
192
|
// dataless ids) at narrow widths.
|
|
@@ -191,6 +194,9 @@ export const styles = css.create({
|
|
|
191
194
|
[`@container (max-width: ${COMPACT_BP})`]: 'hidden',
|
|
192
195
|
},
|
|
193
196
|
whiteSpace: 'normal',
|
|
197
|
+
// Break long unbreakable tokens (emails, UUIDs) so they wrap within the
|
|
198
|
+
// column instead of forcing it wider / overflowing.
|
|
199
|
+
overflowWrap: 'break-word',
|
|
194
200
|
gridTemplateColumns: {
|
|
195
201
|
default: null,
|
|
196
202
|
[`@container (max-width: ${STACK_BP})`]: '1fr 2fr',
|
|
@@ -201,6 +207,16 @@ export const styles = css.create({
|
|
|
201
207
|
},
|
|
202
208
|
},
|
|
203
209
|
|
|
210
|
+
// Wrapper around a (non-actions) cell's value. A raw text node is an
|
|
211
|
+
// anonymous flex item with `min-width: auto`, so it refuses to shrink and
|
|
212
|
+
// overflows its column even when the cell itself can shrink. Giving the value
|
|
213
|
+
// its own `min-width: 0` flex item lets the text wrap inside the column.
|
|
214
|
+
cellValue: {
|
|
215
|
+
minWidth: 0,
|
|
216
|
+
flexGrow: 1,
|
|
217
|
+
overflowWrap: 'break-word',
|
|
218
|
+
},
|
|
219
|
+
|
|
204
220
|
// Label rendered as a real <span> inside each cell. Display:none in
|
|
205
221
|
// default/compact, shown only in stack — matches StyleX guidance to
|
|
206
222
|
// prefer real elements over ::before/::after for non-shadow-DOM cases.
|
|
@@ -304,6 +320,63 @@ export const styles = css.create({
|
|
|
304
320
|
alignSelf: 'flex-start',
|
|
305
321
|
},
|
|
306
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
|
+
|
|
307
380
|
// Dynamic: grid columns applied on Root (non-responsive path only).
|
|
308
381
|
gridColumns: (template: string) => ({
|
|
309
382
|
gridTemplateColumns: template,
|