@duro-app/ui 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/components/Table/FromTanstack.d.ts +2 -2
  2. package/dist/components/Table/FromTanstack.d.ts.map +1 -1
  3. package/dist/components/Table/SortChip.d.ts +1 -8
  4. package/dist/components/Table/SortChip.d.ts.map +1 -1
  5. package/dist/components/Table/Table.d.ts +6 -6
  6. package/dist/components/Table/Table.d.ts.map +1 -1
  7. package/dist/components/Table/Table.stories.d.ts +3 -2
  8. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  9. package/dist/components/Table/styles.css.d.ts +5 -40
  10. package/dist/components/Table/styles.css.d.ts.map +1 -1
  11. package/dist/components/Tag/Tag.d.ts +9 -1
  12. package/dist/components/Tag/Tag.d.ts.map +1 -1
  13. package/dist/components/Tag/Tag.stories.d.ts +1 -0
  14. package/dist/components/Tag/Tag.stories.d.ts.map +1 -1
  15. package/dist/components/Tag/styles.css.d.ts +7 -0
  16. package/dist/components/Tag/styles.css.d.ts.map +1 -1
  17. package/dist/components/VirtualTable/VirtualTable.d.ts +11 -1
  18. package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -1
  19. package/dist/components/VirtualTable/VirtualTable.stories.d.ts +1 -0
  20. package/dist/components/VirtualTable/VirtualTable.stories.d.ts.map +1 -1
  21. package/dist/components/VirtualTable/styles.css.d.ts +40 -0
  22. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -1
  23. package/dist/index.css +1 -1
  24. package/dist/index.js +2633 -2623
  25. package/dist/index.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/components/Table/FromTanstack.tsx +2 -2
  28. package/src/components/Table/SortChip.tsx +2 -15
  29. package/src/components/Table/Table.stories.tsx +91 -75
  30. package/src/components/Table/Table.tsx +35 -103
  31. package/src/components/Table/styles.css.ts +38 -65
  32. package/src/components/Tag/Tag.stories.tsx +22 -0
  33. package/src/components/Tag/Tag.tsx +10 -0
  34. package/src/components/Tag/styles.css.ts +12 -0
  35. package/src/components/VirtualTable/VirtualTable.stories.tsx +29 -0
  36. package/src/components/VirtualTable/VirtualTable.tsx +152 -58
  37. 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.45.0",
3
+ "version": "0.47.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.45.0"
58
+ "@duro-app/tokens": "^0.47.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@babel/preset-typescript": "^7.28.0",
@@ -22,8 +22,8 @@ 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. */
25
+ /** Min width (px) each flexible column keeps before the table scrolls
26
+ * horizontally. Forwarded to Table.Root — see its `minColumnWidth`. */
27
27
  readonly minColumnWidth?: number
28
28
  /** Render a SortChip above the grid (stack-mode only). Options derive from
29
29
  * columns where `column.getCanSort()` returns true. Wires value/onChange
@@ -13,13 +13,6 @@ 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
23
16
  }
24
17
 
25
18
  /**
@@ -31,18 +24,12 @@ export interface SortChipProps {
31
24
  * couple to @tanstack/react-table — the consumer wires `value` and
32
25
  * `onChange` from whatever sort state they manage.
33
26
  */
