@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.
@@ -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
+ }