@uniflowed/ui 0.0.0-alpha.2 → 0.0.0-alpha.37

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 (58) hide show
  1. package/accordion.js +360 -0
  2. package/alert-dialog.js +282 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +276 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +550 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +264 -0
  9. package/collapsible.js +169 -0
  10. package/combobox.js +728 -0
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +523 -0
  14. package/drawer.js +490 -0
  15. package/field.js +387 -0
  16. package/hover-card.js +330 -0
  17. package/index.js +1699 -22
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2163 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/controlled-state.js +65 -0
  22. package/internal/date-grid.js +260 -0
  23. package/internal/disclosure.js +298 -0
  24. package/internal/focus.js +64 -0
  25. package/internal/form-value.js +83 -0
  26. package/internal/hover-intent.js +259 -0
  27. package/internal/menu-tree.js +228 -0
  28. package/internal/merge-props.js +285 -0
  29. package/internal/range.js +147 -0
  30. package/internal/roving-focus.js +430 -0
  31. package/menu.js +823 -0
  32. package/menubar.js +287 -0
  33. package/navigation-menu.js +251 -0
  34. package/package.json +9 -9
  35. package/pagination.js +209 -0
  36. package/popover.js +343 -0
  37. package/progress.js +91 -0
  38. package/radio-group.js +302 -0
  39. package/resizable.js +447 -0
  40. package/scroll-area.js +283 -0
  41. package/select.js +902 -0
  42. package/separator.js +97 -0
  43. package/sheet.js +189 -0
  44. package/sidebar.js +300 -0
  45. package/skeleton.js +159 -0
  46. package/slider.js +405 -0
  47. package/switch.js +81 -0
  48. package/table.js +502 -0
  49. package/tabs.js +289 -0
  50. package/toast.js +592 -0
  51. package/toggle-group.js +283 -0
  52. package/toggle.js +105 -0
  53. package/tooltip.js +400 -0
  54. package/internal/dialog.js +0 -236
  55. package/internal/field.js +0 -161
  56. package/internal/props.js +0 -78
  57. package/internal/switch.js +0 -122
  58. package/internal/tabs.js +0 -270
