@uniflowed/ui 0.0.0-alpha.10
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/accordion.js +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
package/table.js
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A table, and the four things about one a caller cannot get right by hand.
|
|
4
|
+
//
|
|
5
|
+
// The markup is not one of them. A `<table>` with `<th scope="col">` and a
|
|
6
|
+
// `<caption>` is already accessible, and a component that only renames those
|
|
7
|
+
// elements has added a dependency and no behaviour. What this owns is the part
|
|
8
|
+
// that is invisible until somebody uses a screen reader on it:
|
|
9
|
+
//
|
|
10
|
+
// * **Sorting.** `aria-sort` on exactly one header, the header's content in a
|
|
11
|
+
// button so the sort is reachable at all, and the re-order *announced* —
|
|
12
|
+
// because the rows change places and a screen reader is told nothing.
|
|
13
|
+
// * **Selection.** A header checkbox that is `mixed` when some rows are
|
|
14
|
+
// chosen, and a row checkbox whose name says which row.
|
|
15
|
+
// * **Counting.** `aria-rowcount` and `aria-rowindex`, so a reader on page
|
|
16
|
+
// four is not told "row 3 of 10".
|
|
17
|
+
// * **Pagination**, which is `pagination.js` next door.
|
|
18
|
+
//
|
|
19
|
+
// # `table`, not `grid`, and no opt-in
|
|
20
|
+
//
|
|
21
|
+
// "Make it a `role="grid"`" is the advice that circulates and it is usually
|
|
22
|
+
// wrong. `grid` takes the arrow keys away from the reader and gives them to
|
|
23
|
+
// the component: in a grid, arrows move between cells, which is right for a
|
|
24
|
+
// spreadsheet and wrong for a list of records — because a screen reader
|
|
25
|
+
// already has its own table-reading commands, they work perfectly on a plain
|
|
26
|
+
// `<table>`, and readers rely on them.
|
|
27
|
+
//
|
|
28
|
+
// So this is a real `<table>` and there is no `role="grid"` flag. A flag would
|
|
29
|
+
// be a stub: a grid is not an attribute, it is a two-dimensional keyboard
|
|
30
|
+
// contract — focus in the cells, `Ctrl+Home` to the first cell, `PageUp` and
|
|
31
|
+
// `PageDown` by a screenful — and `role="grid"` without it is strictly worse
|
|
32
|
+
// than what it replaced, because the reader is told the arrow keys will do
|
|
33
|
+
// something and they do nothing. An editable grid is a component of its own and
|
|
34
|
+
// is worth writing as one when somebody needs it.
|
|
35
|
+
//
|
|
36
|
+
// # `DataTable` is not here either, and that is the same decision shadcn made
|
|
37
|
+
//
|
|
38
|
+
// The benchmark ships a *guide* rather than a component — "instead of a
|
|
39
|
+
// data-table component, I thought it would be more helpful to provide a guide
|
|
40
|
+
// on how to build your own" — and composes TanStack Table with its `Table`. uf
|
|
41
|
+
// has no TanStack Table equivalent: `@uniflowed/query` is the fetching layer,
|
|
42
|
+
// not table state. So shipping a `DataTable` would mean first shipping a
|
|
43
|
+
// headless table-state library, which is a real project and a different one.
|
|
44
|
+
//
|
|
45
|
+
// This module is the half that is uf's to own: the accessibility of a table
|
|
46
|
+
// whose state somebody else holds. What is above is deliberate rather than
|
|
47
|
+
// unfinished, and `crates/uf_lib/src/ui.rs` says so beside the entry.
|
|
48
|
+
//
|
|
49
|
+
// # Where the announcement lives
|
|
50
|
+
//
|
|
51
|
+
// `Table.Root` renders the `<table>` *and* a live region after it, as
|
|
52
|
+
// siblings, and the region is there from the first render holding nothing.
|
|
53
|
+
// That is the rule `combobox.js` states and `toast.js` is built on: a live
|
|
54
|
+
// region added in the same commit as its text is not announced, because the
|
|
55
|
+
// technology watching it had nothing to watch.
|
|
56
|
+
//
|
|
57
|
+
// It is rendered by the root rather than offered as a part a caller places,
|
|
58
|
+
// because a sort that announces nothing is the failure this component exists
|
|
59
|
+
// to prevent and a part is a thing somebody forgets. The cost is one extra
|
|
60
|
+
// element in the caller's layout, and a sentence that is visible until they
|
|
61
|
+
// style it — which is not a bad thing to see, and a table that shows its sort
|
|
62
|
+
// status in words is a table more people can use.
|
|
63
|
+
|
|
64
|
+
"use client";
|
|
65
|
+
|
|
66
|
+
import * as React from "@uniflowed/react";
|
|
67
|
+
import { createContext, useContext, useEffect, useId, useMemo, useState } from "@uniflowed/react";
|
|
68
|
+
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
69
|
+
|
|
70
|
+
import { Checkbox } from "./checkbox.js";
|
|
71
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
72
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
73
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
74
|
+
|
|
75
|
+
/** Which column a table is sorted by, and which way. */
|
|
76
|
+
export type Sort = {|
|
|
77
|
+
readonly column: string,
|
|
78
|
+
readonly direction: "ascending" | "descending",
|
|
79
|
+
|};
|
|
80
|
+
|
|
81
|
+
type TableState = {|
|
|
82
|
+
readonly sort: Sort | null,
|
|
83
|
+
readonly setSort: (sort: Sort | null) => void,
|
|
84
|
+
/** How many rows the whole set has, which is not how many are rendered. */
|
|
85
|
+
readonly rowCount: number | null,
|
|
86
|
+
/** Where the rendered rows start in that set, counting from zero. */
|
|
87
|
+
readonly rowOffset: number,
|
|
88
|
+
readonly headerRows: number,
|
|
89
|
+
readonly registerHeader: (present: boolean) => void,
|
|
90
|
+
readonly captionId: string,
|
|
91
|
+
readonly captioned: boolean,
|
|
92
|
+
readonly registerCaption: (present: boolean) => void,
|
|
93
|
+
/** What each sortable column is called, for the announcement. */
|
|
94
|
+
readonly labels: { readonly [string]: string },
|
|
95
|
+
readonly registerLabel: (column: string, label: string) => void,
|
|
96
|
+
|};
|
|
97
|
+
|
|
98
|
+
const TableContext: React.Context<TableState | null> = createContext(null);
|
|
99
|
+
|
|
100
|
+
/** Whether the rows below are header rows, which decides `th` versus `td`. */
|
|
101
|
+
const HeaderContext: React.Context<boolean> = createContext(false);
|
|
102
|
+
|
|
103
|
+
hook useTable(part: string): TableState {
|
|
104
|
+
const state = useContext(TableContext);
|
|
105
|
+
if (state == null) {
|
|
106
|
+
throw new Error(`${part} must be rendered inside a Table.Root`);
|
|
107
|
+
}
|
|
108
|
+
return state;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The table, and the region that announces what happens to it.
|
|
113
|
+
*
|
|
114
|
+
* `rowCount` is how many rows the *whole* set has — five hundred people, not
|
|
115
|
+
* the ten on this page — and the component adds the header row to reach
|
|
116
|
+
* `aria-rowcount`, which ARIA defines as every row in the table. Doing that
|
|
117
|
+
* arithmetic here rather than asking the caller for `501` is the point of
|
|
118
|
+
* having a component: `aria-rowcount` is the single most commonly missing
|
|
119
|
+
* attribute in data tables, and the reason is that nobody wants to think about
|
|
120
|
+
* whether the header counts.
|
|
121
|
+
*
|
|
122
|
+
* `announceSort` is the wording of the announcement, for an application with a
|
|
123
|
+
* translation table. The default is English.
|
|
124
|
+
*/
|
|
125
|
+
export component TableRoot(
|
|
126
|
+
children: React.Node,
|
|
127
|
+
sort?: Sort | null,
|
|
128
|
+
defaultSort?: Sort | null = null,
|
|
129
|
+
onSortChange?: (sort: Sort | null) => void,
|
|
130
|
+
rowCount?: number | null = null,
|
|
131
|
+
rowOffset?: number = 0,
|
|
132
|
+
announceSort?: (column: string, direction: "ascending" | "descending") => string,
|
|
133
|
+
...rest: Rest
|
|
134
|
+
) {
|
|
135
|
+
const base = useId();
|
|
136
|
+
const [current, setCurrent] = useControlled(sort, defaultSort, onSortChange);
|
|
137
|
+
const [headerRows, setHeaderRows] = useState(0);
|
|
138
|
+
const [captioned, setCaptioned] = useState(false);
|
|
139
|
+
const [labels, setLabels] = useState<{ readonly [string]: string }>({});
|
|
140
|
+
|
|
141
|
+
const registerHeader = useStableCallback((present: boolean) => {
|
|
142
|
+
setHeaderRows(present ? 1 : 0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Only ever added to. A column that has been rendered once keeps its name,
|
|
146
|
+
// so a sort applied from outside — a URL, a saved preference — is announced
|
|
147
|
+
// with the column's own words rather than with its key.
|
|
148
|
+
const registerLabel = useStableCallback((column: string, label: string) => {
|
|
149
|
+
setLabels((held) => (held[column] === label ? held : { ...held, [column]: label }));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const state = useMemo(
|
|
153
|
+
() => ({
|
|
154
|
+
sort: current,
|
|
155
|
+
setSort: setCurrent,
|
|
156
|
+
rowCount,
|
|
157
|
+
rowOffset,
|
|
158
|
+
headerRows,
|
|
159
|
+
registerHeader,
|
|
160
|
+
captionId: `${base}-caption`,
|
|
161
|
+
captioned,
|
|
162
|
+
registerCaption: setCaptioned,
|
|
163
|
+
labels,
|
|
164
|
+
registerLabel,
|
|
165
|
+
}),
|
|
166
|
+
[
|
|
167
|
+
base,
|
|
168
|
+
current,
|
|
169
|
+
setCurrent,
|
|
170
|
+
rowCount,
|
|
171
|
+
rowOffset,
|
|
172
|
+
headerRows,
|
|
173
|
+
registerHeader,
|
|
174
|
+
captioned,
|
|
175
|
+
labels,
|
|
176
|
+
registerLabel,
|
|
177
|
+
],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const message =
|
|
181
|
+
current == null
|
|
182
|
+
? ""
|
|
183
|
+
: (announceSort ?? defaultAnnouncement)(
|
|
184
|
+
labels[current.column] ?? current.column,
|
|
185
|
+
current.direction,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<TableContext.Provider value={state}>
|
|
190
|
+
<table
|
|
191
|
+
{...rest}
|
|
192
|
+
// The caption is a `<table>`'s accessible name in HTML-AAM already;
|
|
193
|
+
// naming it again here is what turns "the host will probably do this"
|
|
194
|
+
// into something this component promises, and it costs nothing because
|
|
195
|
+
// both point at the same words. Only while a caption is rendered, for
|
|
196
|
+
// the reason every part of this package repeats.
|
|
197
|
+
aria-labelledby={captioned ? `${base}-caption` : undefined}
|
|
198
|
+
// Every row in the table, which is the data plus the header — the
|
|
199
|
+
// arithmetic a caller should not have to remember.
|
|
200
|
+
aria-rowcount={rowCount == null ? undefined : rowCount + headerRows}
|
|
201
|
+
>
|
|
202
|
+
{children}
|
|
203
|
+
</table>
|
|
204
|
+
{/*
|
|
205
|
+
Beside the table rather than inside it, because a `<table>` may only
|
|
206
|
+
contain a caption, column groups and row groups — and mounted from the
|
|
207
|
+
first render, holding nothing, because a live region that appears with
|
|
208
|
+
its text is a live region that says nothing.
|
|
209
|
+
*/}
|
|
210
|
+
<div aria-atomic="true" aria-live="polite" data-uf-table-status="" role="status">
|
|
211
|
+
{message}
|
|
212
|
+
</div>
|
|
213
|
+
</TableContext.Provider>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The table's name.
|
|
219
|
+
*
|
|
220
|
+
* A real `<caption>`, which is what gives a `<table>` its accessible name and
|
|
221
|
+
* what a screen reader reads when a reader lands on it. A heading above the
|
|
222
|
+
* table looks the same and is not the table's name.
|
|
223
|
+
*/
|
|
224
|
+
export component TableCaption(children: React.Node, ...rest: Rest) {
|
|
225
|
+
const table = useTable("Table.Caption");
|
|
226
|
+
const register = table.registerCaption;
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
register(true);
|
|
230
|
+
return () => register(false);
|
|
231
|
+
}, [register]);
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<caption {...rest} id={table.captionId}>
|
|
235
|
+
{children}
|
|
236
|
+
</caption>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The header rows.
|
|
242
|
+
*
|
|
243
|
+
* It tells the root that it exists, because `aria-rowcount` and every row's
|
|
244
|
+
* `aria-rowindex` count header rows and a table without one counts differently.
|
|
245
|
+
*/
|
|
246
|
+
export component TableHeader(children: React.Node, ...rest: Rest) {
|
|
247
|
+
const table = useTable("Table.Header");
|
|
248
|
+
const register = table.registerHeader;
|
|
249
|
+
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
register(true);
|
|
252
|
+
return () => register(false);
|
|
253
|
+
}, [register]);
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<HeaderContext.Provider value={true}>
|
|
257
|
+
<thead {...rest}>{children}</thead>
|
|
258
|
+
</HeaderContext.Provider>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The data rows. */
|
|
263
|
+
export component TableBody(children: React.Node, ...rest: Rest) {
|
|
264
|
+
return (
|
|
265
|
+
<HeaderContext.Provider value={false}>
|
|
266
|
+
<tbody {...rest}>{children}</tbody>
|
|
267
|
+
</HeaderContext.Provider>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* One row, and its real position in the whole set.
|
|
273
|
+
*
|
|
274
|
+
* `index` counts from zero within the rows that are rendered — the index a
|
|
275
|
+
* caller already has from mapping this page — and the component turns it into
|
|
276
|
+
* `aria-rowindex`, which counts from one across every row of the table
|
|
277
|
+
* including the header. Ten rows of five hundred on page ten are rows 92 to
|
|
278
|
+
* 101 and the component works that out; a caller who had to would get it wrong
|
|
279
|
+
* once and never find out, because a reader on page four being told "row 3 of
|
|
280
|
+
* 10" looks exactly like a reader being told the truth.
|
|
281
|
+
*
|
|
282
|
+
* Only when the table was given a `rowCount`. A table showing everything it
|
|
283
|
+
* has needs neither attribute — the browser counts the rows itself — and
|
|
284
|
+
* adding them anyway is a second source of truth that can disagree with the
|
|
285
|
+
* document.
|
|
286
|
+
*/
|
|
287
|
+
export component TableRow(children: React.Node, index?: number | null = null, ...rest: Rest) {
|
|
288
|
+
const table = useTable("Table.Row");
|
|
289
|
+
const header = useContext(HeaderContext);
|
|
290
|
+
const counted = table.rowCount != null;
|
|
291
|
+
|
|
292
|
+
// An `if` chain rather than a `match`, because matching on a boolean is a
|
|
293
|
+
// `match` whose subject carries none of the information.
|
|
294
|
+
let rowIndex;
|
|
295
|
+
if (!counted) {
|
|
296
|
+
rowIndex = undefined;
|
|
297
|
+
} else if (header) {
|
|
298
|
+
rowIndex = 1;
|
|
299
|
+
} else {
|
|
300
|
+
rowIndex = table.headerRows + table.rowOffset + (index ?? 0) + 1;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<tr {...rest} aria-rowindex={rowIndex}>
|
|
305
|
+
{children}
|
|
306
|
+
</tr>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A column header, and the sort control when the column has one.
|
|
312
|
+
*
|
|
313
|
+
* `scope="col"` always: it is what tells a screen reader which cells this
|
|
314
|
+
* header names, and it is one attribute that turns a grid of text into a table
|
|
315
|
+
* a reader can navigate.
|
|
316
|
+
*
|
|
317
|
+
* Given a `column`, the header's content becomes a `button`, because a sort a
|
|
318
|
+
* reader cannot reach with the keyboard is a sort half the readers do not have.
|
|
319
|
+
* `aria-sort` then appears on **this header only when it is the sorted one**.
|
|
320
|
+
* Not `"none"` on the others: eleven headers each announcing "not sorted" is
|
|
321
|
+
* eleven announcements of nothing, on every pass through the table.
|
|
322
|
+
*/
|
|
323
|
+
export component TableHead(children: React.Node, column?: string | null = null, ...rest: Rest) {
|
|
324
|
+
const table = useTable("Table.Head");
|
|
325
|
+
const passed = withoutComposed(rest, column == null ? [] : ["onClick", "ref"]);
|
|
326
|
+
const sorted = column != null && table.sort?.column === column;
|
|
327
|
+
const register = table.registerLabel;
|
|
328
|
+
const [element, setElement] = useState<HTMLElement | null>(null);
|
|
329
|
+
|
|
330
|
+
// What the column is called, for the announcement — read from the element
|
|
331
|
+
// rather than from `children`, which may be an icon beside a word or a
|
|
332
|
+
// caller's own component and is not a string anybody can rely on.
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (column == null || element == null) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const label = (element.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
338
|
+
if (label !== "") {
|
|
339
|
+
register(column, label);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (column == null) {
|
|
344
|
+
return (
|
|
345
|
+
<th {...rest} scope="col">
|
|
346
|
+
{children}
|
|
347
|
+
</th>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<th
|
|
353
|
+
{...passed}
|
|
354
|
+
aria-sort={sorted ? table.sort?.direction : undefined}
|
|
355
|
+
ref={composeRefs(rest.ref, setElement)}
|
|
356
|
+
scope="col"
|
|
357
|
+
>
|
|
358
|
+
<button
|
|
359
|
+
onClick={composeHandlers(rest.onClick, () => {
|
|
360
|
+
// Two states, not three. A sort that cycles back to "unsorted" gives
|
|
361
|
+
// a reader a third press whose result is a table in an order nobody
|
|
362
|
+
// asked for.
|
|
363
|
+
table.setSort({
|
|
364
|
+
column,
|
|
365
|
+
direction: sorted && table.sort?.direction === "ascending" ? "descending" : "ascending",
|
|
366
|
+
});
|
|
367
|
+
})}
|
|
368
|
+
type="button"
|
|
369
|
+
>
|
|
370
|
+
{children}
|
|
371
|
+
</button>
|
|
372
|
+
</th>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** One cell. */
|
|
377
|
+
export component TableCell(children: React.Node, ...rest: Rest) {
|
|
378
|
+
return <td {...rest}>{children}</td>;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A row header: the cell that says which row this is.
|
|
383
|
+
*
|
|
384
|
+
* `scope="row"` is the other half of `scope="col"`, and it is what lets a
|
|
385
|
+
* screen reader say "Ada Lovelace, born 1815" instead of "1815" when a reader
|
|
386
|
+
* moves down the year column. A table of records usually has one and almost
|
|
387
|
+
* never marks it.
|
|
388
|
+
*/
|
|
389
|
+
export component TableRowHeader(children: React.Node, ...rest: Rest) {
|
|
390
|
+
return (
|
|
391
|
+
<th {...rest} scope="row">
|
|
392
|
+
{children}
|
|
393
|
+
</th>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The "select all" checkbox.
|
|
399
|
+
*
|
|
400
|
+
* `checked` is `boolean | "mixed"` and that is the whole reason this part
|
|
401
|
+
* exists. `checkbox.js` was written for it:
|
|
402
|
+
*
|
|
403
|
+
* > a half-selected "select all" that clears itself on the first click is the
|
|
404
|
+
* > behaviour every table in every application gets wrong.
|
|
405
|
+
*
|
|
406
|
+
* A `boolean` prop would let a caller pass `false` for "two of three rows are
|
|
407
|
+
* selected", and a reader would be told nothing is selected while three
|
|
408
|
+
* checkboxes below say otherwise. The union makes the third state a case the
|
|
409
|
+
* caller has to answer rather than one they can fail to notice, and choosing a
|
|
410
|
+
* mixed box reports `true` — select all, which is what a reader expects it to
|
|
411
|
+
* move to.
|
|
412
|
+
*
|
|
413
|
+
* # Why this takes named props and not `...rest: Rest`
|
|
414
|
+
*
|
|
415
|
+
* Every other part in this package ends with `...rest: Rest` and spreads it
|
|
416
|
+
* onto an intrinsic. This one renders a `Checkbox`, which is a *typed*
|
|
417
|
+
* component, and `Rest`'s `mixed` indexer cannot promise that `indeterminate`
|
|
418
|
+
* is a boolean — `uf check` says so, and it is right to. `Rest` is the type of
|
|
419
|
+
* props on their way onto an element whose own props are unchecked, which
|
|
420
|
+
* `merge-props.js` explains; it is not a way to pass anything to anything.
|
|
421
|
+
*
|
|
422
|
+
* So the props are written out. `className` is here because styling is what a
|
|
423
|
+
* caller actually needs to pass; anything more than that is a sign the caller
|
|
424
|
+
* wants a `Checkbox` of their own, which they should write — this part exists
|
|
425
|
+
* for the type of `checked`, not for the markup.
|
|
426
|
+
*/
|
|
427
|
+
export component TableSelectAll(
|
|
428
|
+
checked: boolean | "mixed",
|
|
429
|
+
onCheckedChange: (checked: boolean) => void,
|
|
430
|
+
label?: string = "Select all rows",
|
|
431
|
+
className?: string,
|
|
432
|
+
disabled?: boolean = false,
|
|
433
|
+
) {
|
|
434
|
+
return (
|
|
435
|
+
<Checkbox
|
|
436
|
+
aria-label={label}
|
|
437
|
+
checked={checked === "mixed" ? false : checked}
|
|
438
|
+
className={className}
|
|
439
|
+
disabled={disabled}
|
|
440
|
+
indeterminate={checked === "mixed"}
|
|
441
|
+
onCheckedChange={onCheckedChange}
|
|
442
|
+
/>
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* One row's checkbox.
|
|
448
|
+
*
|
|
449
|
+
* `label` is required, and requiring it is the point. "Select row" repeated
|
|
450
|
+
* forty times is forty identical announcements, and a reader moving through
|
|
451
|
+
* the column hears the same three words with no way to tell which row they are
|
|
452
|
+
* on. `label={`Select ${person.name}`}` is the difference, and it is the sort
|
|
453
|
+
* of thing a component can require and a guide can only suggest.
|
|
454
|
+
*
|
|
455
|
+
* Named props rather than `...rest: Rest`, for the reason `Table.SelectAll`
|
|
456
|
+
* gives above.
|
|
457
|
+
*/
|
|
458
|
+
export component TableRowSelect(
|
|
459
|
+
label: string,
|
|
460
|
+
checked: boolean,
|
|
461
|
+
onCheckedChange: (checked: boolean) => void,
|
|
462
|
+
className?: string,
|
|
463
|
+
disabled?: boolean = false,
|
|
464
|
+
) {
|
|
465
|
+
return (
|
|
466
|
+
<Checkbox
|
|
467
|
+
aria-label={label}
|
|
468
|
+
checked={checked}
|
|
469
|
+
className={className}
|
|
470
|
+
disabled={disabled}
|
|
471
|
+
onCheckedChange={onCheckedChange}
|
|
472
|
+
/>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** The wording used when the caller supplies none. */
|
|
477
|
+
function defaultAnnouncement(column: string, direction: "ascending" | "descending"): string {
|
|
478
|
+
return `Sorted by ${column}, ${direction}.`;
|
|
479
|
+
}
|