@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/radio-group.js ADDED
@@ -0,0 +1,304 @@
1
+ // @flow
2
+ //
3
+ // A radio group: several answers, where choosing one unchooses the rest.
4
+ //
5
+ // The group is the control, not the buttons in it. That is the sentence the
6
+ // whole component follows from: a reader is told "Plan, radio group" and then
7
+ // "Pro, radio button, 2 of 3, selected", the *set* takes one stop in the page's
8
+ // tab order, and the arrow keys move inside it. Twelve hand-written radios take
9
+ // twelve Tab presses to get past and announce themselves as twelve unrelated
10
+ // buttons, which is a different widget wearing the same paint.
11
+ //
12
+ // # It is a tab list with automatic activation, under another name
13
+ //
14
+ // `tabs.js` already implements almost all of this: one tab stop, arrows that
15
+ // move, and `activationMode="automatic"` where moving to an item selects it. A
16
+ // radio group is that with `role="radio"` in place of `role="tab"` and no
17
+ // manual mode at all — arrows in a radio group *always* check, because that is
18
+ // what the pattern says, and a radio group whose arrows only moved focus would
19
+ // leave a reader believing they had answered when they had not.
20
+ //
21
+ // The one thing it needs that no other set here does is **the initial tab
22
+ // stop**. `Tabs.Tab` computes `tabIndex={active ? 0 : -1}` from the selection,
23
+ // which is right for both — but `Tabs` always has a selection, because
24
+ // `defaultValue` is required, and a radio group with nothing chosen is the
25
+ // state every unanswered form starts in. With the tab stop derived from the
26
+ // selection alone, nothing carries `tabindex="0"`, and an unanswered radio
27
+ // group is unreachable from the keyboard: not hard to reach, not awkward —
28
+ // absent. `internal/roving-focus.js`'s `useFirstItem` is the answer, and it is
29
+ // there rather than here because a toggle group nobody has focused yet has the
30
+ // same hole.
31
+ //
32
+ // # Which arrow keys, and a deviation stated rather than hidden
33
+ //
34
+ // The WAI-ARIA practices list both pairs for a radio group: `ArrowDown` and
35
+ // `ArrowRight` both move to the next radio. This package gates on `orientation`
36
+ // instead, as its tab list and its menu do, and the reason is the one
37
+ // `internal/roving-focus.js` gives about the keys a component does *not* claim:
38
+ // `ArrowDown` scrolls the page, and a horizontal row of three radios that
39
+ // swallows it has taken reading away from everyone who reads with the keyboard
40
+ // to buy a second way to do what `ArrowRight` already does. `aria-orientation`
41
+ // says which pair is live, so a reader is told rather than left to guess, and
42
+ // the default is `vertical` because that is how a radio group is nearly always
43
+ // laid out.
44
+ //
45
+ // # Naming the group
46
+ //
47
+ // A radio group with no name is announced as "radio group" and nothing else,
48
+ // which tells a reader that three answers exist and not what the question was.
49
+ // There is no `RadioGroup.Label` part because `Field` already is one:
50
+ //
51
+ // <Field.Root>
52
+ // <Field.Label>Plan</Field.Label>
53
+ // <Field.Control
54
+ // render={(props) => (
55
+ // <RadioGroup.Root {...props} defaultValue="free">…</RadioGroup.Root>
56
+ // )}
57
+ // />
58
+ // </Field.Root>
59
+ //
60
+ // `Field.Control` hands over `aria-labelledby` pointing at the label it
61
+ // rendered, which is the wiring `Field` exists to get right, and a second
62
+ // spelling of it here would be a second thing to keep in step.
63
+ //
64
+ // # Space, and the key that is not handled
65
+ //
66
+ // `Space` checks the focused radio, and prevents its default so the page does
67
+ // not scroll and the browser's own click does not arrive afterwards and check
68
+ // it a second time. `Enter` is not handled: it reaches these items as the
69
+ // browser's own click on a `<button>` and checks them, which is the least
70
+ // surprising thing for it to do. Claiming it in order to *stop* it would leave
71
+ // the key dead — `type="button"` cannot submit a form either — which is worse
72
+ // than the practices being quiet about it.
73
+
74
+ "use client";
75
+
76
+ import * as React from "@uniflowed/react";
77
+ import { createContext, useCallback, useContext, useId, useMemo, useRef } from "@uniflowed/react";
78
+
79
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
80
+ import {
81
+ composeHandlers,
82
+ composeRefs,
83
+ withoutComposed,
84
+ withProps,
85
+ } from "./internal/merge-props.js";
86
+ import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
87
+ import type { Orientation, RovingSet } from "./internal/roving-focus.js";
88
+ import { useControlled } from "./internal/controlled-state.js";
89
+
90
+ /**
91
+ * What the keyboard steps across in a radio group, and what owns one.
92
+ *
93
+ * By role rather than by a `data-*` attribute of this package's own, because
94
+ * that is the promise the component makes to a reader: whatever produced a
95
+ * `role="radio"` inside this group is one of the answers, and the arrow keys
96
+ * have to reach it. `ToggleGroup type="single"` renders through here and is the
97
+ * reason that matters in practice rather than in principle.
98
+ */
99
+ export function radioSet(orientation: Orientation): RovingSet {
100
+ return {
101
+ item: '[role="radio"]',
102
+ owner: '[role="radiogroup"]',
103
+ orientation,
104
+ wrap: true,
105
+ skipDisabled: true,
106
+ };
107
+ }
108
+
109
+ type RadioGroupState = {|
110
+ readonly selected: string | null,
111
+ readonly select: (value: string) => void,
112
+ /** The item holding the tab stop while nothing is chosen; see `useFirstItem`. */
113
+ readonly firstId: string | null,
114
+ |};
115
+
116
+ const RadioGroupContext: React.Context<RadioGroupState | null> = createContext(null);
117
+
118
+ /** Whether the surrounding item is the chosen one, for `RadioGroup.Indicator`. */
119
+ type RadioItemState = {| readonly checked: boolean |};
120
+
121
+ const RadioItemContext: React.Context<RadioItemState | null> = createContext(null);
122
+
123
+ hook useRadioGroup(part: string): RadioGroupState {
124
+ const state = useContext(RadioGroupContext);
125
+ if (state == null) {
126
+ throw new Error(`${part} must be rendered inside a RadioGroup.Root`);
127
+ }
128
+ return state;
129
+ }
130
+
131
+ /**
132
+ * The group, which is the control a reader is told about.
133
+ *
134
+ * `children` is `React.Node` rather than `renders* RadioGroupItem`, and the
135
+ * reason is worth stating rather than leaving as an omission. `ToggleGroup
136
+ * type="single"` is this component wearing segments: it renders a
137
+ * `RadioGroup.Root` and passes its own items through. A `renders*` constraint
138
+ * is a promise about the element a child produces, and a `ToggleGroup.Item`
139
+ * cannot make it — it produces a radio in one mode and a toggle button in the
140
+ * other — while naming both kinds here would make this module import the module
141
+ * that imports it. `Tabs.List` keeps the tighter promise because nothing else
142
+ * in this package renders a `tab`.
143
+ *
144
+ * `name` puts the answer where a form can submit it; see the hidden input
145
+ * below.
146
+ */
147
+ export component RadioGroupRoot(
148
+ children: React.Node,
149
+ defaultValue?: string | null = null,
150
+ value?: string | null,
151
+ onValueChange?: (value: string) => void,
152
+ orientation?: Orientation = "vertical",
153
+ name?: string,
154
+ render?: RenderProp,
155
+ ...rest: Rest
156
+ ) {
157
+ // `onValueChange` promises a `string` while the group's *state* is
158
+ // `string | null`, and the two meet here rather than being flattened into one
159
+ // type that lies in one direction or the other: "nothing chosen yet" is a
160
+ // state a radio group starts in, and it is not an event it can ever report,
161
+ // because no gesture inside a radio group unchooses an answer. Widening the
162
+ // prop to `string | null` would also make every `(plan: string) => void` a
163
+ // caller already has a type error at the call site.
164
+ const report = useCallback(
165
+ (next: string | null) => {
166
+ if (next != null) {
167
+ onValueChange?.(next);
168
+ }
169
+ },
170
+ [onValueChange],
171
+ );
172
+ const [selected, select] = useControlled<string | null>(value, defaultValue, report);
173
+ const rootRef = useRef<HTMLElement | null>(null);
174
+ // Only while nothing is chosen. Once there is an answer it holds the tab
175
+ // stop, and asking the document which item comes first is work with no reader.
176
+ const firstId = useFirstItem(rootRef, radioSet(orientation), selected == null);
177
+
178
+ const state = useMemo(() => ({ selected, select, firstId }), [selected, select, firstId]);
179
+ const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
180
+ const content = (
181
+ <>
182
+ {children}
183
+ {/*
184
+ A form submits `<input>` elements, and none of the parts above is one.
185
+ Without this the group is a control a reader can operate and a form
186
+ cannot read, which is the same hole `Combobox` still has.
187
+
188
+ `type="hidden"` rather than a visually hidden real radio, because the
189
+ buttons above already carry the whole of the accessible semantics: a
190
+ second set of native radios would be announced as a second set of
191
+ answers, and hiding them from the accessibility tree to stop that
192
+ leaves elements a form's own validation would then point its
193
+ "please choose one" at.
194
+ */}
195
+ {name == null ? null : <input name={name} type="hidden" value={selected ?? ""} />}
196
+ </>
197
+ );
198
+ const props = withProps(passed, {
199
+ "aria-orientation": orientation,
200
+ children: content,
201
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
202
+ const group: $FlowFixMe = event.currentTarget;
203
+ const next = moveOnKey(event, group, radioSet(orientation));
204
+ if (next != null) {
205
+ // Checking in the same key press is not a shortcut, it is the
206
+ // pattern: a radio group whose arrows moved focus without checking
207
+ // leaves a reader believing they have answered when they have not.
208
+ select(next.getAttribute("data-value") ?? "");
209
+ }
210
+ }),
211
+ // React calls callback refs during commit; keyboard handlers read it later.
212
+ // uf-lint-disable-next-line react-compiler/refs
213
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
214
+ rootRef.current = element;
215
+ }),
216
+ role: "radiogroup",
217
+ });
218
+
219
+ return (
220
+ <RadioGroupContext.Provider value={state}>
221
+ {render == null ? <div {...props} /> : render(props)}
222
+ </RadioGroupContext.Provider>
223
+ );
224
+ }
225
+
226
+ /**
227
+ * One answer.
228
+ *
229
+ * A disabled item is `aria-disabled` rather than `disabled`, so it stays in the
230
+ * accessibility tree: a reader is told "Enterprise, radio button, dimmed, 3 of
231
+ * 3" and learns that the answer exists and is unavailable, where a native
232
+ * `disabled` leaves a gap they cannot ask about. The arrow keys step over it
233
+ * either way, and so does the search for the item that holds the tab stop.
234
+ */
235
+ export component RadioGroupItem(
236
+ value: string,
237
+ children?: React.Node,
238
+ disabled?: boolean = false,
239
+ render?: RenderProp,
240
+ ...rest: Rest
241
+ ) {
242
+ const group = useRadioGroup("RadioGroup.Item");
243
+ const id = useId();
244
+ const checked = group.selected === value;
245
+ const item = useMemo(() => ({ checked }), [checked]);
246
+ const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
247
+ const props = withProps(passed, {
248
+ "aria-checked": checked ? "true" : "false",
249
+ "aria-disabled": disabled ? "true" : undefined,
250
+ // Read by the group's key handler, which finds items in the document
251
+ // rather than in a registry and so needs each one to carry its value.
252
+ "data-value": value,
253
+ children,
254
+ id,
255
+ onClick: composeHandlers(rest.onClick, (_event: PartEvent) => {
256
+ if (!disabled) {
257
+ group.select(value);
258
+ }
259
+ }),
260
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
261
+ if (disabled || event.key !== " ") {
262
+ return;
263
+ }
264
+ // Stops `Space` scrolling the page — which is what makes a
265
+ // hand-written radio feel broken even when it works — and stops the
266
+ // browser's own click arriving afterwards to check this again.
267
+ event.preventDefault();
268
+ group.select(value);
269
+ }),
270
+ role: "radio",
271
+ // The roving tab stop: the chosen answer, or the first one while there
272
+ // is no answer, so `Tab` reaches the group in either state and leaves
273
+ // it in one press.
274
+ tabIndex: checked || (group.selected == null && group.firstId === id) ? 0 : -1,
275
+ });
276
+
277
+ return (
278
+ <RadioItemContext.Provider value={item}>
279
+ {render == null ? <button {...props} type="button" /> : render(props)}
280
+ </RadioItemContext.Provider>
281
+ );
282
+ }
283
+
284
+ /**
285
+ * The mark inside the chosen answer, rendered only while it is chosen.
286
+ *
287
+ * `aria-hidden` because the item it sits in already says `aria-checked`: a dot
288
+ * that also announced itself would have a reader hear the answer's state twice,
289
+ * once as a state and once as a stray element. It exists so a caller can style
290
+ * a mark that appears and disappears without reaching for
291
+ * `[aria-checked="true"] > *`, and so the "only while chosen" part is not
292
+ * something each caller reimplements.
293
+ */
294
+ export component RadioGroupIndicator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
295
+ const item = useContext(RadioItemContext);
296
+ if (item == null) {
297
+ throw new Error("RadioGroup.Indicator must be rendered inside a RadioGroup.Item");
298
+ }
299
+ if (!item.checked) {
300
+ return null;
301
+ }
302
+ const props = withProps(rest, { "aria-hidden": "true", children });
303
+ return render == null ? <span {...props} /> : render(props);
304
+ }