34
- export function SortChip({
35
- options,
36
- value,
37
- onChange,
38
- label = 'Sort by',
39
- forceShow = false,
40
- }: SortChipProps) {
27
+ export function SortChip({options, value, onChange, label = 'Sort by'}: SortChipProps) {
41
28
  const current = value ? options.find((o) => o.id === value.id) : null
42
29
  const triggerLabel = current ? `${label}: ${current.label} ${value!.desc ? '↓' : '↑'}` : label
43
30
 
44
31
  return (
45
- <html.div style={[styles.sortChip, forceShow && styles.sortChipStacked]}>
32
+ <html.div style={styles.sortChip}>
46
33
  <Menu.Root>
47
34
  <Menu.Trigger>{triggerLabel}</Menu.Trigger>
48
35
  <Menu.Popup>
@@ -1137,80 +1137,112 @@ export const SlotPropsOnRoot: Story = {
1137
1137
 
1138
1138
  // --- Regression: many columns + long content must wrap, not overlap ---
1139
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},
1140
+ // The responsive model: above the `sm` (640px) breakpoint a table stays
1141
+ // tabular and, if its columns don't fit, scrolls horizontally (each column
1142
+ // keeps a `minColumnWidth` floor); below `sm` it cards up. One uniform
1143
+ // breakpoint for every table — no per-table content measurement.
1144
+ const respStyles = css.create({
1145
+ // 8 columns × 120px floor = 960px min. At 800px the grid overflows → the
1146
+ // wrapper scrolls sideways, but it's still tabular (above 640).
1147
+ scrollDense: {width: 800},
1148
+ // Same table at 1120px: 960 < 1120, so it fits with no scrollbar.
1149
+ roomy: {width: 1120},
1150
+ // Below the 640px card breakpoint → cards.
1151
+ phone: {width: 560},
1149
1152
  })
1150
1153
 
1151
1154
  const LONG_EMAIL = 'a-really-long-service-account.address@subdomain.example-company.com'
1152
1155
 
1153
- export const ManyColumnsWrapNoOverlap: Story = {
1156
+ const grantsColumns = (
1157
+ <Table.Header>
1158
+ <Table.Row>
1159
+ <Table.HeaderCell>Principal</Table.HeaderCell>
1160
+ <Table.HeaderCell>Application</Table.HeaderCell>
1161
+ <Table.HeaderCell>Role</Table.HeaderCell>
1162
+ <Table.HeaderCell>Entitlement</Table.HeaderCell>
1163
+ <Table.HeaderCell>Resource</Table.HeaderCell>
1164
+ <Table.HeaderCell>Granted by</Table.HeaderCell>
1165
+ <Table.HeaderCell>Expires</Table.HeaderCell>
1166
+ <Table.HeaderCell>Status</Table.HeaderCell>
1167
+ </Table.Row>
1168
+ </Table.Header>
1169
+ )
1170
+ const grantsRow = (
1171
+ <Table.Row>
1172
+ <Table.Cell>alice</Table.Cell>
1173
+ <Table.Cell>Immich</Table.Cell>
1174
+ <Table.Cell>Editor</Table.Cell>
1175
+ <Table.Cell>library</Table.Cell>
1176
+ <Table.Cell>all</Table.Cell>
1177
+ <Table.Cell>daddy</Table.Cell>
1178
+ <Table.Cell>Never</Table.Cell>
1179
+ <Table.Cell>
1180
+ <Badge variant="success">Active</Badge>
1181
+ </Table.Cell>
1182
+ </Table.Row>
1183
+ )
1184
+
1185
+ // A dense table too narrow to fit but WIDER than the card breakpoint stays
1186
+ // tabular and scrolls sideways — the standard data-table behavior, and the
1187
+ // fix for grants carding up on a laptop.
1188
+ export const ScrollsHorizontallyWhenDense: Story = {
1189
+ name: 'Scrolls horizontally when dense (not cards)',
1154
1190
  render: () => (
1155
- <html.div style={overlapWrapStyles.wide}>
1191
+ <html.div style={respStyles.scrollDense}>
1156
1192
  <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>
1193
+ {grantsColumns}
1194
+ <Table.Body>{grantsRow}</Table.Body>
1187
1195
  </Table.Root>
1188
1196
  </html.div>
1189
1197
  ),
1190
1198
  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)
1199
+ const grid = canvas.getByRole('table')
1200
+ const scroller = grid.parentElement as HTMLElement
1201
+ await waitFor(async () => {
1202
+ // Still tabular: all 8 headers present (NOT carded up).
1203
+ await expect(canvas.queryAllByRole('columnheader')).toHaveLength(8)
1204
+ })
1205
+ // The wrapper scrolls: content (8 × 120px ≈ 960) exceeds its 800px box.
1206
+ await expect(scroller.scrollWidth).toBeGreaterThan(scroller.clientWidth + 2)
1196
1207
  },
1197
1208
  }
1198
1209
 
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',
1210
+ // Same table with room: fits, no horizontal scrollbar, stays tabular. Guards
1211
+ // against the old column-count heuristic that carded this up on a laptop.
1212
+ export const ManyColumnsFitNoScroll: Story = {
1213
+ name: 'Many columns fit — no scroll, no cards',
1205
1214
  render: () => (
1206
- <html.div style={overlapWrapStyles.cramped}>
1215
+ <html.div style={respStyles.roomy}>
1216
+ <Table.Root>
1217
+ {grantsColumns}
1218
+ <Table.Body>{grantsRow}</Table.Body>
1219
+ </Table.Root>
1220
+ </html.div>
1221
+ ),
1222
+ play: async ({canvas}) => {
1223
+ const grid = canvas.getByRole('table')
1224
+ const scroller = grid.parentElement as HTMLElement
1225
+ await waitFor(async () => {
1226
+ await expect(canvas.queryAllByRole('columnheader')).toHaveLength(8)
1227
+ })
1228
+ const tracks = getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/)
1229
+ await expect(tracks.length).toBe(8)
1230
+ // No horizontal overflow: the grid fits its container.
1231
+ await expect(scroller.scrollWidth).toBeLessThanOrEqual(scroller.clientWidth + 2)
1232
+ },
1233
+ }
1234
+
1235
+ // Below the single 640px card breakpoint every table cards up: header hides,
1236
+ // each row becomes a label/value card.
1237
+ export const CardsBelowMobileBreakpoint: Story = {
1238
+ name: 'Cards below the mobile breakpoint',
1239
+ render: () => (
1240
+ <html.div style={respStyles.phone}>
1207
1241
  <Table.Root>
1208
1242
  <Table.Header>
1209
1243
  <Table.Row>
1210
1244
  <Table.HeaderCell>Display Name</Table.HeaderCell>
1211
- <Table.HeaderCell>Type</Table.HeaderCell>
1212
1245
  <Table.HeaderCell>Email</Table.HeaderCell>
1213
- <Table.HeaderCell>Status</Table.HeaderCell>
1214
1246
  <Table.HeaderCell>Owner</Table.HeaderCell>
1215
1247
  <Table.HeaderCell width="max-content">Actions</Table.HeaderCell>
1216
1248
  </Table.Row>
@@ -1218,19 +1250,10 @@ export const AutoStacksWhenCramped: Story = {
1218
1250
  <Table.Body>
1219
1251
  <Table.Row>
1220
1252
  <Table.Cell>SMTP submission service account</Table.Cell>
1221
- <Table.Cell>
1222
- <Badge>User</Badge>
1223
- </Table.Cell>
1224
1253
  <Table.Cell>{LONG_EMAIL}</Table.Cell>
1225
- <Table.Cell>
1226
- <Badge variant="warning">No certs</Badge>
1227
- </Table.Cell>
1228
1254
  <Table.Cell>daddy</Table.Cell>
1229
1255
  <Table.Cell isActions>
1230
1256
  <Button size="small">Send Cert</Button>
1231
- <Button size="small" variant="secondary">
1232
- Revoke
1233
- </Button>
1234
1257
  </Table.Cell>
1235
1258
  </Table.Row>
1236
1259
  </Table.Body>
@@ -1239,18 +1262,11 @@ export const AutoStacksWhenCramped: Story = {
1239
1262
  ),
1240
1263
  play: async ({canvas}) => {
1241
1264
  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.
1265
+ // Single-track grid = card layout (the @container rule fires below 640).
1266
+ const tracks = getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/)
1267
+ await expect(tracks.length).toBe(1)
1268
+ // Header hidden → out of the a11y tree; each cell surfaces its label span.
1250
1269
  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
1270
  const ownerLabel = canvas
1255
1271
  .getAllByText('Owner')
1256
1272
  .find((el) => el.tagName.toLowerCase() === 'span') as HTMLElement
@@ -3,12 +3,8 @@ import {
3
3
  type ReactNode,
4
4
  type MutableRefObject,
5
5
  createContext,
6
- cloneElement,
7
6
  useContext,
8
- useEffect,
9
- useLayoutEffect,
10
7
  useRef,
11
- useState,
12
8
  Children,
13
9
  isValidElement,
14
10
  } from 'react'
@@ -35,20 +31,8 @@ interface TableContextValue {
35
31
  labels: ReadonlyArray<string>
36
32
  /** Mutable ref: header Row writes inferred template, Root reads it */
37
33
  inferredTemplateRef: MutableRefObject<string | null>
38
- /** JS-measured force-stack flag. True when Root's ResizeObserver finds the
39
- * container too narrow for its column count (cells would be crushed), so
40
- * every cell/row/header also applies its `*Stacked` variant. Independent
41
- * of the @container base, which still handles genuinely narrow widths. */
42
- stacked: boolean
43
34
  }
44
35
 
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
36
  const TableContext = createContext<TableContextValue | null>(null)
53
37
 
54
38
  function useTable() {
@@ -123,7 +107,10 @@ function unwrapElementType(t: unknown): unknown {
123
107
  return current
124
108
  }
125
109
 
126
- function extractColumnMeta(children: ReactNode): {
110
+ function extractColumnMeta(
111
+ children: ReactNode,
112
+ minColumnWidth: number,
113
+ ): {
127
114
  template: string
128
115
  compactTemplate: string
129
116
  labels: string[]
@@ -142,14 +129,14 @@ function extractColumnMeta(children: ReactNode): {
142
129
  (unwrappedType as {name?: string} | null)?.name ||
143
130
  ''
144
131
  if (unwrappedType === HeaderCell || displayName === 'HeaderCell') {
145
- // Default to minmax(0, 1fr) instead of plain '1fr' so each column
146
- // can shrink below its content's min-content size. A plain '1fr'
147
- // track has an implicit min of `auto` (= min-content), which makes
148
- // the column refuse to shrink and the whole table overflow when a
149
- // cell's text is wider than 1/N of the container. With minmax(0,
150
- // 1fr), the cell's compact-mode `min-width: 0` + `overflow:
151
- // hidden` + `text-overflow: ellipsis` can actually truncate.
152
- const width = props.width || 'minmax(0, 1fr)'
132
+ // Default each column to `minmax(<minColumnWidth>px, 1fr)`: columns
133
+ // share the row evenly (1fr) but never shrink below a readable floor.
134
+ // That floor is what makes a dense table overflow its container
135
+ // instead of crushing — Root's scroll wrapper then scrolls sideways.
136
+ // Narrow columns (a checkbox) or naturally-wide ones (an actions
137
+ // cell) override this with an explicit `width` (e.g. '40px',
138
+ // 'max-content').
139
+ const width = props.width || `minmax(${minColumnWidth}px, 1fr)`
153
140
  widths.push(width)
154
141
  // `compactWidth` lets the consumer switch to a content-aware
155
142
  // layout in the compact band while staying evenly distributed
@@ -188,14 +175,14 @@ interface RootProps {
188
175
  children: ReactNode
189
176
  variant?: TableVariant
190
177
  size?: TableSize
191
- /** Opt out of responsive container-query behavior. Default true. */
178
+ /** Opt out of responsive behavior (horizontal scroll + card breakpoint). Default true. */
192
179
  responsive?: boolean
193
180
  /**
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.
181
+ * Minimum width (px) each flexible column keeps before the table scrolls
182
+ * horizontally rather than crushing its cells. Columns still share the row
183
+ * evenly (`minmax(<minColumnWidth>px, 1fr)`); this is only the floor. Raise
184
+ * it for roomier columns (more scrolling), lower it to fit more before
185
+ * scrolling. Columns with an explicit `width` are unaffected. Default 120.
199
186
  */
200
187
  minColumnWidth?: number
201
188
  /**
@@ -216,45 +203,16 @@ export function Root({
216
203
  variant = 'default',
217
204
  size = 'md',
218
205
  responsive = true,
219
- minColumnWidth = 128,
206
+ minColumnWidth = 120,
220
207
  sortChip,
221
208
  pagination,
222
209
  }: RootProps) {
223
210
  const inferredTemplateRef = useRef<string | null>(null)
224
- const {template, compactTemplate, labels} = extractColumnMeta(children)
211
+ const {template, compactTemplate, labels} = extractColumnMeta(children, minColumnWidth)
225
212
  if (template) {
226
213
  inferredTemplateRef.current = template
227
214
  }
228
215
 
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
-
258
216
  const grid = (
259
217
  <html.div
260
218
  role="table"
@@ -272,45 +230,33 @@ export function Root({
272
230
  : styles.gridColumns(template)
273
231
  : undefined,
274
232
  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,
278
233
  ]}
279
234
  >
280
235
  {children}
281
236
  </html.div>
282
237
  )
283
238
 
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
239
+ // Above the stack breakpoint the grid keeps each column ≥ minColumnWidth, so
240
+ // a dense table overflows this wrapper and scrolls sideways instead of
241
+ // crushing. Below it (@container ≤ sm) the grid is one column and there's
242
+ // nothing to scroll. Non-responsive tables opt out of the frame entirely.
243
+ const framedGrid = responsive ? <html.div style={styles.scrollX}>{grid}</html.div> : grid
291
244
 
292
245
  // Slots render unconditionally — `responsive=false` still wants its sort/
293
246
  // pagination chrome. Only the containerType:inline-size wrapper is
294
- // conditional, since it only matters when @container queries fire.
247
+ // conditional, since it only matters when @container queries fire. SortChip
248
+ // reveals itself in card mode via its own @container rule — no JS needed.
295
249
  const body = (
296
250
  <>
297
- {sortChipEl}
298
- {grid}
251
+ {sortChip}
252
+ {framedGrid}
299
253
  {pagination}
300
254
  </>
301
255
  )
302
256
 
303
257
  return (
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
- )}
258
+ <TableContext.Provider value={{variant, size, responsive, labels, inferredTemplateRef}}>
259
+ {responsive ? <html.div style={styles.rootContainer}>{body}</html.div> : body}
314
260
  </TableContext.Provider>
315
261
  )
316
262
  }
@@ -318,10 +264,9 @@ export function Root({
318
264
  // --- Header ---
319
265
 
320
266
  export function Header({children}: {children: ReactNode}) {
321
- const {stacked} = useTable()
322
267
  return (
323
268
  <HeaderContext.Provider value={true}>
324
- <html.div role="rowgroup" style={[styles.header, stacked && styles.headerStacked]}>
269
+ <html.div role="rowgroup" style={styles.header}>
325
270
  {children}
326
271
  </html.div>
327
272
  </HeaderContext.Provider>
@@ -385,7 +330,7 @@ const INTERACTIVE_SELECTOR =
385
330
  'button, a, input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="menuitem"], [role="switch"], [role="tab"], [contenteditable="true"]'
386
331
 
387
332
  export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
388
- const {variant, stacked} = useTable()
333
+ const {variant} = useTable()
389
334
  const isHeader = useContext(HeaderContext)
390
335
  const rowIndex = useContext(RowIndexContext)
391
336
  const isEvenRow = rowIndex >= 0 && rowIndex % 2 === 1
@@ -427,9 +372,6 @@ export function Row({children, onClick, 'aria-label': ariaLabel}: RowProps) {
427
372
  !isHeader && styles.bodyRow,
428
373
  !isHeader && variant === 'striped' && isEvenRow && styles.stripedEven,
429
374
  isClickable && styles.clickableRow,
430
- stacked && styles.rowStacked,
431
- // bodyRowStacked last so its card background wins over stripedEven.
432
- !isHeader && stacked && styles.bodyRowStacked,
433
375
  ]}
434
376
  >
435
377
  {childArray.map((child, index) => (
@@ -518,7 +460,7 @@ HeaderCell.displayName = 'HeaderCell'
518
460
  // isActions=true — actions footers don't show a label.
519
461
 
520
462
  export function Cell({children, isActions}: {children: ReactNode; isActions?: boolean}) {
521
- const {size, variant, labels, responsive, stacked} = useTable()
463
+ const {size, variant, labels, responsive} = useTable()
522
464
  const {index, total} = useContext(CellIndexContext)
523
465
  const isLast = variant === 'bordered' && index === total - 1
524
466
  const label = labels[index] ?? ''
@@ -532,20 +474,10 @@ export function Cell({children, isActions}: {children: ReactNode; isActions?: bo
532
474
  variant === 'bordered' && styles.borderedCell,
533
475
  isLast && styles.borderedCellLast,
534
476
  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,
543
477
  ]}
544
478
  >
545
479
  {responsive && !isActions && label !== '' ? (
546
- <html.span style={[styles.cellLabel, stacked && styles.cellLabelStacked]}>
547
- {label}
548
- </html.span>
480
+ <html.span style={styles.cellLabel}>{label}</html.span>
549
481
  ) : null}
550
482
  {isActions ? children : <html.div style={styles.cellValue}>{children}</html.div>}
551
483
  </html.div>