@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/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
+ }