@duro-app/ui 0.46.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.46.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.46.0"
58
+ "@duro-app/tokens": "^0.47.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 width (px) each flexible column keeps before the table scrolls
26
+ * horizontally. Forwarded to Table.Root — see its `minColumnWidth`. */
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,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,167 +1137,123 @@ 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, 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).
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.
1152
1149
  roomy: {width: 1120},
1150
+ // Below the 640px card breakpoint → cards.
1151
+ phone: {width: 560},
1153
1152
  })
1154
1153
 
1155
1154
  const LONG_EMAIL = 'a-really-long-service-account.address@subdomain.example-company.com'
1156
1155
 
1157
- 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)',
1158
1190
  render: () => (
1159
- <html.div style={overlapWrapStyles.wide}>
1191
+ <html.div style={respStyles.scrollDense}>
1160
1192
  <Table.Root>
1161
- <Table.Header>
1162
- <Table.Row>
1163
- <Table.HeaderCell>Display Name</Table.HeaderCell>
1164
- <Table.HeaderCell>Type</Table.HeaderCell>
1165
- <Table.HeaderCell>Email</Table.HeaderCell>
1166
- <Table.HeaderCell>Status</Table.HeaderCell>
1167
- <Table.HeaderCell width="max-content">Actions</Table.HeaderCell>
1168
- </Table.Row>
1169
- </Table.Header>
1170
- <Table.Body>
1171
- <Table.Row>
1172
- <Table.Cell>SMTP submission service account</Table.Cell>
1173
- <Table.Cell>
1174
- <Badge>User</Badge>
1175
- </Table.Cell>
1176
- <Table.Cell>{LONG_EMAIL}</Table.Cell>
1177
- <Table.Cell>
1178
- <Badge variant="warning">No certs</Badge>
1179
- </Table.Cell>
1180
- <Table.Cell isActions>
1181
- <Button size="small">Send Cert</Button>
1182
- <Button size="small" variant="secondary">
1183
- Certificates
1184
- </Button>
1185
- <Button size="small" variant="danger">
1186
- Revoke All
1187
- </Button>
1188
- </Table.Cell>
1189
- </Table.Row>
1190
- </Table.Body>
1193
+ {grantsColumns}
1194
+ <Table.Body>{grantsRow}</Table.Body>
1191
1195
  </Table.Root>
1192
1196
  </html.div>
1193
1197
  ),
1194
1198
  play: async ({canvas}) => {
1195
- const emailCell = canvas.getByText(LONG_EMAIL).closest('[role="cell"]') as HTMLElement
1196
- await expect(emailCell).toBeTruthy()
1197
- // The long email wraps inside its column instead of overflowing into the
1198
- // next one: the cell's content is no wider than the cell box.
1199
- 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)
1200
1207
  },
1201
1208
  }
1202
1209
 
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',
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',
1209
1214
  render: () => (
1210
- <html.div style={overlapWrapStyles.cramped}>
1215
+ <html.div style={respStyles.roomy}>
1211
1216
  <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>
1217
+ {grantsColumns}
1218
+ <Table.Body>{grantsRow}</Table.Body>
1243
1219
  </Table.Root>
1244
1220
  </html.div>
1245
1221
  ),
1246
1222
  play: async ({canvas}) => {
1247
1223
  const grid = canvas.getByRole('table')
1248
- // ResizeObserver fires post-layout; wait for the stack decision to commit.
1224
+ const scroller = grid.parentElement as HTMLElement
1249
1225
  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)
1226
+ await expect(canvas.queryAllByRole('columnheader')).toHaveLength(8)
1253
1227
  })
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')
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)
1265
1232
  },
1266
1233
  }
1267
1234
 
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',
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',
1275
1239
  render: () => (
1276
- <html.div style={overlapWrapStyles.roomy}>
1240
+ <html.div style={respStyles.phone}>
1277
1241
  <Table.Root>
1278
1242
  <Table.Header>
1279
1243
  <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>
1244
+ <Table.HeaderCell>Display Name</Table.HeaderCell>
1245
+ <Table.HeaderCell>Email</Table.HeaderCell>
1246
+ <Table.HeaderCell>Owner</Table.HeaderCell>
1247
+ <Table.HeaderCell width="max-content">Actions</Table.HeaderCell>
1288
1248
  </Table.Row>
1289
1249
  </Table.Header>
1290
1250
  <Table.Body>
1291
1251
  <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>
1252
+ <Table.Cell>SMTP submission service account</Table.Cell>
1253
+ <Table.Cell>{LONG_EMAIL}</Table.Cell>
1297
1254
  <Table.Cell>daddy</Table.Cell>
1298
- <Table.Cell>Never</Table.Cell>
1299
- <Table.Cell>
1300
- <Badge variant="success">Active</Badge>
1255
+ <Table.Cell isActions>
1256
+ <Button size="small">Send Cert</Button>
1301
1257
  </Table.Cell>
1302
1258
  </Table.Row>
1303
1259
  </Table.Body>
@@ -1306,12 +1262,15 @@ export const ManyColumnsStayTabularWhenFitting: Story = {
1306
1262
  ),
1307
1263
  play: async ({canvas}) => {
1308
1264
  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
- })
1265
+ // Single-track grid = card layout (the @container rule fires below 640).
1314
1266
  const tracks = getComputedStyle(grid).gridTemplateColumns.trim().split(/\s+/)
1315
- await expect(tracks.length).toBe(8)
1267
+ await expect(tracks.length).toBe(1)
1268
+ // Header hidden → out of the a11y tree; each cell surfaces its label span.
1269
+ await expect(canvas.queryAllByRole('columnheader')).toHaveLength(0)
1270
+ const ownerLabel = canvas
1271
+ .getAllByText('Owner')
1272
+ .find((el) => el.tagName.toLowerCase() === 'span') as HTMLElement
1273
+ await expect(ownerLabel).toBeTruthy()
1274
+ await expect(getComputedStyle(ownerLabel).display).toBe('block')
1316
1275
  },
1317
1276
  }