@uniflowed/ui 0.0.0-alpha.10
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/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
package/toggle-group.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A row of toggle buttons that behaves as one control.
|
|
4
|
+
//
|
|
5
|
+
// It looks like a styling decision — the same buttons, drawn joined up — and it
|
|
6
|
+
// is two: one tab stop for the whole set instead of one per button, and a
|
|
7
|
+
// choice about what a reader is told the set *is*.
|
|
8
|
+
//
|
|
9
|
+
// # `type` is two sets of semantics, not a flag
|
|
10
|
+
//
|
|
11
|
+
// * **`multiple`** — a group of toggle buttons. `role="group"` around
|
|
12
|
+
// `aria-pressed` buttons, any number of them pressed at once. Text
|
|
13
|
+
// alignment left/centre/right is not this; bold/italic/underline is.
|
|
14
|
+
// * **`single`** — a radio group drawn as segments. `role="radiogroup"` and
|
|
15
|
+
// `role="radio"`, because a reader told "three pressed buttons" will
|
|
16
|
+
// reasonably believe they may press all three, and they may not. Choosing
|
|
17
|
+
// one unchooses the rest, and — as in any radio group — there is no gesture
|
|
18
|
+
// that unchooses without choosing something else.
|
|
19
|
+
//
|
|
20
|
+
// So `single` is not a variant of `multiple` with a constraint bolted on: it is
|
|
21
|
+
// a different widget, and it is the one this package already ships.
|
|
22
|
+
// `radio-group.js` renders it — `ToggleGroup.Root` renders a `RadioGroup.Root`
|
|
23
|
+
// and `ToggleGroup.Item` renders a `RadioGroup.Item` — so the arrow keys that
|
|
24
|
+
// check as they move, the initial tab stop when nothing is chosen, and the
|
|
25
|
+
// `aria-checked` wiring have exactly one implementation and cannot drift into
|
|
26
|
+
// two that disagree. What is left here is the `multiple` mode and the choice
|
|
27
|
+
// between them.
|
|
28
|
+
//
|
|
29
|
+
// The one thing that reuse costs is stated in `radio-group.js`:
|
|
30
|
+
// `RadioGroup.Root` takes `React.Node` rather than `renders* RadioGroupItem`,
|
|
31
|
+
// because the items arriving through here produce a radio in one mode and a
|
|
32
|
+
// toggle button in the other, and no `renders*` promise can hold for both.
|
|
33
|
+
//
|
|
34
|
+
// # One value type for both modes
|
|
35
|
+
//
|
|
36
|
+
// `value` is always a `$ReadOnlyArray<string>` — the items that are on. In
|
|
37
|
+
// `single` mode the array holds at most one, and that invariant is the
|
|
38
|
+
// component's to keep rather than the caller's to remember. The alternative,
|
|
39
|
+
// `string | $ReadOnlyArray<string>` chosen by `type`, is a type that makes
|
|
40
|
+
// every caller narrow at every use for a shape they already know, and Flow
|
|
41
|
+
// cannot tie it to a sibling prop's value anyway; a type that has to be
|
|
42
|
+
// narrowed past is a type that has given up.
|
|
43
|
+
//
|
|
44
|
+
// # Naming the set
|
|
45
|
+
//
|
|
46
|
+
// A group with no name is announced as "group" and nothing else. Pass
|
|
47
|
+
// `aria-label`, or wire `Field.Label` through `Field.Control` the way
|
|
48
|
+
// `radio-group.js` shows.
|
|
49
|
+
|
|
50
|
+
"use client";
|
|
51
|
+
|
|
52
|
+
import * as React from "@uniflowed/react";
|
|
53
|
+
import {
|
|
54
|
+
createContext,
|
|
55
|
+
useCallback,
|
|
56
|
+
useContext,
|
|
57
|
+
useId,
|
|
58
|
+
useMemo,
|
|
59
|
+
useRef,
|
|
60
|
+
useState,
|
|
61
|
+
} from "@uniflowed/react";
|
|
62
|
+
|
|
63
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
64
|
+
import {
|
|
65
|
+
composeHandlers,
|
|
66
|
+
composeRefs,
|
|
67
|
+
forwarded,
|
|
68
|
+
withoutComposed,
|
|
69
|
+
} from "./internal/merge-props.js";
|
|
70
|
+
import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
|
|
71
|
+
import type { Orientation, RovingSet } from "./internal/roving-focus.js";
|
|
72
|
+
import { RadioGroupItem, RadioGroupRoot } from "./radio-group.js";
|
|
73
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
74
|
+
|
|
75
|
+
/** Whether the set holds one answer or any number of them. */
|
|
76
|
+
export type ToggleGroupType = "single" | "multiple";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The empty selection, hoisted so it is one array rather than a new one per
|
|
80
|
+
* render — a fresh `[]` default would be a new identity in every `useMemo`
|
|
81
|
+
* dependency list that ever holds it.
|
|
82
|
+
*/
|
|
83
|
+
const NOTHING: $ReadOnlyArray<string> = [];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What the keyboard steps across in a `multiple` group.
|
|
87
|
+
*
|
|
88
|
+
* `[aria-pressed]` rather than a `data-*` name of this package's own, for the
|
|
89
|
+
* same reason `radio-group.js` looks for `[role="radio"]`: the attribute is
|
|
90
|
+
* what makes an element one of these, so anything that carries it is something
|
|
91
|
+
* the arrow keys must reach. A `single` group asks `radio-group.js` instead.
|
|
92
|
+
*/
|
|
93
|
+
function toggleSet(orientation: Orientation): RovingSet {
|
|
94
|
+
return {
|
|
95
|
+
item: "[aria-pressed]",
|
|
96
|
+
owner: '[role="group"]',
|
|
97
|
+
orientation,
|
|
98
|
+
wrap: true,
|
|
99
|
+
skipDisabled: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type ToggleGroupState = {|
|
|
104
|
+
readonly type: ToggleGroupType,
|
|
105
|
+
readonly pressed: $ReadOnlyArray<string>,
|
|
106
|
+
readonly toggle: (value: string) => void,
|
|
107
|
+
/** The item focus last visited, which holds the tab stop for the set. */
|
|
108
|
+
readonly activeId: string | null,
|
|
109
|
+
readonly setActiveId: (id: string) => void,
|
|
110
|
+
/** The item holding the tab stop before focus has visited any; see `useFirstItem`. */
|
|
111
|
+
readonly firstId: string | null,
|
|
112
|
+
|};
|
|
113
|
+
|
|
114
|
+
const ToggleGroupContext: React.Context<ToggleGroupState | null> = createContext(null);
|
|
115
|
+
|
|
116
|
+
hook useToggleGroup(part: string): ToggleGroupState {
|
|
117
|
+
const state = useContext(ToggleGroupContext);
|
|
118
|
+
if (state == null) {
|
|
119
|
+
throw new Error(`${part} must be rendered inside a ToggleGroup.Root`);
|
|
120
|
+
}
|
|
121
|
+
return state;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The set.
|
|
126
|
+
*
|
|
127
|
+
* Uncontrolled by default and controlled the moment `value` is passed, like
|
|
128
|
+
* everything else here.
|
|
129
|
+
*/
|
|
130
|
+
export component ToggleGroupRoot(
|
|
131
|
+
children: renders* ToggleGroupItem,
|
|
132
|
+
type?: ToggleGroupType = "multiple",
|
|
133
|
+
defaultValue?: $ReadOnlyArray<string> = NOTHING,
|
|
134
|
+
value?: $ReadOnlyArray<string>,
|
|
135
|
+
onValueChange?: (value: $ReadOnlyArray<string>) => void,
|
|
136
|
+
orientation?: Orientation = "horizontal",
|
|
137
|
+
...rest: Rest
|
|
138
|
+
) {
|
|
139
|
+
const [pressed, setPressed] = useControlled<$ReadOnlyArray<string>>(
|
|
140
|
+
value,
|
|
141
|
+
defaultValue,
|
|
142
|
+
onValueChange,
|
|
143
|
+
);
|
|
144
|
+
const rootRef = useRef<HTMLElement | null>(null);
|
|
145
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
146
|
+
// Only for `multiple`, and only until focus has been somewhere: a `single`
|
|
147
|
+
// group's tab stop is `radio-group.js`'s business, and once focus has landed
|
|
148
|
+
// the item it landed on holds it.
|
|
149
|
+
const firstId = useFirstItem(
|
|
150
|
+
rootRef,
|
|
151
|
+
toggleSet(orientation),
|
|
152
|
+
type === "multiple" && activeId == null,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const toggle = useCallback(
|
|
156
|
+
(item: string) => {
|
|
157
|
+
setPressed(
|
|
158
|
+
pressed.includes(item) ? pressed.filter((each) => each !== item) : [...pressed, item],
|
|
159
|
+
);
|
|
160
|
+
},
|
|
161
|
+
[pressed, setPressed],
|
|
162
|
+
);
|
|
163
|
+
// Stable, so the radio group below does not get a new `onValueChange` on
|
|
164
|
+
// every render and hand every one of its items a new context with it.
|
|
165
|
+
const chooseOne = useCallback((next: string) => setPressed([next]), [setPressed]);
|
|
166
|
+
|
|
167
|
+
const state = useMemo(
|
|
168
|
+
() => ({ type, pressed, toggle, activeId, setActiveId, firstId }),
|
|
169
|
+
[type, pressed, toggle, activeId, firstId],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (type === "single") {
|
|
173
|
+
// The whole of the single mode. `radio-group.js` owns the arrow keys that
|
|
174
|
+
// check as they move, the tab stop while nothing is chosen, the
|
|
175
|
+
// `aria-checked` wiring and the `role="radiogroup"` container; a second
|
|
176
|
+
// copy of any of it here would be a second thing to keep correct.
|
|
177
|
+
return (
|
|
178
|
+
<ToggleGroupContext.Provider value={state}>
|
|
179
|
+
<RadioGroupRoot
|
|
180
|
+
{...forwarded(rest)}
|
|
181
|
+
onValueChange={chooseOne}
|
|
182
|
+
orientation={orientation}
|
|
183
|
+
value={pressed[0] ?? null}
|
|
184
|
+
>
|
|
185
|
+
{children}
|
|
186
|
+
</RadioGroupRoot>
|
|
187
|
+
</ToggleGroupContext.Provider>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<ToggleGroupContext.Provider value={state}>
|
|
195
|
+
<div
|
|
196
|
+
{...passed}
|
|
197
|
+
aria-orientation={orientation}
|
|
198
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
199
|
+
const group: $FlowFixMe = event.currentTarget;
|
|
200
|
+
// Moves and nothing else. Pressing every button the arrows pass over
|
|
201
|
+
// is what a `single` group does, and doing it here would apply half a
|
|
202
|
+
// dozen commands on the way to the one the reader wanted.
|
|
203
|
+
moveOnKey(event, group, toggleSet(orientation));
|
|
204
|
+
})}
|
|
205
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
206
|
+
rootRef.current = element;
|
|
207
|
+
})}
|
|
208
|
+
role="group"
|
|
209
|
+
>
|
|
210
|
+
{children}
|
|
211
|
+
</div>
|
|
212
|
+
</ToggleGroupContext.Provider>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* One button of the set.
|
|
218
|
+
*
|
|
219
|
+
* A disabled item is `aria-disabled` rather than `disabled`, the opposite of
|
|
220
|
+
* the lone `Toggle`: an unavailable button *in a set* has to stay announced, or
|
|
221
|
+
* a reader is told the set has two members when it has three and cannot ask
|
|
222
|
+
* where the third went. The arrow keys step over it either way.
|
|
223
|
+
*/
|
|
224
|
+
export component ToggleGroupItem(
|
|
225
|
+
value: string,
|
|
226
|
+
children?: React.Node,
|
|
227
|
+
disabled?: boolean = false,
|
|
228
|
+
...rest: Rest
|
|
229
|
+
) {
|
|
230
|
+
const group = useToggleGroup("ToggleGroup.Item");
|
|
231
|
+
// Before the branch, because it is a hook: which mode the set is in is not
|
|
232
|
+
// allowed to change how many of them run.
|
|
233
|
+
const id = useId();
|
|
234
|
+
|
|
235
|
+
if (group.type === "single") {
|
|
236
|
+
// A radio, whole. The `aria-checked` state, the roving tab stop and the
|
|
237
|
+
// arrow keys that check as they move all belong to the radio group that
|
|
238
|
+
// `ToggleGroup.Root` rendered around this.
|
|
239
|
+
return (
|
|
240
|
+
<RadioGroupItem {...forwarded(rest)} disabled={disabled} value={value}>
|
|
241
|
+
{children}
|
|
242
|
+
</RadioGroupItem>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const on = group.pressed.includes(value);
|
|
247
|
+
const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown"]);
|
|
248
|
+
const setActiveId = group.setActiveId;
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<button
|
|
252
|
+
{...passed}
|
|
253
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
254
|
+
aria-pressed={on ? "true" : "false"}
|
|
255
|
+
id={id}
|
|
256
|
+
onClick={composeHandlers(rest.onClick, () => {
|
|
257
|
+
if (!disabled) {
|
|
258
|
+
group.toggle(value);
|
|
259
|
+
}
|
|
260
|
+
})}
|
|
261
|
+
// The roving tab stop follows real focus rather than leading it, so a
|
|
262
|
+
// pointer that moves focus and an arrow key that moves focus agree
|
|
263
|
+
// without the two having to be kept in step by hand.
|
|
264
|
+
onFocus={composeHandlers(rest.onFocus, () => setActiveId(id))}
|
|
265
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
266
|
+
if (disabled || (event.key !== " " && event.key !== "Enter")) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// Stops `Space` scrolling the page, and stops the browser's own click
|
|
270
|
+
// arriving afterwards and pressing this back to where it started.
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
group.toggle(value);
|
|
273
|
+
})}
|
|
274
|
+
tabIndex={group.activeId === id || (group.activeId == null && group.firstId === id) ? 0 : -1}
|
|
275
|
+
type="button"
|
|
276
|
+
>
|
|
277
|
+
{children}
|
|
278
|
+
</button>
|
|
279
|
+
);
|
|
280
|
+
}
|
package/toggle.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A toggle button: an action that stays applied.
|
|
4
|
+
//
|
|
5
|
+
// This is the third of the package's two-state controls, and the argument that
|
|
6
|
+
// separates them is already half written in `switch.js` and `checkbox.js`. What
|
|
7
|
+
// a reader is told is the whole of it:
|
|
8
|
+
//
|
|
9
|
+
// * **`role="switch"`** — "notifications, switch, on". A *setting*. It takes
|
|
10
|
+
// effect where it is, and `switch.js` explains why it is not a checkbox.
|
|
11
|
+
// * **`role="checkbox"`** — "subscribe, checkbox, checked". An *answer* to a
|
|
12
|
+
// question, usually in a form, usually not doing anything until the form is
|
|
13
|
+
// submitted, and the only one of the three with a third state.
|
|
14
|
+
// * **`aria-pressed`** — "bold, toggle button, pressed". An *action* that
|
|
15
|
+
// stays applied. Bold in a toolbar. Pressing it does the thing immediately,
|
|
16
|
+
// and the pressed state is a report of what was done rather than a value
|
|
17
|
+
// anybody is going to submit.
|
|
18
|
+
//
|
|
19
|
+
// A toggle button announced as a checkbox tells a reader they are answering a
|
|
20
|
+
// question, and one announced as a switch tells them they are configuring
|
|
21
|
+
// something; a toolbar full of either is a toolbar nobody can use by ear. The
|
|
22
|
+
// three are separate files rather than one with a `role` prop because the
|
|
23
|
+
// keyboard, the states and the reason to reach for each of them differ, and a
|
|
24
|
+
// flag would let a caller pick the wrong one without ever being told what the
|
|
25
|
+
// difference was.
|
|
26
|
+
//
|
|
27
|
+
// # `Space` and `Enter` both press it
|
|
28
|
+
//
|
|
29
|
+
// Because a toggle button is a button, and a button activates on both. That is
|
|
30
|
+
// the same reasoning `switch.js` gives for `Enter`, and the opposite of
|
|
31
|
+
// `checkbox.js`, which leaves `Enter` alone because a checkbox is something a
|
|
32
|
+
// reader answers on their way to submitting a form.
|
|
33
|
+
//
|
|
34
|
+
// # It is `disabled`, not `aria-disabled`
|
|
35
|
+
//
|
|
36
|
+
// A lone toggle button that cannot be pressed should be out of the tab order,
|
|
37
|
+
// like the `<button>` it is — nothing is lost, because a reader who never
|
|
38
|
+
// reaches it never wonders where the rest of the set went. `ToggleGroup.Item`
|
|
39
|
+
// makes the opposite choice for the opposite reason: an unavailable item *in a
|
|
40
|
+
// set* has to stay announced, or a reader finds a gap they cannot ask about.
|
|
41
|
+
|
|
42
|
+
"use client";
|
|
43
|
+
|
|
44
|
+
import * as React from "@uniflowed/react";
|
|
45
|
+
|
|
46
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
47
|
+
import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
|
|
48
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
49
|
+
|
|
50
|
+
/** A button whose state stays applied: pressed or not. */
|
|
51
|
+
export component Toggle(
|
|
52
|
+
pressed?: boolean,
|
|
53
|
+
defaultPressed?: boolean = false,
|
|
54
|
+
onPressedChange?: (pressed: boolean) => void,
|
|
55
|
+
disabled?: boolean = false,
|
|
56
|
+
children?: React.Node,
|
|
57
|
+
...rest: Rest
|
|
58
|
+
) {
|
|
59
|
+
const [on, setOn] = useControlled(pressed, defaultPressed, onPressedChange);
|
|
60
|
+
const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
{...passed}
|
|
65
|
+
// No `role`: this *is* a button, and `aria-pressed` is what makes it a
|
|
66
|
+
// toggle one. Adding `role="button"` to a `<button>` would be noise, and
|
|
67
|
+
// adding any other role would be a lie about what pressing it does.
|
|
68
|
+
aria-pressed={on ? "true" : "false"}
|
|
69
|
+
disabled={disabled}
|
|
70
|
+
onClick={composeHandlers(rest.onClick, () => {
|
|
71
|
+
if (!disabled) {
|
|
72
|
+
setOn(!on);
|
|
73
|
+
}
|
|
74
|
+
})}
|
|
75
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
76
|
+
if (disabled || (event.key !== " " && event.key !== "Enter")) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// Preventing the default stops `Space` scrolling the page, and stops
|
|
80
|
+
// the browser's own click arriving after this handler and pressing the
|
|
81
|
+
// button a second time — back to where it started, which reads as the
|
|
82
|
+
// key having done nothing at all.
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
setOn(!on);
|
|
85
|
+
})}
|
|
86
|
+
type="button"
|
|
87
|
+
>
|
|
88
|
+
{children}
|
|
89
|
+
</button>
|
|
90
|
+
);
|
|
91
|
+
}
|