@uniflowed/ui 0.0.0-alpha.6 → 0.0.0-alpha.8
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/collapsible.js +147 -0
- package/combobox.js +14 -1
- package/index.js +405 -12
- package/internal/disclosure.js +97 -0
- package/internal/form-value.js +83 -0
- package/internal/merge-props.js +33 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +205 -11
- package/menu.js +29 -4
- package/navigation-menu.js +251 -0
- package/package.json +17 -4
- package/pagination.js +197 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/select.js +855 -0
- package/slider.js +405 -0
- package/table.js +479 -0
- package/tabs.js +13 -16
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
package/index.js
CHANGED
|
@@ -50,9 +50,27 @@
|
|
|
50
50
|
// Where a component has to learn something the DOM knows — how many options a
|
|
51
51
|
// caller filtered down to, which item the arrow key should move to — it reads
|
|
52
52
|
// the document in an effect or an event handler and, if a render depends on
|
|
53
|
-
// the answer, puts it in state.
|
|
54
|
-
//
|
|
55
|
-
// render is the thing
|
|
53
|
+
// the answer, puts it in state. That is deliberately *not*
|
|
54
|
+
// `useSyncExternalStore`: the DOM is not a store whose value a render may
|
|
55
|
+
// read, and reading layout during a render is the thing that API exists to
|
|
56
|
+
// prevent.
|
|
57
|
+
//
|
|
58
|
+
// One component does use it, and it is the case the API is actually for.
|
|
59
|
+
// `toast("Saved")` is called from an event handler or a `catch`, so the queue
|
|
60
|
+
// of notifications lives outside React — a store at module scope in
|
|
61
|
+
// `toast.js`, read through `useSyncExternalStore` with the cached immutable
|
|
62
|
+
// snapshots and the consistent server snapshot that requires. A queue is a
|
|
63
|
+
// store; the DOM is not.
|
|
64
|
+
//
|
|
65
|
+
// That store should be an atom in `@uniflowed/state`, and was one. It is
|
|
66
|
+
// written out by hand in `toast.js` because `@uniflowed/ui` is published to
|
|
67
|
+
// npm and `@uniflowed/state` is not: its name has never been bound, and
|
|
68
|
+
// binding it takes a person with an `npm login` session and a 2FA prompt —
|
|
69
|
+
// ubugeeei-prod/uf#210. A published package whose dependency is missing
|
|
70
|
+
// installs as nothing, `ETARGET` on the first thing a user types, so this
|
|
71
|
+
// package cannot declare that dependency until the name exists. The queue
|
|
72
|
+
// moves back to `@uniflowed/state` when #210 binds it. The local store is the
|
|
73
|
+
// shippable design, not the better one.
|
|
56
74
|
//
|
|
57
75
|
// # Server and client
|
|
58
76
|
//
|
|
@@ -71,26 +89,57 @@
|
|
|
71
89
|
// - `menu.js` — the arrow keys, typeahead, submenus and `Escape` stacking.
|
|
72
90
|
// - `combobox.js` — `aria-activedescendant` over a filtered list, and the
|
|
73
91
|
// count a screen reader is told.
|
|
92
|
+
// - `select.js` — the other half of the combobox pattern: the select-only one,
|
|
93
|
+
// with typeahead, option groups and a value a form can submit.
|
|
74
94
|
// - `tabs.js` — the roving `tabindex`, and automatic versus manual activation.
|
|
95
|
+
// - `toast.js` — the live region that was watching before there was anything
|
|
96
|
+
// to announce, and the countdown that stops.
|
|
75
97
|
// - `field.js` — the label, description, error and `aria-invalid` wiring.
|
|
76
|
-
// - `switch.js` and `
|
|
77
|
-
//
|
|
98
|
+
// - `switch.js`, `checkbox.js` and `toggle.js` — the three two-state controls,
|
|
99
|
+
// apart because a reader is told something different by each, and because the
|
|
100
|
+
// third state and the `Enter` key genuinely differ between them.
|
|
101
|
+
// - `radio-group.js` — one answer out of several, and the tab stop an
|
|
102
|
+
// unanswered group would otherwise not have.
|
|
103
|
+
// - `toggle-group.js` — a row of toggle buttons as one control, whose `single`
|
|
104
|
+
// mode is a radio group and is rendered by `radio-group.js` rather than
|
|
105
|
+
// written a second time.
|
|
106
|
+
// - `collapsible.js`, `accordion.js` and `navigation-menu.js` — the disclosure
|
|
107
|
+
// pattern on its own, stacked, and applied to a site's navigation. The third
|
|
108
|
+
// of those exists as much to prevent `role="menu"` from being used for a list
|
|
109
|
+
// of links as to provide anything.
|
|
110
|
+
// - `slider.js`, `resizable.js` and `progress.js` — the three that report a
|
|
111
|
+
// number in a range. A window splitter is a slider wearing a separator's
|
|
112
|
+
// role, which is why it is beside one rather than with the layout.
|
|
113
|
+
// - `table.js` and `pagination.js` — the sort that is announced, the selection
|
|
114
|
+
// that can be mixed, and the rows a page is not showing.
|
|
78
115
|
//
|
|
79
116
|
// Every name below is exported from one of those, so a consumer may import
|
|
80
117
|
// `@uniflowed/ui` or `@uniflowed/ui/dialog` and get the same thing. The split
|
|
81
118
|
// is by primitive because that is the unit a reader looks for, the unit a
|
|
82
119
|
// bundler drops, and the unit the WAI-ARIA practices are written in.
|
|
83
120
|
//
|
|
84
|
-
// `internal/` holds
|
|
121
|
+
// `internal/` holds six modules and nothing else, each a rule the primitives
|
|
85
122
|
// must apply identically and a consumer must not be able to apply differently:
|
|
86
123
|
// `merge-props.js` (the caller's props go on first, the component's semantics
|
|
87
|
-
// last), `controlled-state.js` (what "controlled" means here),
|
|
88
|
-
// `roving-focus.js` (how a set of items is found and moved between)
|
|
89
|
-
//
|
|
90
|
-
// `
|
|
91
|
-
//
|
|
124
|
+
// last), `controlled-state.js` (what "controlled" means here),
|
|
125
|
+
// `roving-focus.js` (how a set of items is found and moved between),
|
|
126
|
+
// `disclosure.js` (how a button says whether a region is showing, and how a
|
|
127
|
+
// closed region stays findable), `form-value.js` (what a `<form>` submits for a
|
|
128
|
+
// control the browser has never heard of), and `range.js` (the arithmetic that
|
|
129
|
+
// keeps `aria-valuemin`, `aria-valuemax` and `aria-valuenow` true about each
|
|
130
|
+
// other). Each says in its own header why it is unreachable rather than
|
|
131
|
+
// exported. There is no `internal/props.js`-shaped bag of helpers: a module
|
|
132
|
+
// that cannot say what it is about does not belong in this package.
|
|
92
133
|
|
|
134
|
+
import {
|
|
135
|
+
AccordionContent,
|
|
136
|
+
AccordionHeader,
|
|
137
|
+
AccordionItem,
|
|
138
|
+
AccordionRoot,
|
|
139
|
+
AccordionTrigger,
|
|
140
|
+
} from "./accordion.js";
|
|
93
141
|
import { Checkbox } from "./checkbox.js";
|
|
142
|
+
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from "./collapsible.js";
|
|
94
143
|
import {
|
|
95
144
|
ComboboxEmpty,
|
|
96
145
|
ComboboxInput,
|
|
@@ -123,12 +172,85 @@ import {
|
|
|
123
172
|
MenuSubTrigger,
|
|
124
173
|
MenuTrigger,
|
|
125
174
|
} from "./menu.js";
|
|
175
|
+
import {
|
|
176
|
+
NavigationMenuBody,
|
|
177
|
+
NavigationMenuItem,
|
|
178
|
+
NavigationMenuLink,
|
|
179
|
+
NavigationMenuList,
|
|
180
|
+
NavigationMenuRoot,
|
|
181
|
+
NavigationMenuTrigger,
|
|
182
|
+
} from "./navigation-menu.js";
|
|
183
|
+
import {
|
|
184
|
+
PaginationContent,
|
|
185
|
+
PaginationItem,
|
|
186
|
+
PaginationNext,
|
|
187
|
+
PaginationPrevious,
|
|
188
|
+
PaginationRoot,
|
|
189
|
+
} from "./pagination.js";
|
|
190
|
+
import { Progress } from "./progress.js";
|
|
191
|
+
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot } from "./radio-group.js";
|
|
192
|
+
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./resizable.js";
|
|
193
|
+
import {
|
|
194
|
+
SelectGroup,
|
|
195
|
+
SelectGroupLabel,
|
|
196
|
+
SelectLabel,
|
|
197
|
+
SelectList,
|
|
198
|
+
SelectOption,
|
|
199
|
+
SelectRoot,
|
|
200
|
+
SelectSeparator,
|
|
201
|
+
SelectTrigger,
|
|
202
|
+
SelectValue,
|
|
203
|
+
} from "./select.js";
|
|
204
|
+
import { SliderRange, SliderRoot, SliderThumb, SliderTrack } from "./slider.js";
|
|
126
205
|
import { Switch } from "./switch.js";
|
|
206
|
+
import {
|
|
207
|
+
TableBody,
|
|
208
|
+
TableCaption,
|
|
209
|
+
TableCell,
|
|
210
|
+
TableHead,
|
|
211
|
+
TableHeader,
|
|
212
|
+
TableRoot,
|
|
213
|
+
TableRow,
|
|
214
|
+
TableRowHeader,
|
|
215
|
+
TableRowSelect,
|
|
216
|
+
TableSelectAll,
|
|
217
|
+
} from "./table.js";
|
|
127
218
|
import { TabsList, TabsPanel, TabsRoot, TabsTab } from "./tabs.js";
|
|
219
|
+
import {
|
|
220
|
+
ToastAction,
|
|
221
|
+
ToastClose,
|
|
222
|
+
ToastDescription,
|
|
223
|
+
ToastRegion,
|
|
224
|
+
ToastRoot,
|
|
225
|
+
ToastTitle,
|
|
226
|
+
dismissAllToasts,
|
|
227
|
+
dismissToast,
|
|
228
|
+
toast,
|
|
229
|
+
updateToast,
|
|
230
|
+
} from "./toast.js";
|
|
231
|
+
import { Toggle } from "./toggle.js";
|
|
232
|
+
import { ToggleGroupItem, ToggleGroupRoot } from "./toggle-group.js";
|
|
128
233
|
|
|
234
|
+
export type { AccordionType } from "./accordion.js";
|
|
129
235
|
export type { ActivationMode } from "./tabs.js";
|
|
236
|
+
export type { Sort } from "./table.js";
|
|
237
|
+
export type { Notification, ToastChanges, ToastOptions, Urgency } from "./toast.js";
|
|
238
|
+
export type { ToggleGroupType } from "./toggle-group.js";
|
|
130
239
|
|
|
131
|
-
export { Checkbox, Switch };
|
|
240
|
+
export { Checkbox, Progress, Switch, Toggle };
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Queueing a notification, from anywhere.
|
|
244
|
+
*
|
|
245
|
+
* Functions rather than a hook, because the places a notification comes from —
|
|
246
|
+
* an event handler, a `catch`, a Server Action's error path — do not have a
|
|
247
|
+
* component to hold state in. `Toast.Region` is what displays them.
|
|
248
|
+
*
|
|
249
|
+
* const id = toast("Uploading…", { duration: null });
|
|
250
|
+
* updateToast(id, { content: "Uploaded", duration: 4000 });
|
|
251
|
+
* toast("Could not save", { urgency: "assertive" });
|
|
252
|
+
*/
|
|
253
|
+
export { dismissAllToasts, dismissToast, toast, updateToast };
|
|
132
254
|
|
|
133
255
|
/**
|
|
134
256
|
* An accessible form field.
|
|
@@ -170,6 +292,114 @@ export const Tabs = {
|
|
|
170
292
|
Panel: TabsPanel,
|
|
171
293
|
};
|
|
172
294
|
|
|
295
|
+
/**
|
|
296
|
+
* A button and the region it shows, with the three attributes that say so.
|
|
297
|
+
*
|
|
298
|
+
* The content stays in the document while it is closed, so the browser's
|
|
299
|
+
* find-in-page can still reach the text in it.
|
|
300
|
+
*
|
|
301
|
+
* <Collapsible.Root>
|
|
302
|
+
* <Collapsible.Trigger>Details</Collapsible.Trigger>
|
|
303
|
+
* <Collapsible.Content>…</Collapsible.Content>
|
|
304
|
+
* </Collapsible.Root>
|
|
305
|
+
*/
|
|
306
|
+
export const Collapsible = {
|
|
307
|
+
Root: CollapsibleRoot,
|
|
308
|
+
Trigger: CollapsibleTrigger,
|
|
309
|
+
Content: CollapsibleContent,
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A stack of disclosures that know about each other.
|
|
314
|
+
*
|
|
315
|
+
* `Accordion.Header` takes the heading `level`, because which heading an
|
|
316
|
+
* accordion's sections are depends on where the accordion sits. Each panel is a
|
|
317
|
+
* region named after the header that opens it.
|
|
318
|
+
*
|
|
319
|
+
* <Accordion.Root type="single">
|
|
320
|
+
* <Accordion.Item value="shipping">
|
|
321
|
+
* <Accordion.Header level={3}>
|
|
322
|
+
* <Accordion.Trigger>Shipping</Accordion.Trigger>
|
|
323
|
+
* </Accordion.Header>
|
|
324
|
+
* <Accordion.Content>…</Accordion.Content>
|
|
325
|
+
* </Accordion.Item>
|
|
326
|
+
* </Accordion.Root>
|
|
327
|
+
*/
|
|
328
|
+
export const Accordion = {
|
|
329
|
+
Root: AccordionRoot,
|
|
330
|
+
Item: AccordionItem,
|
|
331
|
+
Header: AccordionHeader,
|
|
332
|
+
Trigger: AccordionTrigger,
|
|
333
|
+
Content: AccordionContent,
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Site navigation: a list of links behind buttons, and not a `menu`.
|
|
338
|
+
*
|
|
339
|
+
* <NavigationMenu.Root aria-label="Main">
|
|
340
|
+
* <NavigationMenu.List>
|
|
341
|
+
* <NavigationMenu.Item value="docs">
|
|
342
|
+
* <NavigationMenu.Trigger>Docs</NavigationMenu.Trigger>
|
|
343
|
+
* <NavigationMenu.Body>
|
|
344
|
+
* <NavigationMenu.Link href="/guide">Guide</NavigationMenu.Link>
|
|
345
|
+
* </NavigationMenu.Body>
|
|
346
|
+
* </NavigationMenu.Item>
|
|
347
|
+
* </NavigationMenu.List>
|
|
348
|
+
* </NavigationMenu.Root>
|
|
349
|
+
*/
|
|
350
|
+
export const NavigationMenu = {
|
|
351
|
+
Root: NavigationMenuRoot,
|
|
352
|
+
List: NavigationMenuList,
|
|
353
|
+
Item: NavigationMenuItem,
|
|
354
|
+
Trigger: NavigationMenuTrigger,
|
|
355
|
+
Body: NavigationMenuBody,
|
|
356
|
+
Link: NavigationMenuLink,
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* One answer out of several, with the arrow keys that check as they move.
|
|
361
|
+
*
|
|
362
|
+
* `Tab` reaches the chosen answer, or the first one while there is none, and
|
|
363
|
+
* leaves the whole group in one press. `name` puts the answer where a form can
|
|
364
|
+
* submit it.
|
|
365
|
+
*
|
|
366
|
+
* <Field.Root>
|
|
367
|
+
* <Field.Label>Plan</Field.Label>
|
|
368
|
+
* <Field.Control
|
|
369
|
+
* render={(props) => (
|
|
370
|
+
* <RadioGroup.Root {...props} defaultValue="free" name="plan">
|
|
371
|
+
* <RadioGroup.Item value="free">
|
|
372
|
+
* Free <RadioGroup.Indicator>●</RadioGroup.Indicator>
|
|
373
|
+
* </RadioGroup.Item>
|
|
374
|
+
* <RadioGroup.Item value="pro">Pro</RadioGroup.Item>
|
|
375
|
+
* </RadioGroup.Root>
|
|
376
|
+
* )}
|
|
377
|
+
* />
|
|
378
|
+
* </Field.Root>
|
|
379
|
+
*/
|
|
380
|
+
export const RadioGroup = {
|
|
381
|
+
Root: RadioGroupRoot,
|
|
382
|
+
Item: RadioGroupItem,
|
|
383
|
+
Indicator: RadioGroupIndicator,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A row of toggle buttons that behaves as one control.
|
|
388
|
+
*
|
|
389
|
+
* `type="multiple"` is a group of toggle buttons, any number of them pressed.
|
|
390
|
+
* `type="single"` is a radio group drawn as segments, and is rendered by
|
|
391
|
+
* `RadioGroup` rather than written a second time.
|
|
392
|
+
*
|
|
393
|
+
* <ToggleGroup.Root aria-label="Formatting" type="multiple">
|
|
394
|
+
* <ToggleGroup.Item value="bold">B</ToggleGroup.Item>
|
|
395
|
+
* <ToggleGroup.Item value="italic">I</ToggleGroup.Item>
|
|
396
|
+
* </ToggleGroup.Root>
|
|
397
|
+
*/
|
|
398
|
+
export const ToggleGroup = {
|
|
399
|
+
Root: ToggleGroupRoot,
|
|
400
|
+
Item: ToggleGroupItem,
|
|
401
|
+
};
|
|
402
|
+
|
|
173
403
|
/**
|
|
174
404
|
* A modal dialog: focus moved in, kept in, and given back.
|
|
175
405
|
*
|
|
@@ -257,3 +487,166 @@ export const Combobox = {
|
|
|
257
487
|
Empty: ComboboxEmpty,
|
|
258
488
|
Status: ComboboxStatus,
|
|
259
489
|
};
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The other half of the combobox pattern: a button, a list, and no typing.
|
|
493
|
+
*
|
|
494
|
+
* Use a native `<select>` when a native `<select>` will do — `select.js` says
|
|
495
|
+
* so first and means it. This is for the popup a `<select>` cannot draw.
|
|
496
|
+
*
|
|
497
|
+
* <Select.Root defaultValue="GB" name="country">
|
|
498
|
+
* <Select.Label>Country</Select.Label>
|
|
499
|
+
* <Select.Trigger>
|
|
500
|
+
* <Select.Value placeholder="Choose one" />
|
|
501
|
+
* </Select.Trigger>
|
|
502
|
+
* <Select.List>
|
|
503
|
+
* <Select.Group>
|
|
504
|
+
* <Select.GroupLabel>Europe</Select.GroupLabel>
|
|
505
|
+
* <Select.Option value="GB">United Kingdom</Select.Option>
|
|
506
|
+
* <Select.Option value="FR">France</Select.Option>
|
|
507
|
+
* </Select.Group>
|
|
508
|
+
* <Select.Separator />
|
|
509
|
+
* <Select.Option value="JP">Japan</Select.Option>
|
|
510
|
+
* </Select.List>
|
|
511
|
+
* </Select.Root>
|
|
512
|
+
*
|
|
513
|
+
* `Select.Label` names the field and `Select.GroupLabel` names a group of
|
|
514
|
+
* options. shadcn has one `SelectLabel` and it is the second of those; a select
|
|
515
|
+
* needs both, so they are two parts here.
|
|
516
|
+
*/
|
|
517
|
+
export const Select = {
|
|
518
|
+
Root: SelectRoot,
|
|
519
|
+
Label: SelectLabel,
|
|
520
|
+
Trigger: SelectTrigger,
|
|
521
|
+
Value: SelectValue,
|
|
522
|
+
List: SelectList,
|
|
523
|
+
Option: SelectOption,
|
|
524
|
+
Group: SelectGroup,
|
|
525
|
+
GroupLabel: SelectGroupLabel,
|
|
526
|
+
Separator: SelectSeparator,
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Notifications, in a live region that was watching before them.
|
|
531
|
+
*
|
|
532
|
+
* Render `Toast.Region` once, in the layout; `toast()` from anywhere.
|
|
533
|
+
*
|
|
534
|
+
* <Toast.Region>
|
|
535
|
+
* {(each) => (
|
|
536
|
+
* <Toast.Root>
|
|
537
|
+
* <Toast.Title>{each.content}</Toast.Title>
|
|
538
|
+
* <Toast.Action onClick={undo}>Undo</Toast.Action>
|
|
539
|
+
* <Toast.Close />
|
|
540
|
+
* </Toast.Root>
|
|
541
|
+
* )}
|
|
542
|
+
* </Toast.Region>
|
|
543
|
+
*/
|
|
544
|
+
export const Toast = {
|
|
545
|
+
Region: ToastRegion,
|
|
546
|
+
Root: ToastRoot,
|
|
547
|
+
Title: ToastTitle,
|
|
548
|
+
Description: ToastDescription,
|
|
549
|
+
Action: ToastAction,
|
|
550
|
+
Close: ToastClose,
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* A value in a range, with `role="slider"` on the thumb where it belongs.
|
|
555
|
+
*
|
|
556
|
+
* One thumb or two; a range is the same component with a second one, each
|
|
557
|
+
* bounded by its neighbour and each needing its own name.
|
|
558
|
+
*
|
|
559
|
+
* <Slider.Root defaultValue={[20, 60]} valueText={(each) => `£${each}`}>
|
|
560
|
+
* <Slider.Track>
|
|
561
|
+
* <Slider.Range />
|
|
562
|
+
* </Slider.Track>
|
|
563
|
+
* <Slider.Thumb aria-label="Minimum" index={0} />
|
|
564
|
+
* <Slider.Thumb aria-label="Maximum" index={1} />
|
|
565
|
+
* </Slider.Root>
|
|
566
|
+
*/
|
|
567
|
+
export const Slider = {
|
|
568
|
+
Root: SliderRoot,
|
|
569
|
+
Track: SliderTrack,
|
|
570
|
+
Range: SliderRange,
|
|
571
|
+
Thumb: SliderThumb,
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Two panes and the splitter between them, operable from the keyboard.
|
|
576
|
+
*
|
|
577
|
+
* <Resizable.PanelGroup defaultValue={30}>
|
|
578
|
+
* <Resizable.Panel primary>Files</Resizable.Panel>
|
|
579
|
+
* <Resizable.Handle label="Resize the file list" />
|
|
580
|
+
* <Resizable.Panel>Editor</Resizable.Panel>
|
|
581
|
+
* </Resizable.PanelGroup>
|
|
582
|
+
*/
|
|
583
|
+
export const Resizable = {
|
|
584
|
+
PanelGroup: ResizablePanelGroup,
|
|
585
|
+
Panel: ResizablePanel,
|
|
586
|
+
Handle: ResizableHandle,
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* A table, with the four things about one nobody gets right by hand.
|
|
591
|
+
*
|
|
592
|
+
* A real `<table>`, deliberately not a `role="grid"` — `table.js` says why —
|
|
593
|
+
* and its own live region, so a re-sort is something a reader is told about
|
|
594
|
+
* rather than something that happens silently behind them.
|
|
595
|
+
*
|
|
596
|
+
* <Table.Root onSortChange={setSort} rowCount={500} rowOffset={90} sort={sort}>
|
|
597
|
+
* <Table.Caption>People</Table.Caption>
|
|
598
|
+
* <Table.Header>
|
|
599
|
+
* <Table.Row>
|
|
600
|
+
* <Table.Head>
|
|
601
|
+
* <Table.SelectAll checked={all} onCheckedChange={setAll} />
|
|
602
|
+
* </Table.Head>
|
|
603
|
+
* <Table.Head column="name">Name</Table.Head>
|
|
604
|
+
* </Table.Row>
|
|
605
|
+
* </Table.Header>
|
|
606
|
+
* <Table.Body>
|
|
607
|
+
* {page.map((person, at) => (
|
|
608
|
+
* <Table.Row index={at} key={person.id}>
|
|
609
|
+
* <Table.Cell>
|
|
610
|
+
* <Table.RowSelect
|
|
611
|
+
* checked={chosen.has(person.id)}
|
|
612
|
+
* label={`Select ${person.name}`}
|
|
613
|
+
* onCheckedChange={(on) => choose(person.id, on)}
|
|
614
|
+
* />
|
|
615
|
+
* </Table.Cell>
|
|
616
|
+
* <Table.RowHeader>{person.name}</Table.RowHeader>
|
|
617
|
+
* </Table.Row>
|
|
618
|
+
* ))}
|
|
619
|
+
* </Table.Body>
|
|
620
|
+
* </Table.Root>
|
|
621
|
+
*/
|
|
622
|
+
export const Table = {
|
|
623
|
+
Root: TableRoot,
|
|
624
|
+
Caption: TableCaption,
|
|
625
|
+
Header: TableHeader,
|
|
626
|
+
Body: TableBody,
|
|
627
|
+
Row: TableRow,
|
|
628
|
+
Head: TableHead,
|
|
629
|
+
RowHeader: TableRowHeader,
|
|
630
|
+
Cell: TableCell,
|
|
631
|
+
SelectAll: TableSelectAll,
|
|
632
|
+
RowSelect: TableRowSelect,
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* The navigation a paginated table needs, and the sentence that says it moved.
|
|
637
|
+
*
|
|
638
|
+
* <Pagination.Root page={4} pageCount={25}>
|
|
639
|
+
* <Pagination.Content>
|
|
640
|
+
* <Pagination.Previous disabled={page === 1} href={hrefFor(page - 1)}>‹</Pagination.Previous>
|
|
641
|
+
* <Pagination.Item current href={hrefFor(4)}>4</Pagination.Item>
|
|
642
|
+
* <Pagination.Next href={hrefFor(page + 1)}>›</Pagination.Next>
|
|
643
|
+
* </Pagination.Content>
|
|
644
|
+
* </Pagination.Root>
|
|
645
|
+
*/
|
|
646
|
+
export const Pagination = {
|
|
647
|
+
Root: PaginationRoot,
|
|
648
|
+
Content: PaginationContent,
|
|
649
|
+
Item: PaginationItem,
|
|
650
|
+
Previous: PaginationPrevious,
|
|
651
|
+
Next: PaginationNext,
|
|
652
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The other pattern every part of this package keeps writing.
|
|
4
|
+
//
|
|
5
|
+
// `roving-focus.js` is the keyboard half of these components. This is the
|
|
6
|
+
// other half, and it is one sentence: **a button says whether a region is
|
|
7
|
+
// showing, and names it.** `Dialog.Trigger` and `Menu.Trigger` are that
|
|
8
|
+
// sentence, and so are a collapsible, an accordion header and an expandable
|
|
9
|
+
// entry in a site's navigation — three components that look nothing alike and
|
|
10
|
+
// are the same three attributes underneath.
|
|
11
|
+
//
|
|
12
|
+
// Two rules make it up, and both of them fail silently.
|
|
13
|
+
//
|
|
14
|
+
// * **Name it only while it is there.** `aria-controls` pointing at an id
|
|
15
|
+
// nothing has tells a reader there is somewhere to go and then has nowhere
|
|
16
|
+
// to send them, and `aria-labelledby` pointing at a missing element makes a
|
|
17
|
+
// screen reader announce *nothing at all* rather than falling back to the
|
|
18
|
+
// element's own text. So a panel reports whether it is in the document, and
|
|
19
|
+
// whatever names it only claims the name while the report says yes. This is
|
|
20
|
+
// the same subscription `Tabs.Panel` makes to `Tabs.Tab`, for the same
|
|
21
|
+
// reason.
|
|
22
|
+
// * **A closed panel is hidden, not absent.** `Tabs.Panel` returns `null`
|
|
23
|
+
// when it is not selected, which is right for a tab set — the panels are
|
|
24
|
+
// alternatives, and a reader looking for text in one of them is looking at
|
|
25
|
+
// the wrong tab. It is wrong for a disclosure: the browser's find-in-page
|
|
26
|
+
// cannot find text in a section that is not in the document, so a
|
|
27
|
+
// forty-section FAQ becomes forty sections a reader has to open by hand to
|
|
28
|
+
// search. `hidden="until-found"` is the platform's answer — the browser
|
|
29
|
+
// reveals the section, fires `beforematch`, and scrolls to the match —
|
|
30
|
+
// and it works in Chrome since 102 (2022-05), Firefox since 148 (2026-02)
|
|
31
|
+
// and Safari since 26.2 (2025-12, which does not yet scroll to the match);
|
|
32
|
+
// checked 2026-09-06. Everywhere else it degrades to a plain `hidden`,
|
|
33
|
+
// which is what the panel would have been anyway.
|
|
34
|
+
//
|
|
35
|
+
// # Why `useUntilFound` is a hook and not a prop
|
|
36
|
+
//
|
|
37
|
+
// Because React 19 cannot say `hidden="until-found"`. `hidden` is on React's
|
|
38
|
+
// list of boolean attributes, so `<div hidden="until-found">` renders
|
|
39
|
+
// `hidden=""` — the string is truthy, and truthy is all React keeps. There is
|
|
40
|
+
// no prop spelling that produces the attribute, which is a thing worth knowing
|
|
41
|
+
// before spending an afternoon looking for one.
|
|
42
|
+
//
|
|
43
|
+
// So the panel is rendered with the ordinary boolean `hidden` — which is what
|
|
44
|
+
// the server sends, and what keeps a closed section closed before any
|
|
45
|
+
// JavaScript arrives — and an effect *upgrades* the attribute afterwards. It is
|
|
46
|
+
// an upgrade rather than a fight: React sets `hidden=""` when it commits, this
|
|
47
|
+
// runs after that commit, and the next time React changes the prop it removes
|
|
48
|
+
// or re-adds the attribute and this upgrades it again. Nothing here writes an
|
|
49
|
+
// attribute React believes it owns while React believes it.
|
|
50
|
+
//
|
|
51
|
+
// # Why this is `internal/` and not a subpath
|
|
52
|
+
//
|
|
53
|
+
// The same reason `roving-focus.js` gives. These are rules about markup this
|
|
54
|
+
// package emits — that a trigger and its panel agree on an id, that a panel is
|
|
55
|
+
// the element carrying `hidden` — and they hold because the components build
|
|
56
|
+
// both halves. Exported, they would be advice.
|
|
57
|
+
|
|
58
|
+
import { useEffect } from "@uniflowed/react";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Report that this part is in the document, for as long as it is.
|
|
62
|
+
*
|
|
63
|
+
* `register` is the setter from whatever names this part; it is called with
|
|
64
|
+
* `true` on mount and `false` on unmount, and taking it as a possibly-missing
|
|
65
|
+
* function lets a part be rendered outside the thing that would name it
|
|
66
|
+
* without the caller having to care.
|
|
67
|
+
*/
|
|
68
|
+
export hook usePresence(register: ((present: boolean) => void) | void): void {
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (register == null) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
register(true);
|
|
74
|
+
return () => register(false);
|
|
75
|
+
}, [register]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Keep a closed panel hidden the way the platform means it: findable.
|
|
80
|
+
*
|
|
81
|
+
* The element must be rendered with a plain boolean `hidden` as well — see the
|
|
82
|
+
* module header. This only upgrades the attribute React has already committed,
|
|
83
|
+
* so a browser that has never heard of `until-found` sees exactly the `hidden`
|
|
84
|
+
* it would have seen, and one that has can reveal the section for a
|
|
85
|
+
* find-in-page hit.
|
|
86
|
+
*/
|
|
87
|
+
export hook useUntilFound(ref: { current: HTMLElement | null }, open: boolean): void {
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const element = ref.current;
|
|
90
|
+
// Nothing to do while it is open: React has removed the attribute, and
|
|
91
|
+
// adding one back would hide a panel the reader just opened.
|
|
92
|
+
if (element == null || open) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
element.setAttribute("hidden", "until-found");
|
|
96
|
+
}, [ref, open]);
|
|
97
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// What a `<form>` submits for a control the browser has never heard of.
|
|
4
|
+
//
|
|
5
|
+
// Every widget in this package is a `button` or a `div` wearing an ARIA role,
|
|
6
|
+
// which is what makes it styleable and what makes it invisible to form
|
|
7
|
+
// submission: `new FormData(form)` collects the form's *listed* elements, and a
|
|
8
|
+
// `<div role="listbox">` is not one. So a Select inside a form submitted
|
|
9
|
+
// nothing at all, and a Combobox submitted `Combobox.Input`'s value — which is
|
|
10
|
+
// the label the reader sees and not the value the application meant. A country
|
|
11
|
+
// picker posted "United Kingdom" where the server was waiting for `GB`.
|
|
12
|
+
//
|
|
13
|
+
// The fix is one hidden `<input>` carrying the real value, rendered only when
|
|
14
|
+
// the caller asked for one by giving a `name`. No `name`, no control: a Select
|
|
15
|
+
// used to drive a filter has nothing to submit, and a form field the caller
|
|
16
|
+
// never named is not one this package should invent.
|
|
17
|
+
//
|
|
18
|
+
// # Why a hidden input and not a hidden `<select>`
|
|
19
|
+
//
|
|
20
|
+
// Rendering a real, visually hidden `<select>` is the other answer, and it buys
|
|
21
|
+
// two things: the browser autofills it, and `required` gets native constraint
|
|
22
|
+
// validation. Both were tried and neither survives contact with the
|
|
23
|
+
// accessibility tree.
|
|
24
|
+
//
|
|
25
|
+
// A `<select>` is focusable, so it is announced. A reader who tabs into the
|
|
26
|
+
// widget hears the styled combobox and then a second, invisible combobox with
|
|
27
|
+
// the same options — the duplicate-announcement bug that makes people describe
|
|
28
|
+
// a component library as "noisy". Taking it out of the tree means
|
|
29
|
+
// `aria-hidden="true"`, and `aria-hidden` on a focusable element is itself the
|
|
30
|
+
// violation: it hides the element from a screen reader while leaving it in the
|
|
31
|
+
// tab order, so the reader lands on something their software says is not there.
|
|
32
|
+
// `tabindex="-1"` plus `aria-hidden` closes that hole and gives up the tab
|
|
33
|
+
// order, which is the autofill affordance the native control was for.
|
|
34
|
+
//
|
|
35
|
+
// And native validation cannot work here either. The browser reports a
|
|
36
|
+
// constraint failure by focusing the invalid control and drawing a bubble at
|
|
37
|
+
// it; on a control with no box, Chrome logs "An invalid form control with
|
|
38
|
+
// name='country' is not focusable" and refuses to submit the form at all, with
|
|
39
|
+
// nothing shown to the reader. A headless select's `required` therefore belongs
|
|
40
|
+
// to `@uniflowed/form` and `@uniflowed/validator`, which is where uf already
|
|
41
|
+
// put every other rule, and `Field.Error` is where the message goes.
|
|
42
|
+
//
|
|
43
|
+
// An `<input type="hidden">` is none of those things: never focusable, never in
|
|
44
|
+
// the accessibility tree, never validated, and always submitted. What it costs
|
|
45
|
+
// is autofill, which is a real loss and is written down rather than hidden —
|
|
46
|
+
// a browser will not fill a hidden input the way it fills `<select
|
|
47
|
+
// name="country">`.
|
|
48
|
+
//
|
|
49
|
+
// # This is not how `@uniflowed/form` reads a value
|
|
50
|
+
//
|
|
51
|
+
// Worth stating because the two look like they overlap and do not.
|
|
52
|
+
// `@uniflowed/form` holds values in its own store and calls `preventDefault()`
|
|
53
|
+
// on submit, so it never builds a `FormData` and never sees this element. A
|
|
54
|
+
// Select bound to that library is bound through `useController` — `field.value`
|
|
55
|
+
// into `value`, `field.onChange` into `onValueChange` — and needs no `name`
|
|
56
|
+
// here at all. This element is for the other kind of form: a plain `<form
|
|
57
|
+
// action={…}>`, a Server Action, or anything else that submits the document.
|
|
58
|
+
//
|
|
59
|
+
// # Why this is `internal/` and not a subpath
|
|
60
|
+
//
|
|
61
|
+
// It is one sentence about what this package promises a form, and the failure
|
|
62
|
+
// mode of writing it twice is that Select and Combobox disagree about what a
|
|
63
|
+
// disabled control submits. Exported, it would be a `<HiddenInput>` a consumer
|
|
64
|
+
// could reach for in a component that had not thought about any of the above.
|
|
65
|
+
|
|
66
|
+
"use client";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The control a form actually reads.
|
|
70
|
+
*
|
|
71
|
+
* `value` is the widget's value, not its label. `null` renders an empty string
|
|
72
|
+
* rather than omitting the control, so a form that submits a Select the reader
|
|
73
|
+
* left alone still carries the field — a key missing from the payload and a key
|
|
74
|
+
* present and empty are different questions to a server, and "the reader saw
|
|
75
|
+
* this field and chose nothing" is the second one.
|
|
76
|
+
*
|
|
77
|
+
* `disabled` is passed through rather than interpreted: a disabled control is
|
|
78
|
+
* omitted from the submission by the browser, which is the behaviour a native
|
|
79
|
+
* `<select disabled>` has and the one a caller who disabled the widget expects.
|
|
80
|
+
*/
|
|
81
|
+
export component FormValue(name: string, value: string | null, disabled?: boolean = false) {
|
|
82
|
+
return <input disabled={disabled} name={name} type="hidden" value={value ?? ""} />;
|
|
83
|
+
}
|
package/internal/merge-props.js
CHANGED
|
@@ -75,6 +75,39 @@
|
|
|
75
75
|
*/
|
|
76
76
|
export type Rest = { readonly key?: empty, readonly [string]: mixed };
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* A caller's props on their way to another *part of this package*, rather than
|
|
80
|
+
* onto an intrinsic element.
|
|
81
|
+
*
|
|
82
|
+
* `Rest` names `key` out of its indexer and types it `empty`, which is a true
|
|
83
|
+
* sentence and is what stopped thirty-two intrinsics being rejected for a
|
|
84
|
+
* property that cannot be there. It has a second consequence, and it only shows
|
|
85
|
+
* up the first time one part of this package renders another —
|
|
86
|
+
* `ToggleGroup.Root` rendering a `RadioGroup.Root`, which is how `single` mode
|
|
87
|
+
* avoids being a second copy of the radio group. Creating
|
|
88
|
+
* `<RadioGroup.Root {...rest} />` has Flow check the props object against that
|
|
89
|
+
* component's own `...rest: Rest`, `key` included, and the indexer answers
|
|
90
|
+
* `mixed` for it rather than the named `empty`:
|
|
91
|
+
*
|
|
92
|
+
* error[incompatible-type]: Cannot create RadioGroupRoot element because in
|
|
93
|
+
* property key: unknown is incompatible with empty.
|
|
94
|
+
*
|
|
95
|
+
* So a part is spreadable onto a `<div>` and not onto a sibling part. That is a
|
|
96
|
+
* hole in the type rather than a fact about the props, and this is the one
|
|
97
|
+
* place it is papered over — a named function rather than an `as $FlowFixMe` at
|
|
98
|
+
* the call site, so there is somewhere to say what is and is not lost.
|
|
99
|
+
*
|
|
100
|
+
* What is lost is nothing that was ever checked. Every element this package
|
|
101
|
+
* renders has `any`-typed props today, for the reason `Rest` gives above: uf
|
|
102
|
+
* does not merge Flow's `jsx.js` environment, so `$JSXIntrinsics` is the
|
|
103
|
+
* bare-bones table in `lib/react.js` and `key` is the only property of an
|
|
104
|
+
* element anything verifies. On the day that changes and `Rest` becomes
|
|
105
|
+
* `React.PropsOf`, this function is what gets deleted.
|
|
106
|
+
*/
|
|
107
|
+
export function forwarded(rest: Rest): $FlowFixMe {
|
|
108
|
+
return rest;
|
|
109
|
+
}
|
|
110
|
+
|
|
78
111
|
/**
|
|
79
112
|
* Call the caller's handler and then the component's.
|
|
80
113
|
*
|