@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.40
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 +360 -0
- package/alert-dialog.js +282 -0
- package/alert.js +142 -0
- package/avatar.js +276 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +547 -0
- package/carousel.js +410 -0
- package/checkbox.js +216 -31
- package/collapsible.js +169 -0
- package/combobox.js +209 -40
- package/context-menu.js +206 -0
- package/date-picker.js +346 -0
- package/dialog.js +229 -197
- package/drawer.js +490 -0
- package/field.js +257 -42
- package/hover-card.js +330 -0
- package/index.js +1548 -24
- package/input-otp.js +218 -0
- package/interactions.js +2323 -0
- package/internal/anchor.js +565 -0
- package/internal/date-grid.js +260 -0
- package/internal/disclosure.js +298 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +206 -7
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +205 -11
- package/menu.js +521 -336
- package/menubar.js +288 -0
- package/navigation-menu.js +251 -0
- package/package.json +8 -12
- package/pagination.js +209 -0
- package/popover.js +344 -0
- package/progress.js +91 -0
- package/radio-group.js +302 -0
- package/resizable.js +447 -0
- package/scroll-area.js +283 -0
- package/select.js +888 -0
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +313 -0
- package/skeleton.js +159 -0
- package/slider.js +405 -0
- package/switch.js +43 -34
- package/table.js +520 -0
- package/tabs.js +99 -96
- package/toast.js +592 -0
- package/toggle-group.js +282 -0
- package/toggle.js +105 -0
- package/tooltip.js +400 -0
package/radio-group.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
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
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
212
|
+
rootRef.current = element;
|
|
213
|
+
}),
|
|
214
|
+
role: "radiogroup",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<RadioGroupContext.Provider value={state}>
|
|
219
|
+
{render == null ? <div {...props} /> : render(props)}
|
|
220
|
+
</RadioGroupContext.Provider>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* One answer.
|
|
226
|
+
*
|
|
227
|
+
* A disabled item is `aria-disabled` rather than `disabled`, so it stays in the
|
|
228
|
+
* accessibility tree: a reader is told "Enterprise, radio button, dimmed, 3 of
|
|
229
|
+
* 3" and learns that the answer exists and is unavailable, where a native
|
|
230
|
+
* `disabled` leaves a gap they cannot ask about. The arrow keys step over it
|
|
231
|
+
* either way, and so does the search for the item that holds the tab stop.
|
|
232
|
+
*/
|
|
233
|
+
export component RadioGroupItem(
|
|
234
|
+
value: string,
|
|
235
|
+
children?: React.Node,
|
|
236
|
+
disabled?: boolean = false,
|
|
237
|
+
render?: RenderProp,
|
|
238
|
+
...rest: Rest
|
|
239
|
+
) {
|
|
240
|
+
const group = useRadioGroup("RadioGroup.Item");
|
|
241
|
+
const id = useId();
|
|
242
|
+
const checked = group.selected === value;
|
|
243
|
+
const item = useMemo(() => ({ checked }), [checked]);
|
|
244
|
+
const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
|
|
245
|
+
const props = withProps(passed, {
|
|
246
|
+
"aria-checked": checked ? "true" : "false",
|
|
247
|
+
"aria-disabled": disabled ? "true" : undefined,
|
|
248
|
+
// Read by the group's key handler, which finds items in the document
|
|
249
|
+
// rather than in a registry and so needs each one to carry its value.
|
|
250
|
+
"data-value": value,
|
|
251
|
+
children,
|
|
252
|
+
id,
|
|
253
|
+
onClick: composeHandlers(rest.onClick, (_event: PartEvent) => {
|
|
254
|
+
if (!disabled) {
|
|
255
|
+
group.select(value);
|
|
256
|
+
}
|
|
257
|
+
}),
|
|
258
|
+
onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
|
|
259
|
+
if (disabled || event.key !== " ") {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// Stops `Space` scrolling the page — which is what makes a
|
|
263
|
+
// hand-written radio feel broken even when it works — and stops the
|
|
264
|
+
// browser's own click arriving afterwards to check this again.
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
group.select(value);
|
|
267
|
+
}),
|
|
268
|
+
role: "radio",
|
|
269
|
+
// The roving tab stop: the chosen answer, or the first one while there
|
|
270
|
+
// is no answer, so `Tab` reaches the group in either state and leaves
|
|
271
|
+
// it in one press.
|
|
272
|
+
tabIndex: checked || (group.selected == null && group.firstId === id) ? 0 : -1,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<RadioItemContext.Provider value={item}>
|
|
277
|
+
{render == null ? <button {...props} type="button" /> : render(props)}
|
|
278
|
+
</RadioItemContext.Provider>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The mark inside the chosen answer, rendered only while it is chosen.
|
|
284
|
+
*
|
|
285
|
+
* `aria-hidden` because the item it sits in already says `aria-checked`: a dot
|
|
286
|
+
* that also announced itself would have a reader hear the answer's state twice,
|
|
287
|
+
* once as a state and once as a stray element. It exists so a caller can style
|
|
288
|
+
* a mark that appears and disappears without reaching for
|
|
289
|
+
* `[aria-checked="true"] > *`, and so the "only while chosen" part is not
|
|
290
|
+
* something each caller reimplements.
|
|
291
|
+
*/
|
|
292
|
+
export component RadioGroupIndicator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
293
|
+
const item = useContext(RadioItemContext);
|
|
294
|
+
if (item == null) {
|
|
295
|
+
throw new Error("RadioGroup.Indicator must be rendered inside a RadioGroup.Item");
|
|
296
|
+
}
|
|
297
|
+
if (!item.checked) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
const props = withProps(rest, { "aria-hidden": "true", children });
|
|
301
|
+
return render == null ? <span {...props} /> : render(props);
|
|
302
|
+
}
|