@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.
- package/README.md +10 -0
- package/dist/Badge/Badge.d.ts +33 -3
- package/dist/Badge/Badge.d.ts.map +1 -1
- package/dist/Badge/Badge.js +20 -2
- package/dist/Badge/Badge.js.map +1 -1
- package/dist/Board/Board.d.ts +119 -0
- package/dist/Board/Board.d.ts.map +1 -0
- package/dist/Board/Board.js +165 -0
- package/dist/Board/Board.js.map +1 -0
- package/dist/Board/BoardCard.d.ts +120 -0
- package/dist/Board/BoardCard.d.ts.map +1 -0
- package/dist/Board/BoardCard.js +77 -0
- package/dist/Board/BoardCard.js.map +1 -0
- package/dist/Board/BoardLaneList.d.ts +103 -0
- package/dist/Board/BoardLaneList.d.ts.map +1 -0
- package/dist/Board/BoardLaneList.js +69 -0
- package/dist/Board/BoardLaneList.js.map +1 -0
- package/dist/Board/boardMove.d.ts +116 -0
- package/dist/Board/boardMove.d.ts.map +1 -0
- package/dist/Board/boardMove.js +116 -0
- package/dist/Board/boardMove.js.map +1 -0
- package/dist/Board/useBoardDrag.d.ts +44 -0
- package/dist/Board/useBoardDrag.d.ts.map +1 -0
- package/dist/Board/useBoardDrag.js +237 -0
- package/dist/Board/useBoardDrag.js.map +1 -0
- package/dist/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox/Checkbox.js +15 -4
- package/dist/Checkbox/Checkbox.js.map +1 -1
- package/dist/DataTable/DataTable.d.ts +159 -0
- package/dist/DataTable/DataTable.d.ts.map +1 -0
- package/dist/DataTable/DataTable.js +197 -0
- package/dist/DataTable/DataTable.js.map +1 -0
- package/dist/DatePicker/DateGrid.d.ts +79 -0
- package/dist/DatePicker/DateGrid.d.ts.map +1 -0
- package/dist/DatePicker/DateGrid.js +220 -0
- package/dist/DatePicker/DateGrid.js.map +1 -0
- package/dist/DatePicker/DatePicker.d.ts +138 -0
- package/dist/DatePicker/DatePicker.d.ts.map +1 -0
- package/dist/DatePicker/DatePicker.js +384 -0
- package/dist/DatePicker/DatePicker.js.map +1 -0
- package/dist/DatePicker/datePresets.d.ts +29 -0
- package/dist/DatePicker/datePresets.d.ts.map +1 -0
- package/dist/DatePicker/datePresets.js +28 -0
- package/dist/DatePicker/datePresets.js.map +1 -0
- package/dist/DatePicker/parseDateInput.d.ts +106 -0
- package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
- package/dist/DatePicker/parseDateInput.js +419 -0
- package/dist/DatePicker/parseDateInput.js.map +1 -0
- package/dist/DatePicker/plainDate.d.ts +167 -0
- package/dist/DatePicker/plainDate.d.ts.map +1 -0
- package/dist/DatePicker/plainDate.js +272 -0
- package/dist/DatePicker/plainDate.js.map +1 -0
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
- package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.js +379 -0
- package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.js +303 -0
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
- package/dist/categoricalStyles.d.ts +53 -0
- package/dist/categoricalStyles.d.ts.map +1 -0
- package/dist/categoricalStyles.js +195 -0
- package/dist/categoricalStyles.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Badge/Badge.mdx +46 -0
- package/src/Badge/Badge.stories.tsx +98 -4
- package/src/Badge/Badge.test.tsx +157 -2
- package/src/Badge/Badge.tsx +49 -3
- package/src/Board/Board.mdx +199 -0
- package/src/Board/Board.stories.tsx +707 -0
- package/src/Board/Board.test.tsx +310 -0
- package/src/Board/Board.tsx +333 -0
- package/src/Board/BoardCard.tsx +290 -0
- package/src/Board/BoardLaneList.tsx +275 -0
- package/src/Board/boardMove.test.ts +213 -0
- package/src/Board/boardMove.ts +225 -0
- package/src/Board/useBoardDrag.ts +413 -0
- package/src/Checkbox/Checkbox.tsx +34 -3
- package/src/DataTable/DataTable.mdx +123 -0
- package/src/DataTable/DataTable.stories.tsx +411 -0
- package/src/DataTable/DataTable.test.tsx +386 -0
- package/src/DataTable/DataTable.tsx +497 -0
- package/src/DatePicker/DateGrid.tsx +467 -0
- package/src/DatePicker/DatePicker.mdx +170 -0
- package/src/DatePicker/DatePicker.stories.tsx +268 -0
- package/src/DatePicker/DatePicker.test.tsx +455 -0
- package/src/DatePicker/DatePicker.tsx +852 -0
- package/src/DatePicker/datePresets.ts +34 -0
- package/src/DatePicker/parseDateInput.test.ts +187 -0
- package/src/DatePicker/parseDateInput.ts +629 -0
- package/src/DatePicker/plainDate.test.ts +375 -0
- package/src/DatePicker/plainDate.ts +465 -0
- package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
- package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
- package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
- package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
- package/src/MarkdownEditor/markdownCommands.ts +566 -0
- package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
- package/src/MarkdownEditor/markdownSpans.ts +458 -0
- package/src/argTypes.storyHelpers.ts +39 -0
- package/src/categoricalStyles.ts +244 -0
- package/src/index.ts +89 -0
- 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
|
+
}
|