@charcuterie/ui 3.0.0 → 3.1.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 (113) hide show
  1. package/README.md +10 -0
  2. package/dist/Badge/Badge.d.ts +33 -3
  3. package/dist/Badge/Badge.d.ts.map +1 -1
  4. package/dist/Badge/Badge.js +20 -2
  5. package/dist/Badge/Badge.js.map +1 -1
  6. package/dist/Board/Board.d.ts +119 -0
  7. package/dist/Board/Board.d.ts.map +1 -0
  8. package/dist/Board/Board.js +165 -0
  9. package/dist/Board/Board.js.map +1 -0
  10. package/dist/Board/BoardCard.d.ts +120 -0
  11. package/dist/Board/BoardCard.d.ts.map +1 -0
  12. package/dist/Board/BoardCard.js +77 -0
  13. package/dist/Board/BoardCard.js.map +1 -0
  14. package/dist/Board/BoardLaneList.d.ts +103 -0
  15. package/dist/Board/BoardLaneList.d.ts.map +1 -0
  16. package/dist/Board/BoardLaneList.js +69 -0
  17. package/dist/Board/BoardLaneList.js.map +1 -0
  18. package/dist/Board/boardMove.d.ts +116 -0
  19. package/dist/Board/boardMove.d.ts.map +1 -0
  20. package/dist/Board/boardMove.js +116 -0
  21. package/dist/Board/boardMove.js.map +1 -0
  22. package/dist/Board/useBoardDrag.d.ts +44 -0
  23. package/dist/Board/useBoardDrag.d.ts.map +1 -0
  24. package/dist/Board/useBoardDrag.js +237 -0
  25. package/dist/Board/useBoardDrag.js.map +1 -0
  26. package/dist/Checkbox/Checkbox.d.ts.map +1 -1
  27. package/dist/Checkbox/Checkbox.js +15 -4
  28. package/dist/Checkbox/Checkbox.js.map +1 -1
  29. package/dist/DataTable/DataTable.d.ts +159 -0
  30. package/dist/DataTable/DataTable.d.ts.map +1 -0
  31. package/dist/DataTable/DataTable.js +197 -0
  32. package/dist/DataTable/DataTable.js.map +1 -0
  33. package/dist/DatePicker/DateGrid.d.ts +79 -0
  34. package/dist/DatePicker/DateGrid.d.ts.map +1 -0
  35. package/dist/DatePicker/DateGrid.js +220 -0
  36. package/dist/DatePicker/DateGrid.js.map +1 -0
  37. package/dist/DatePicker/DatePicker.d.ts +138 -0
  38. package/dist/DatePicker/DatePicker.d.ts.map +1 -0
  39. package/dist/DatePicker/DatePicker.js +384 -0
  40. package/dist/DatePicker/DatePicker.js.map +1 -0
  41. package/dist/DatePicker/datePresets.d.ts +29 -0
  42. package/dist/DatePicker/datePresets.d.ts.map +1 -0
  43. package/dist/DatePicker/datePresets.js +28 -0
  44. package/dist/DatePicker/datePresets.js.map +1 -0
  45. package/dist/DatePicker/parseDateInput.d.ts +106 -0
  46. package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
  47. package/dist/DatePicker/parseDateInput.js +419 -0
  48. package/dist/DatePicker/parseDateInput.js.map +1 -0
  49. package/dist/DatePicker/plainDate.d.ts +167 -0
  50. package/dist/DatePicker/plainDate.d.ts.map +1 -0
  51. package/dist/DatePicker/plainDate.js +272 -0
  52. package/dist/DatePicker/plainDate.js.map +1 -0
  53. package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
  54. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
  55. package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
  56. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
  57. package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
  58. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
  59. package/dist/MarkdownEditor/markdownCommands.js +379 -0
  60. package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
  61. package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
  62. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
  63. package/dist/MarkdownEditor/markdownSpans.js +303 -0
  64. package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
  65. package/dist/categoricalStyles.d.ts +53 -0
  66. package/dist/categoricalStyles.d.ts.map +1 -0
  67. package/dist/categoricalStyles.js +195 -0
  68. package/dist/categoricalStyles.js.map +1 -0
  69. package/dist/index.d.ts +22 -0
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +11 -0
  72. package/dist/index.js.map +1 -1
  73. package/package.json +2 -2
  74. package/src/Badge/Badge.mdx +46 -0
  75. package/src/Badge/Badge.stories.tsx +98 -4
  76. package/src/Badge/Badge.test.tsx +157 -2
  77. package/src/Badge/Badge.tsx +49 -3
  78. package/src/Board/Board.mdx +199 -0
  79. package/src/Board/Board.stories.tsx +707 -0
  80. package/src/Board/Board.test.tsx +310 -0
  81. package/src/Board/Board.tsx +333 -0
  82. package/src/Board/BoardCard.tsx +290 -0
  83. package/src/Board/BoardLaneList.tsx +275 -0
  84. package/src/Board/boardMove.test.ts +213 -0
  85. package/src/Board/boardMove.ts +225 -0
  86. package/src/Board/useBoardDrag.ts +413 -0
  87. package/src/Checkbox/Checkbox.tsx +34 -3
  88. package/src/DataTable/DataTable.mdx +123 -0
  89. package/src/DataTable/DataTable.stories.tsx +411 -0
  90. package/src/DataTable/DataTable.test.tsx +386 -0
  91. package/src/DataTable/DataTable.tsx +497 -0
  92. package/src/DatePicker/DateGrid.tsx +467 -0
  93. package/src/DatePicker/DatePicker.mdx +170 -0
  94. package/src/DatePicker/DatePicker.stories.tsx +268 -0
  95. package/src/DatePicker/DatePicker.test.tsx +455 -0
  96. package/src/DatePicker/DatePicker.tsx +852 -0
  97. package/src/DatePicker/datePresets.ts +34 -0
  98. package/src/DatePicker/parseDateInput.test.ts +187 -0
  99. package/src/DatePicker/parseDateInput.ts +629 -0
  100. package/src/DatePicker/plainDate.test.ts +375 -0
  101. package/src/DatePicker/plainDate.ts +465 -0
  102. package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
  103. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
  104. package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
  105. package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
  106. package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
  107. package/src/MarkdownEditor/markdownCommands.ts +566 -0
  108. package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
  109. package/src/MarkdownEditor/markdownSpans.ts +458 -0
  110. package/src/argTypes.storyHelpers.ts +39 -0
  111. package/src/categoricalStyles.ts +244 -0
  112. package/src/index.ts +89 -0
  113. package/src/sourceRules.test.ts +52 -1