package/pagination.js ADDED
@@ -0,0 +1,209 @@
1
+ // @flow
2
+ //
3
+ // Pagination: the navigation a table needs to be usable, and its four rules.
4
+ //
5
+ // It is here rather than in `table.js` because it is `crates/uf_lib`'s own
6
+ // entry and because a paginated list is not always a table — but it is written
7
+ // for the table next door, and the two are documented together.
8
+ //
9
+ // Four things, each of which is invisible when it is missing:
10
+ //
11
+ // * **It is navigation, so it is a `<nav>` with a name.** A page has more
12
+ // than one `nav`, and an unnamed one is announced as "navigation" with no
13
+ // way to tell it from the site's menu. `aria-label="Pagination"` is what
14
+ // puts it in a screen reader's landmark list under a useful name.
15
+ // * **The current page is `aria-current="page"`.** Not a class, not bold
16
+ // text, not `aria-selected` — `page` is the value ARIA defines for exactly
17
+ // this, and it is the only one that tells a reader where they are.
18
+ // * **Previous and next are named as such.** A link whose content is `‹` is
19
+ // announced as "link, left single quotation mark", which is not a thing
20
+ // anybody can act on. The glyph stays; the name is words.
21
+ // * **The change is announced.** Pressing "next" replaces the rows and moves
22
+ // nothing a reader is looking at, so a live region that was already there
23
+ // says "Page 4 of 25". `combobox.js` states the rule about why it has to
24
+ // have been there first.
25
+ //
26
+ // # Why the controls are links
27
+ //
28
+ // `Pagination.Item` renders an `<a>`, not a `<button>`, and that is an opinion
29
+ // worth stating because it constrains the caller: page four of a table is a
30
+ // *place*, and a reader expects to be able to open it in a new tab, copy it,
31
+ // bookmark it and come back to it. A list paginated with buttons is a list
32
+ // whose fourth page does not exist as far as the rest of the web is concerned.
33
+ //
34
+ // An application that genuinely has no URL for a page — a modal, an unsaved
35
+ // draft — is the case where this is the wrong component, and a `<button>` the
36
+ // caller writes themselves is the right answer. That is a smaller cost than
37
+ // making every well-behaved application invent its own links.
38
+ //
39
+ // # No `"use client"`
40
+ //
41
+ // Nothing here holds state, listens to anything or moves focus. Which page is
42
+ // current is the caller's, the links are links, and the announcement is a
43
+ // string in a div. It renders on a server.
44
+
45
+ import * as React from "@uniflowed/react";
46
+
47
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
48
+ import { withProps, withoutComposed } from "./internal/merge-props.js";
49
+
50
+ /**
51
+ * The pagination, as a named landmark, and the region that announces it.
52
+ *
53
+ * `page` and `pageCount` are what the announcement says. They are separate
54
+ * from which `Pagination.Item` is marked current because the two answer
55
+ * different questions — a reader is told "page 4 of 25" whether or not 25
56
+ * links are on screen, and a component that showed five links out of
57
+ * twenty-five would otherwise announce "page 4 of 5".
58
+ */
59
+ export component PaginationRoot(
60
+ children: React.Node,
61
+ label?: string = "Pagination",
62
+ page?: number | null = null,
63
+ pageCount?: number | null = null,
64
+ announcePage?: (page: number, pageCount: number) => string,
65
+ render?: RenderProp,
66
+ ...rest: Rest
67
+ ) {
68
+ const message =
69
+ page == null || pageCount == null ? "" : (announcePage ?? defaultAnnouncement)(page, pageCount);
70
+ const props = withProps(rest, { "aria-label": label, children });
71
+
72
+ return (
73
+ <>
74
+ {render == null ? <nav {...props} /> : render(withProps(props, { role: "navigation" }))}
75
+ {/*
76
+ Beside the navigation rather than inside it, so a reader walking the
77
+ landmark hears the links and not a sentence about them — and mounted
78
+ from the first render holding nothing, because a live region that
79
+ appears together with its text is not announced at all.
80
+ */}
81
+ <div aria-atomic="true" aria-live="polite" data-uf-pagination-status="" role="status">
82
+ {message}
83
+ </div>
84
+ </>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * The list of pages.
90
+ *
91
+ * A real list, so a reader is told how many there are before walking them and
92
+ * can skip the whole thing in one keystroke.
93
+ */
94
+ export component PaginationContent(
95
+ children: renders* (PaginationItem | PaginationPrevious | PaginationNext),
96
+ render?: RenderProp,
97
+ ...rest: Rest
98
+ ) {
99
+ const props = withProps(rest, { children });
100
+ if (render != null) {
101
+ return render(withProps(props, { role: "list" }));
102
+ }
103
+ return <ul {...props} />;
104
+ }
105
+
106
+ /**
107
+ * One page.
108
+ *
109
+ * The `<li>` is structural and takes nothing; everything a caller passes goes
110
+ * on the `<a>`, which is what they style and what a reader activates.
111
+ */
112
+ export component PaginationItem(
113
+ children: React.Node,
114
+ current?: boolean = false,
115
+ disabled?: boolean = false,
116
+ render?: RenderProp,
117
+ ...rest: Rest
118
+ ) {
119
+ return (
120
+ <PageLink current={current} disabled={disabled} render={render} rest={rest}>
121
+ {children}
122
+ </PageLink>
123
+ );
124
+ }
125
+
126
+ /**
127
+ * The link to the page before this one.
128
+ *
129
+ * `label` is its accessible name and has a default, because the content of
130
+ * this link is a chevron every time — and "link, left single quotation mark"
131
+ * is not something a reader can act on. Pass `label` to translate it; the
132
+ * glyph stays whatever the caller rendered.
133
+ */
134
+ export component PaginationPrevious(
135
+ children?: React.Node,
136
+ label?: string = "Previous page",
137
+ disabled?: boolean = false,
138
+ render?: RenderProp,
139
+ ...rest: Rest
140
+ ) {
141
+ return (
142
+ <PageLink disabled={disabled} label={label} render={render} rest={rest}>
143
+ {children}
144
+ </PageLink>
145
+ );
146
+ }
147
+
148
+ /** The link to the page after this one. See `Pagination.Previous`. */
149
+ export component PaginationNext(
150
+ children?: React.Node,
151
+ label?: string = "Next page",
152
+ disabled?: boolean = false,
153
+ render?: RenderProp,
154
+ ...rest: Rest
155
+ ) {
156
+ return (
157
+ <PageLink disabled={disabled} label={label} render={render} rest={rest}>
158
+ {children}
159
+ </PageLink>
160
+ );
161
+ }
162
+
163
+ /**
164
+ * The `<li><a>` the three parts above all render.
165
+ *
166
+ * `rest` arrives as a *named prop* rather than as a spread, and that is not a
167
+ * style choice. `Rest` is the type of props on their way onto an intrinsic —
168
+ * `merge-props.js` explains why an intrinsic's own props are unchecked here —
169
+ * and spreading its `mixed` indexer onto a typed component makes the checker
170
+ * say, correctly, that `disabled` might not be a boolean. Handing the bag over
171
+ * as one value keeps it a bag until it reaches the element it was always for.
172
+ *
173
+ * `disabled` drops the `href` rather than adding an attribute, because there
174
+ * is no such thing as a disabled link: an `<a>` with no `href` is not in the
175
+ * tab order and is not announced as a link, which is exactly what "there is no
176
+ * previous page" means. `aria-disabled` is there too, so a reader who reaches
177
+ * it another way is told why it does nothing.
178
+ */
179
+ component PageLink(
180
+ rest: Rest,
181
+ children?: React.Node,
182
+ current?: boolean = false,
183
+ disabled?: boolean = false,
184
+ label?: string,
185
+ render?: RenderProp,
186
+ ) {
187
+ const passed = withoutComposed(rest, disabled ? ["href"] : []);
188
+ const props = withProps(passed, {
189
+ "aria-current": current ? "page" : undefined,
190
+ "aria-disabled": disabled ? "true" : undefined,
191
+ "aria-label": label,
192
+ children,
193
+ });
194
+
195
+ if (render != null) {
196
+ return <li>{render(withProps(props, { role: disabled ? undefined : "link" }))}</li>;
197
+ }
198
+
199
+ return (
200
+ <li>
201
+ <a {...props} />
202
+ </li>
203
+ );
204
+ }
205
+
206
+ /** The wording used when the caller supplies none. */
207
+ function defaultAnnouncement(page: number, pageCount: number): string {
208
+ return `Page ${String(page)} of ${String(pageCount)}.`;
209
+ }
package/popover.js ADDED
@@ -0,0 +1,343 @@
1
+ // @flow
2
+ //
3
+ // A popover: a dialog that is not modal, which is the whole of the difference.
4
+ //
5
+ // `dialog.js` is modal and only modal, on purpose — every line of it is a
6
+ // promise that the rest of the page is unavailable. A popover makes the
7
+ // opposite promise, and it has to make it in every one of the same places:
8
+ //
9
+ // * No `aria-modal`, because the page behind is still there.
10
+ // * Nothing is made `inert` and nothing is `aria-hidden`, because a reader
11
+ // may still reach it.
12
+ // * The page is not scroll-locked, because a wheel over a popover scrolling
13
+ // the page behind is what a reader expects from something that did not
14
+ // take the page over.
15
+ // * **`Tab` leaves.** This is the load-bearing one. A trap is what makes a
16
+ // modal dialog safe and what makes a popover a hole a reader falls into:
17
+ // they tabbed in, they tab out, and a component that wraps them back to
18
+ // the first control has taken the page away without ever saying so.
19
+ //
20
+ // So a popover is not a `Dialog` with a flag. A flag would mean every one of
21
+ // the behaviours above reading it, and the failure of the one that forgot would
22
+ // be a dialog that is not modal while announcing that it is — silent, and
23
+ // wrong in the direction that traps people.
24
+ //
25
+ // What it *does* share with a dialog is the part a reader notices when it is
26
+ // missing: focus moves into it when it opens, `Escape` closes it, and focus
27
+ // goes back to the trigger — unless the reader dismissed it by pressing or
28
+ // tabbing somewhere else, in which case it stays where they put it.
29
+ //
30
+ // # Where it goes
31
+ //
32
+ // `internal/anchor.js`, the same as every other overlay here: `side`, `align`,
33
+ // `sideOffset` and `alignOffset` place it against the trigger, it flips and
34
+ // slides to stay on the screen, and it reports where it ended up as `data-side`
35
+ // and `data-align` so a stylesheet can point an arrow without measuring
36
+ // anything itself.
37
+ //
38
+ // # Its name
39
+ //
40
+ // `role="dialog"` needs an accessible name, and a popover has no `Title` part
41
+ // to take one from — shadcn's does not either, and adding one would make the
42
+ // common case (a form, a colour picker, a date picker) carry a heading nobody
43
+ // asked for. So the body is named after the button that opened it, which is
44
+ // true and is what a reader would say the popover is, and a caller who passes
45
+ // `aria-label` or `aria-labelledby` of their own keeps it.
46
+
47
+ "use client";
48
+
49
+ import * as React from "@uniflowed/react";
50
+ import {
51
+ createContext,
52
+ useContext,
53
+ useEffect,
54
+ useId,
55
+ useMemo,
56
+ useRef,
57
+ useState,
58
+ } from "@uniflowed/react";
59
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
60
+
61
+ import type { Align, LogicalSide } from "./internal/anchor.js";
62
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
63
+ import {
64
+ composeHandlers,
65
+ composeRefs,
66
+ withProps,
67
+ withoutComposed,
68
+ } from "./internal/merge-props.js";
69
+ import { focusable } from "./internal/focus.js";
70
+ import { useAnchor } from "./internal/anchor.js";
71
+ import { useControlled } from "./internal/controlled-state.js";
72
+ import { usePresence } from "./internal/disclosure.js";
73
+
74
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
75
+
76
+ type PopoverState = {|
77
+ readonly base: string,
78
+ readonly open: boolean,
79
+ readonly setOpen: (open: boolean) => void,
80
+ /** What opened it, where it is anchored, and where focus goes back to. */
81
+ readonly triggerRef: { current: HTMLElement | null },
82
+ /**
83
+ * Whether a trigger is rendered, so the body only names one that exists.
84
+ *
85
+ * A popover opened by `defaultOpen` in a page with no trigger is a real
86
+ * arrangement — a first-run hint pointing at something — and an
87
+ * `aria-labelledby` naming the id that trigger *would* have had makes a
88
+ * screen reader announce nothing at all.
89
+ */
90
+ readonly triggered: boolean,
91
+ readonly registerTrigger: (present: boolean) => void,
92
+ |};
93
+
94
+ const PopoverContext: React.Context<PopoverState | null> = createContext(null);
95
+
96
+ /**
97
+ * The popover a part belongs to.
98
+ *
99
+ * Raising rather than returning null, for the reason `useDialog` gives: a
100
+ * `Popover.Body` outside a root would render a `role="dialog"` that nothing
101
+ * opens, closes or names, and it would look correct.
102
+ */
103
+ hook usePopover(part: string): PopoverState {
104
+ const state = useContext(PopoverContext);
105
+ if (state == null) {
106
+ throw new Error(`${part} must be rendered inside a Popover.Root`);
107
+ }
108
+ return state;
109
+ }
110
+
111
+ /**
112
+ * The popover, open or closed. Uncontrolled unless `open` is given.
113
+ *
114
+ * Renders no element of its own: the trigger and the body are siblings in
115
+ * whatever layout the caller wrote, and a wrapper would put a `<div>` between
116
+ * them for the caller to style around.
117
+ */
118
+ export component PopoverRoot(
119
+ children: React.Node,
120
+ defaultOpen?: boolean = false,
121
+ open?: boolean,
122
+ onOpenChange?: (open: boolean) => void,
123
+ ) {
124
+ const base = useId();
125
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
126
+ const triggerRef = useRef<HTMLElement | null>(null);
127
+ const [triggered, setTriggered] = useState(false);
128
+
129
+ const state = useMemo(
130
+ () => ({
131
+ base,
132
+ open: isOpen,
133
+ registerTrigger: setTriggered,
134
+ setOpen,
135
+ triggerRef,
136
+ triggered,
137
+ }),
138
+ [base, isOpen, setOpen, triggered],
139
+ );
140
+
141
+ return <PopoverContext.Provider value={state}>{children}</PopoverContext.Provider>;
142
+ }
143
+
144
+ /**
145
+ * The button that opens the popover, and what it is anchored to.
146
+ *
147
+ * A toggle rather than an opener: pressing the button of an open popover closes
148
+ * it, which is what every disclosure does and what a reader who pressed it by
149
+ * accident expects. The outside-press handler in `Popover.Body` knows the
150
+ * trigger is not "outside" for exactly this reason — closing there and letting
151
+ * this click reopen made the press a no-op that flickered.
152
+ */
153
+ export component PopoverTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
154
+ const popover = usePopover("Popover.Trigger");
155
+ const passed = withoutComposed(rest, ["onClick", "ref"]);
156
+ usePresence(popover.registerTrigger);
157
+ const props = withProps(passed, {
158
+ // Only while it is open: an `aria-controls` naming an element that is not
159
+ // in the document tells a reader there is somewhere to go and then has
160
+ // nowhere to send them.
161
+ "aria-controls": popover.open ? `${popover.base}-body` : undefined,
162
+ "aria-expanded": popover.open ? "true" : "false",
163
+ "aria-haspopup": "dialog",
164
+ children,
165
+ id: `${popover.base}-trigger`,
166
+ onClick: composeHandlers(rest.onClick, () => popover.setOpen(!popover.open)),
167
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
168
+ popover.triggerRef.current = element;
169
+ }),
170
+ });
171
+
172
+ if (render != null) {
173
+ return render(props);
174
+ }
175
+ return <button {...props} type="button" />;
176
+ }
177
+
178
+ /**
179
+ * The popover itself: positioned, focused, dismissible, and not a trap.
180
+ *
181
+ * The three ways out are the three a reader tries. `Escape` closes it and gives
182
+ * focus back to the trigger, because the reader is still where they were. A
183
+ * press outside closes it and leaves focus alone, because they have already
184
+ * moved on. `Tab` past the last control inside closes it for the same reason
185
+ * and leaves focus where the browser put it — which is the behaviour that
186
+ * separates this from a dialog, and the one a copy of `dialog.js` with the
187
+ * `aria-modal` deleted would get wrong.
188
+ */
189
+ export component PopoverBody(
190
+ children: React.Node,
191
+ align?: Align = "center",
192
+ alignOffset?: number = 0,
193
+ avoidCollisions?: boolean = true,
194
+ collisionPadding?: number = 0,
195
+ /**
196
+ * Where focus lands when it opens, when the first focus stop is the wrong
197
+ * answer. The same prop `Dialog.Body` takes, deliberately spelled the same
198
+ * way: `DatePicker.Calendar` fills it with the day that holds the grid's tab
199
+ * stop, because a reader who opened a date picker is looking for the date and
200
+ * not for the button that steps back a month.
201
+ */
202
+ initialFocus?: { current: HTMLElement | null },
203
+ render?: RenderProp,
204
+ side?: LogicalSide = "bottom",
205
+ sideOffset?: number = 0,
206
+ ...rest: Rest
207
+ ) {
208
+ const popover = usePopover("Popover.Body");
209
+ const bodyRef = useRef<HTMLElement | null>(null);
210
+ // Stable, so the effect below depends on `open` and on nothing else. Keyed on
211
+ // `setOpen` it re-ran whenever the caller passed a fresh `onOpenChange`
212
+ // closure — which is every render — and re-running it took focus back from
213
+ // wherever the reader had moved it inside the popover.
214
+ const close = useStableCallback(() => popover.setOpen(false));
215
+ // Set when the reader left rather than closed: a press outside, or a Tab that
216
+ // carried them out. Focus is theirs from then on, and dragging it back to the
217
+ // trigger would undo the thing they just did.
218
+ const left = useRef(false);
219
+ const triggerRef = popover.triggerRef;
220
+
221
+ const anchored = useAnchor({
222
+ align,
223
+ alignOffset,
224
+ anchorRef: triggerRef,
225
+ avoidCollisions,
226
+ collisionPadding,
227
+ open: popover.open,
228
+ overlayRef: bodyRef,
229
+ side,
230
+ sideOffset,
231
+ });
232
+
233
+ useEffect(() => {
234
+ const body = bodyRef.current;
235
+ if (!popover.open || body == null) {
236
+ return;
237
+ }
238
+ const document = body.ownerDocument;
239
+ const trigger = triggerRef.current;
240
+ // Whatever had focus, which is the trigger for a popover that was opened
241
+ // and the previously focused element for one that opened itself.
242
+ const opener = trigger ?? (document.activeElement as $FlowFixMe);
243
+
244
+ const outside = (target: EventTarget | null): boolean => {
245
+ const node: $FlowFixMe = target;
246
+ // The trigger is outside the body and is not "outside" for this purpose.
247
+ return node != null && !body.contains(node) && !(trigger?.contains(node) ?? false);
248
+ };
249
+
250
+ const onOutsidePress = (event: Event) => {
251
+ if (!outside(event.target)) {
252
+ return;
253
+ }
254
+ left.current = true;
255
+ close();
256
+ };
257
+ // Capture, so a press is seen even where something below it stops the
258
+ // event — a menu inside the popover, for instance.
259
+ document.addEventListener("pointerdown", onOutsidePress, true);
260
+
261
+ // Tab out is a dismissal, not an escape hatch that leaves a popover open
262
+ // behind the reader: a non-modal overlay whose reader has gone is one they
263
+ // can no longer press Escape at, because Escape is handled where focus is.
264
+ const onFocusMoved = (event: Event) => {
265
+ if (!outside(event.target)) {
266
+ return;
267
+ }
268
+ left.current = true;
269
+ close();
270
+ };
271
+ document.addEventListener("focusin", onFocusMoved, true);
272
+
273
+ // Where the caller said, then the first thing worth acting on, then the
274
+ // popover itself when it holds nothing focusable - so focus is inside it
275
+ // whichever of the three answers, and Escape reaches the handler below.
276
+ //
277
+ // The named element has to still be *in* this popover, for the reason
278
+ // `dialog.js` gives at the same line: a ref left from a previous opening
279
+ // would move focus somewhere the reader did not open.
280
+ const named = initialFocus?.current ?? null;
281
+ ((named != null && body.contains(named) ? named : focusable(body)[0]) ?? body).focus();
282
+
283
+ return () => {
284
+ document.removeEventListener("pointerdown", onOutsidePress, true);
285
+ document.removeEventListener("focusin", onFocusMoved, true);
286
+ if (left.current) {
287
+ left.current = false;
288
+ return;
289
+ }
290
+ // Only when focus would otherwise be lost to `<body>`, the same
291
+ // condition `menu.js` restores under: a popover closed by a control
292
+ // inside it that moved focus somewhere deliberate must not have that
293
+ // undone.
294
+ const active = document.activeElement;
295
+ if (active == null || active === document.body || body.contains(active)) {
296
+ opener?.focus?.();
297
+ }
298
+ };
299
+ }, [popover.open, triggerRef, close, initialFocus]);
300
+
301
+ if (!popover.open) {
302
+ return null;
303
+ }
304
+
305
+ const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
306
+ // Named by its trigger unless the caller said otherwise. Setting it anyway
307
+ // would override an `aria-label` they passed — `aria-labelledby` wins — and
308
+ // leave the popover announced as its button rather than as itself.
309
+ const named = rest["aria-label"] != null || rest["aria-labelledby"] != null;
310
+
311
+ const props = withProps(passed, {
312
+ "aria-labelledby": named || !popover.triggered ? undefined : `${popover.base}-trigger`,
313
+ children,
314
+ "data-align": anchored.align,
315
+ "data-side": anchored.side,
316
+ "data-state": "open",
317
+ id: `${popover.base}-body`,
318
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
319
+ if (event.key !== "Escape") {
320
+ return;
321
+ }
322
+ event.preventDefault();
323
+ // This popover, not the dialog around it. Two overlays nest in the
324
+ // DOM, so without this one Escape closed both.
325
+ event.stopPropagation();
326
+ close();
327
+ }),
328
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
329
+ bodyRef.current = element;
330
+ }),
331
+ // No `aria-modal`. The page behind a popover is still available, and
332
+ // saying otherwise is the one lie a screen reader cannot see through.
333
+ role: "dialog",
334
+ // So the popover can hold focus itself when it contains nothing focusable,
335
+ // and so Escape has somewhere to be heard.
336
+ tabIndex: -1,
337
+ });
338
+
339
+ if (render != null) {
340
+ return render(props);
341
+ }
342
+ return <div {...props} />;
343
+ }
package/progress.js ADDED
@@ -0,0 +1,91 @@
1
+ // @flow
2
+ //
3
+ // A progress bar, whose one line is the line everybody gets wrong.
4
+ //
5
+ // This is the smallest component in the package and the one most often written
6
+ // inline, which is exactly why it is worth owning: `<div className="bar" />`
7
+ // with a width in a style attribute is invisible to a screen reader, and the
8
+ // version that adds a role usually adds the rest of it wrong.
9
+ //
10
+ // # The indeterminate state
11
+ //
12
+ // A progress bar that does not know how far along it is **omits
13
+ // `aria-valuenow` entirely**. It does not set it to `0`.
14
+ //
15
+ // The two are opposite statements. `aria-valuenow="0"` says "nothing has
16
+ // happened yet", and a reader who asks again in ten seconds and hears zero
17
+ // again concludes the operation is stuck. Omitting it says "in progress,
18
+ // amount unknown", which is what a spinner means and what is actually true.
19
+ // The whole component is a conditional, and this is the condition.
20
+ //
21
+ // # `aria-valuetext`, for when the percentage is not the answer
22
+ //
23
+ // "3 of 10 files" is what a reader wants to hear; "30" is a number they then
24
+ // have to do arithmetic on. `aria-valuetext` replaces the announced value
25
+ // without touching `aria-valuenow`, so the assistive technology still has the
26
+ // number to draw a gauge with and the reader still hears the sentence.
27
+ //
28
+ // It is a prop rather than something a caller adds afterwards because
29
+ // `aria-valuenow` and `aria-valuetext` have to agree, and a caller spreading
30
+ // one of them onto a component that owns the other gets a control that says
31
+ // two different things.
32
+ //
33
+ // # No `"use client"`
34
+ //
35
+ // It holds no state, listens to nothing and manages no focus, so it renders on
36
+ // a server. That is the reason this is a component and not a hook: everything
37
+ // it knows, it was told.
38
+
39
+ import * as React from "@uniflowed/react";
40
+
41
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
42
+ import { withProps } from "./internal/merge-props.js";
43
+ import { clamp } from "./internal/range.js";
44
+
45
+ /**
46
+ * How far along something is, or that it is under way at all.
47
+ *
48
+ * `value` is `null` for a bar that does not know — the default, because a bar
49
+ * that has not been told anything genuinely does not know, and the safe answer
50
+ * has to be the honest one rather than "nothing has happened".
51
+ *
52
+ * A name is the caller's: `aria-label="Uploading"` or an `aria-labelledby`
53
+ * pointing at a heading. A progress bar with no name is announced as
54
+ * "progressbar" and a reader is left to guess what is progressing, which is
55
+ * why every example in the documentation carries one.
56
+ *
57
+ * Nothing here is drawn and nothing is measured for the caller — unlike
58
+ * `Slider`, whose value may be its own, a progress bar's value arrived as a
59
+ * prop, so the caller already has everything they need to size a bar with and
60
+ * a helpful custom property would only be their own arithmetic handed back.
61
+ *
62
+ * `render` changes the element and not the accessibility contract: the
63
+ * progressbar role and value attributes are the props handed to the caller.
64
+ */
65
+ export component Progress(
66
+ value?: number | null = null,
67
+ min?: number = 0,
68
+ max?: number = 100,
69
+ valueText?: string,
70
+ children?: React.Node,
71
+ render?: RenderProp,
72
+ ...rest: Rest
73
+ ) {
74
+ const known = value == null ? null : clamp(value, min, max);
75
+ const props = withProps(rest, {
76
+ "aria-valuemax": max,
77
+ "aria-valuemin": min,
78
+ // Omitted, not zeroed. `aria-valuenow="0"` tells a reader that nothing
79
+ // has happened; leaving it out tells them the amount is unknown, which
80
+ // is the true one and the one a spinner means.
81
+ "aria-valuenow": known ?? undefined,
82
+ "aria-valuetext": valueText,
83
+ children,
84
+ role: "progressbar",
85
+ });
86
+
87
+ if (render != null) {
88
+ return render(props);
89
+ }
90
+ return <div {...props} />;
91
+ }