@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.41

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