@@ -0,0 +1,497 @@
1
+ // biome-ignore-all lint/a11y/noRedundantRoles: the roles are redundant only while the table has `display: table`, and the narrow layout takes that away — `block` on the table, `flex` on a cell. Table semantics have historically travelled with `display`, and the engine that matters most here is the one this sandbox cannot run: the consumer is driven from a tablet, so WebKit is the narrow layout's primary browser. Measured in this sandbox's chromium via CDP `Accessibility.getFullAXTree`, these roles change nothing (see the `ROW_GROUP_ROLE` note); a dropped role, in the one layout where the column headers are no longer on screen, turns the table into a stack of unrelated text. Redundant costs nothing, absent costs everything.
2
+
3
+ import type { ReactNode } from "react"
4
+ import { useEffect, useRef } from "react"
5
+
6
+ import { Checkbox } from "../Checkbox/Checkbox.tsx"
7
+ import type { SortDirection } from "../SortableTableHeader/SortableTableHeader.tsx"
8
+ import { SortableTableHeader } from "../SortableTableHeader/SortableTableHeader.tsx"
9
+ import { toClassName } from "../toClassName.ts"
10
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
11
+
12
+ export type DataTableColumn<Row> = {
13
+ /**
14
+ * Lands on this column's `<th>` **and** on every `<td>` under it,
15
+ * which is what makes it the place a width goes: `w-32` on one
16
+ * column is the only sizing this component has, and it is
17
+ * deliberately the consumer's to write.
18
+ */
19
+ className?: string
20
+ /**
21
+ * The column's name. Rendered twice on purpose — once in the
22
+ * header row, and once inside each cell as the label the stacked
23
+ * layout needs — so it has to be a node that can be rendered more
24
+ * than once. The in-cell copy is `aria-hidden`, because the
25
+ * `columnheader` association already says it to assistive
26
+ * technology.
27
+ */
28
+ header: ReactNode
29
+ /**
30
+ * Whether this column offers a sort control. A column without one
31
+ * renders a plain `<th>` with **no** `aria-sort` at all, which is
32
+ * the attribute's way of saying "this column cannot be sorted" —
33
+ * `"none"` would claim the opposite.
34
+ */
35
+ isSortable?: boolean
36
+ /** Stable id. Also the `columnKey` a sort request comes back with. */
37
+ key: string
38
+ renderCell: (row: Row) => ReactNode
39
+ }
40
+
41
+ export type DataTableSort = {
42
+ columnKey: string
43
+ direction: SortDirection
44
+ }
45
+
46
+ export type DataTableSelection<Row> = {
47
+ /**
48
+ * The accessible name of a row's checkbox. A tick box called
49
+ * "Select row" seven times over is seven identical controls in a
50
+ * screen reader's forms list, so this is required rather than
51
+ * defaulted — it is normally the same value the first column
52
+ * renders.
53
+ */
54
+ getRowLabel: (row: Row) => string
55
+ /** The whole next set, never a delta. */
56
+ onSelectionChange: (selectedRowKeys: string[]) => void
57
+ selectAllLabel?: string
58
+ selectedRowKeys: readonly string[]
59
+ }
60
+
61
+ export type DataTableProps<Row> = {
62
+ className?: string
63
+ columns: readonly DataTableColumn<Row>[]
64
+ /**
65
+ * What fills the table when `rows` is empty — an `EmptyState`,
66
+ * normally. Rendered in a `colSpan` cell **inside** the table, so
67
+ * the header row and the sort the reader just performed are still
68
+ * there. Swapping the whole table out for a message is how a
69
+ * filter that matches nothing takes the controls away with it.
70
+ */
71
+ emptyState?: ReactNode
72
+ getRowKey: (row: Row) => string
73
+ /**
74
+ * Shows the `<caption>`. Off by default: most tables sit under a
75
+ * heading the page already renders, and printing the name twice is
76
+ * how a table ends up with two titles.
77
+ */
78
+ isLabelVisible?: boolean
79
+ /**
80
+ * The table's accessible name, as a real `<caption>`. Required,
81
+ * because a screen reader listing "table with 6 columns, 40 rows"
82
+ * and no name is a table nobody can choose to enter.
83
+ */
84
+ label: string
85
+ onSortChange?: (sort: DataTableSort) => void
86
+ rows: readonly Row[]
87
+ selection?: DataTableSelection<Row>
88
+ /**
89
+ * Which column is sorted and which way — **the fact, not the
90
+ * doing.** The rows arrive already ordered; this only says which
91
+ * header lights up and what `aria-sort` announces.
92
+ */
93
+ sort?: DataTableSort
94
+ }
95
+
96
+ /**
97
+ * The cell padding, in one place because four elements have to agree
98
+ * on it: the sortable header's button (which brings its own, inside
99
+ * `SortableTableHeader`), the plain header, the body cell, and the
100
+ * empty-state cell.
101
+ */
102
+ const CELL_PADDING_CLASS = "px-3 py-2"
103
+
104
+ /**
105
+ * `role` on every part, and it is not belt-and-braces.
106
+ *
107
+ * The narrow layout takes `display` away from the table elements —
108
+ * `block` on the table, `grid` on the row, `flex` on the cell — and
109
+ * a table's semantics have historically travelled with its
110
+ * `display`. Measured in this sandbox's chromium via CDP
111
+ * (`Accessibility.getFullAXTree`), that engine now keeps
112
+ * `table` / `row` / `columnheader` / `cell` intact through
113
+ * `display: block`; the roles below are therefore a **no-op here**
114
+ * and insurance for the engines this sandbox cannot run. The one
115
+ * measured difference is `<tbody>`: chromium ignores it entirely
116
+ * until it is given `role="rowgroup"`, at which point it joins the
117
+ * tree as a rowgroup beside `<thead>`'s — which is what a table
118
+ * with two rowgroups is supposed to look like.
119
+ *
120
+ * The cost of being wrong in each direction is what settles it. A
121
+ * redundant role changes nothing anywhere. A dropped one turns the
122
+ * whole table into a stack of unrelated text in exactly the layout
123
+ * where the column headers are no longer on screen to read.
124
+ */
125
+ const ROW_GROUP_ROLE = "rowgroup"
126
+
127
+ /**
128
+ * **A table that reflows instead of scrolling, sized by its
129
+ * container.**
130
+ *
131
+ * ### It composes `SortableTableHeader`; it does not replace it
132
+ *
133
+ * The header cell shipped first, on its own, and stays on its own:
134
+ * it is already in a published 1.0.0 with a consumer of its own
135
+ * (mux-magic's `FileExplorerModal`), and subsuming it would be a
136
+ * breaking change bought for nothing. Every sortable column here
137
+ * renders that component, so there is exactly one place in the fleet
138
+ * that knows `aria-sort` belongs on the `<th>` while the click
139
+ * target is a `<button>` inside it, and one place to fix if that is
140
+ * ever wrong.
141
+ *
142
+ * That composition is also why this is a **real `<table>`**. A grid
143
+ * of `<div role="row">`s is the other reasonable choice, and it
144
+ * cannot host a `<th>` at all — so it would have meant either
145
+ * duplicating the header or breaking it.
146
+ *
147
+ * ### The narrow layout reflows; it never scrolls sideways
148
+ *
149
+ * Below `--cq-md` (32rem) each row stops being a row and becomes a
150
+ * labelled block — the column name beside its value, one pair per
151
+ * line — while the header row wraps into a strip of sort controls
152
+ * that stays reachable. Above it, a normal table.
153
+ *
154
+ * The two rejected alternatives are worth naming because both are
155
+ * common:
156
+ *
157
+ * - **Horizontal scroll.** WCAG 2.2 SC 1.4.10 (Reflow) exists
158
+ * because of it, and the consumer this was built for bans it
159
+ * outright ("No horizontal scroll, ever"). A pan surface also
160
+ * hides the *existence* of the columns off-screen.
161
+ * - **Dropping low-priority columns.** Cheapest to build and the
162
+ * only one that loses data: the assignee column does not come
163
+ * back, and there is no gesture that asks for it.
164
+ *
165
+ * ### Container queries, never a media query
166
+ *
167
+ * The width that matters is the element's, not the window's. A table
168
+ * in a three-up board lane is narrow on a 4K monitor, and the owner
169
+ * of this fleet browses zoomed in — a 1500px window at 175% zoom is
170
+ * ~860 effective pixels, so a `@media (max-width: …)` is measuring
171
+ * something that is not the question. The `@container` is declared
172
+ * on the wrapper and queried by its descendants, because a container
173
+ * query never matches the element that declares it.
174
+ *
175
+ * ### What it does not own
176
+ *
177
+ * **Sorting the data.** `sort` says which column is sorted;
178
+ * `onSortChange` asks for a different one. Comparing two values is
179
+ * per-column app logic — a date, a title, a priority enum with an
180
+ * order that is not alphabetical — and a table that sorted its own
181
+ * rows would have to be handed a comparator per column to do it,
182
+ * which is the same code in a worse place. The rows arrive ordered.
183
+ *
184
+ * **The selected set.** `selection.selectedRowKeys` is authoritative
185
+ * and lives with the consumer, because every use of a selection —
186
+ * the count in a toolbar, the bulk action, the undo — is outside
187
+ * this component. That is the opposite of `Checkbox`, and for a
188
+ * stated reason: the platform owns one checkbox's checkedness, and
189
+ * nothing at all owns "these five rows".
190
+ *
191
+ * **Column widths.** No drag handle, no measured layout. The browser's
192
+ * own auto table layout sizes columns to their content, which is a
193
+ * better algorithm than anything worth reimplementing, and
194
+ * `column.className` overrides it. Drag-to-resize is a *persistence*
195
+ * question (whose widths, stored where, keyed on what) that a
196
+ * component cannot answer for its consumer.
197
+ *
198
+ * **Virtualisation.** Deliberately absent; see the decision record.
199
+ * Every row is in the DOM, which is what keeps the accessibility
200
+ * tree honest and the auto column layout possible.
201
+ */
202
+ export const DataTable = <Row,>({
203
+ className,
204
+ columns,
205
+ emptyState,
206
+ getRowKey,
207
+ isLabelVisible = false,
208
+ label,
209
+ onSortChange,
210
+ rows,
211
+ selection,
212
+ sort,
213
+ }: DataTableProps<Row>): ReactNode => {
214
+ const tableRef = useRef<HTMLTableElement>(null)
215
+
216
+ const selectedRowKeys = selection?.selectedRowKeys
217
+
218
+ /**
219
+ * The checkboxes are **written to**, not re-mounted.
220
+ *
221
+ * `Checkbox` is uncontrolled by design — `isChecked` seeds the
222
+ * first paint and the `<input>` is the store from then on — so a
223
+ * consumer changing `selectedRowKeys` out from under it (a "clear
224
+ * selection" button, a filter that drops a selected row) would
225
+ * otherwise leave the tick behind. Re-mounting with a
226
+ * state-bearing `key` fixes the tick and throws the focus away
227
+ * mid-keyboard, one row into a `Space`-through-the-list.
228
+ *
229
+ * So the store gets written instead, which is also the only way to
230
+ * express the header box's third state: `indeterminate` is a DOM
231
+ * property with no HTML attribute at all, so there is no markup
232
+ * that could have set it.
233
+ *
234
+ * No dependency array: this has to run after every render that
235
+ * could have changed a row, and it is a `querySelectorAll` over
236
+ * the rows already on screen.
237
+ */
238
+ useEffect(() => {
239
+ const table = tableRef.current
240
+
241
+ if (!table || !selectedRowKeys) {
242
+ return
243
+ }
244
+
245
+ const selectedKeys = new Set(selectedRowKeys)
246
+
247
+ for (const input of table.querySelectorAll<HTMLInputElement>(
248
+ 'tbody input[type="checkbox"]',
249
+ )) {
250
+ input.checked = selectedKeys.has(input.value)
251
+ }
252
+
253
+ const selectAllInput =
254
+ table.querySelector<HTMLInputElement>(
255
+ 'thead input[type="checkbox"]',
256
+ )
257
+
258
+ if (selectAllInput) {
259
+ const selectedRowCount = rows.filter((row) =>
260
+ selectedKeys.has(getRowKey(row)),
261
+ ).length
262
+
263
+ selectAllInput.checked =
264
+ rows.length > 0 && selectedRowCount === rows.length
265
+
266
+ selectAllInput.indeterminate =
267
+ selectedRowCount > 0 &&
268
+ selectedRowCount < rows.length
269
+ }
270
+ })
271
+
272
+ const selectedKeys = new Set(selectedRowKeys ?? [])
273
+
274
+ const columnCount = columns.length + (selection ? 1 : 0)
275
+
276
+ return (
277
+ // The container is declared here and queried by everything
278
+ // below it. `className` stays on this element — the outermost
279
+ // box the component renders — which is what lets a caller give
280
+ // the table a width, a margin, or a grid placement.
281
+ <div className={toClassName("@container", className)}>
282
+ <table
283
+ className="block w-full border-collapse text-start text-content-primary text-md cq-md:table"
284
+ ref={tableRef}
285
+ role="table"
286
+ >
287
+ <caption
288
+ className={toClassName(
289
+ "text-start",
290
+ isLabelVisible
291
+ ? "pb-2 font-medium text-content-secondary text-sm"
292
+ : "sr-only",
293
+ )}
294
+ >
295
+ {label}
296
+ </caption>
297
+
298
+ <thead
299
+ className="block cq-md:table-header-group"
300
+ role={ROW_GROUP_ROLE}
301
+ >
302
+ {/* Narrow: a wrapping strip of sort controls, so sorting
303
+ survives the layout that has no header row. Wide: the
304
+ header row. */}
305
+ <tr
306
+ className="flex flex-wrap items-center gap-x-1 border-border-subtle border-b cq-md:table-row cq-md:border-b-0"
307
+ role="row"
308
+ >
309
+ {selection ? (
310
+ <th
311
+ className={toClassName(
312
+ "border-border-subtle p-2 text-start cq-md:table-cell cq-md:border-b",
313
+ )}
314
+ role="columnheader"
315
+ scope="col"
316
+ >
317
+ <Checkbox
318
+ label={
319
+ <VisuallyHidden>
320
+ {selection.selectAllLabel ??
321
+ "Select all rows"}
322
+ </VisuallyHidden>
323
+ }
324
+ onChange={(isChecked) => {
325
+ selection.onSelectionChange(
326
+ isChecked ? rows.map(getRowKey) : [],
327
+ )
328
+ }}
329
+ />
330
+ </th>
331
+ ) : null}
332
+
333
+ {columns.map((column) =>
334
+ column.isSortable ? (
335
+ <SortableTableHeader
336
+ // The header keeps its own `border-b` for the
337
+ // wide layout and loses it in the strip, where
338
+ // one underline per chip reads as five separate
339
+ // rules rather than one header edge.
340
+ className={toClassName(
341
+ "border-b-0 cq-md:border-b",
342
+ column.className,
343
+ )}
344
+ direction={
345
+ sort?.columnKey === column.key
346
+ ? sort.direction
347
+ : undefined
348
+ }
349
+ key={column.key}
350
+ onSort={(direction) => {
351
+ onSortChange?.({
352
+ columnKey: column.key,
353
+ direction,
354
+ })
355
+ }}
356
+ >
357
+ {column.header}
358
+ </SortableTableHeader>
359
+ ) : (
360
+ <th
361
+ className={toClassName(
362
+ "border-border-subtle text-start font-medium text-content-secondary text-sm uppercase tracking-wide cq-md:table-cell cq-md:border-b",
363
+ CELL_PADDING_CLASS,
364
+ column.className,
365
+ )}
366
+ key={column.key}
367
+ role="columnheader"
368
+ scope="col"
369
+ >
370
+ {column.header}
371
+ </th>
372
+ ),
373
+ )}
374
+ </tr>
375
+ </thead>
376
+
377
+ <tbody
378
+ className="flex flex-col gap-2 pt-2 cq-md:table-row-group cq-md:gap-0 cq-md:pt-0"
379
+ role={ROW_GROUP_ROLE}
380
+ >
381
+ {rows.length === 0 ? (
382
+ <tr
383
+ className="block cq-md:table-row"
384
+ role="row"
385
+ >
386
+ <td
387
+ className="block p-0 cq-md:table-cell"
388
+ colSpan={columnCount}
389
+ role="cell"
390
+ >
391
+ {emptyState}
392
+ </td>
393
+ </tr>
394
+ ) : (
395
+ rows.map((row) => {
396
+ const rowKey = getRowKey(row)
397
+
398
+ const isSelected = selectedKeys.has(rowKey)
399
+
400
+ return (
401
+ <tr
402
+ className={toClassName(
403
+ // Narrow: a card. Wide: a row with a hairline
404
+ // under it. Both states are written here rather
405
+ // than in a stylesheet so a reader can see the
406
+ // whole of a row's shape in one place.
407
+ "flex flex-col gap-1 rounded-lg border border-border-subtle p-3 transition-colors duration-(--duration-fast) ease-standard",
408
+ "cq-md:table-row cq-md:gap-0 cq-md:rounded-none cq-md:border-0 cq-md:border-border-subtle cq-md:border-b cq-md:p-0",
409
+ isSelected
410
+ ? "bg-intent-accent-surface"
411
+ : "bg-surface-raised hover:bg-intent-neutral-surface cq-md:bg-transparent",
412
+ )}
413
+ // Not `aria-selected`: that property belongs to
414
+ // `grid`/`treegrid` rows, and this is a `table`.
415
+ // The row's own checkbox is what states the fact
416
+ // to assistive technology, and it is a control
417
+ // that can also change it.
418
+ data-is-selected={isSelected}
419
+ key={rowKey}
420
+ role="row"
421
+ >
422
+ {selection ? (
423
+ <td
424
+ className="block p-2 cq-md:table-cell cq-md:align-middle"
425
+ role="cell"
426
+ >
427
+ <Checkbox
428
+ isChecked={isSelected}
429
+ label={
430
+ <VisuallyHidden>
431
+ {selection.getRowLabel(row)}
432
+ </VisuallyHidden>
433
+ }
434
+ onChange={(isChecked) => {
435
+ selection.onSelectionChange(
436
+ isChecked
437
+ ? [
438
+ ...selection.selectedRowKeys,
439
+ rowKey,
440
+ ]
441
+ : selection.selectedRowKeys.filter(
442
+ (one) => one !== rowKey,
443
+ ),
444
+ )
445
+ }}
446
+ value={rowKey}
447
+ />
448
+ </td>
449
+ ) : null}
450
+
451
+ {columns.map((column) => (
452
+ <td
453
+ className={toClassName(
454
+ "flex items-baseline gap-3 py-0.5 cq-md:table-cell cq-md:align-middle",
455
+ CELL_PADDING_CLASS,
456
+ "cq-md:py-2",
457
+ column.className,
458
+ )}
459
+ key={column.key}
460
+ role="cell"
461
+ >
462
+ {/* The column name, in the cell, for the
463
+ layout where the header row is no longer
464
+ above it. `aria-hidden` because the
465
+ `columnheader` association already says
466
+ this — announcing both is the column read
467
+ out twice per cell. */}
468
+ <span
469
+ aria-hidden="true"
470
+ className="w-20 shrink-0 truncate text-content-secondary text-xs uppercase tracking-wide cq-sm:w-24 cq-md:hidden"
471
+ >
472
+ {column.header}
473
+ </span>
474
+
475
+ {/* `wrap-anywhere` only while stacked. It sets
476
+ the min-content width to one character, which
477
+ is what a narrow cell needs and what would
478
+ wreck the wide layout: the browser's auto
479
+ table algorithm sizes a column from its
480
+ min-content width, so a column of
481
+ break-anywhere text asks for almost nothing
482
+ and gets it — a title column one word wide
483
+ with "extract or fan filter" underneath. */}
484
+ <span className="min-w-0 flex-1 wrap-anywhere cq-md:wrap-normal">
485
+ {column.renderCell(row)}
486
+ </span>
487
+ </td>
488
+ ))}
489
+ </tr>
490
+ )
491
+ })
492
+ )}
493
+ </tbody>
494
+ </table>
495
+ </div>
496
+ )
497
+ }