@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/radio-group.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
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 { Rest } from "./internal/merge-props.js";
|
|
80
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
81
|
+
import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
|
|
82
|
+
import type { Orientation, RovingSet } from "./internal/roving-focus.js";
|
|
83
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What the keyboard steps across in a radio group, and what owns one.
|
|
87
|
+
*
|
|
88
|
+
* By role rather than by a `data-*` attribute of this package's own, because
|
|
89
|
+
* that is the promise the component makes to a reader: whatever produced a
|
|
90
|
+
* `role="radio"` inside this group is one of the answers, and the arrow keys
|
|
91
|
+
* have to reach it. `ToggleGroup type="single"` renders through here and is the
|
|
92
|
+
* reason that matters in practice rather than in principle.
|
|
93
|
+
*/
|
|
94
|
+
export function radioSet(orientation: Orientation): RovingSet {
|
|
95
|
+
return {
|
|
96
|
+
item: '[role="radio"]',
|
|
97
|
+
owner: '[role="radiogroup"]',
|
|
98
|
+
orientation,
|
|
99
|
+
wrap: true,
|
|
100
|
+
skipDisabled: true,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type RadioGroupState = {|
|
|
105
|
+
readonly selected: string | null,
|
|
106
|
+
readonly select: (value: string) => void,
|
|
107
|
+
/** The item holding the tab stop while nothing is chosen; see `useFirstItem`. */
|
|
108
|
+
readonly firstId: string | null,
|
|
109
|
+
|};
|
|
110
|
+
|
|
111
|
+
const RadioGroupContext: React.Context<RadioGroupState | null> = createContext(null);
|
|
112
|
+
|
|
113
|
+
/** Whether the surrounding item is the chosen one, for `RadioGroup.Indicator`. */
|
|
114
|
+
type RadioItemState = {| readonly checked: boolean |};
|
|
115
|
+
|
|
116
|
+
const RadioItemContext: React.Context<RadioItemState | null> = createContext(null);
|
|
117
|
+
|
|
118
|
+
hook useRadioGroup(part: string): RadioGroupState {
|
|
119
|
+
const state = useContext(RadioGroupContext);
|
|
120
|
+
if (state == null) {
|
|
121
|
+
throw new Error(`${part} must be rendered inside a RadioGroup.Root`);
|
|
122
|
+
}
|
|
123
|
+
return state;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The group, which is the control a reader is told about.
|
|
128
|
+
*
|
|
129
|
+
* `children` is `React.Node` rather than `renders* RadioGroupItem`, and the
|
|
130
|
+
* reason is worth stating rather than leaving as an omission. `ToggleGroup
|
|
131
|
+
* type="single"` is this component wearing segments: it renders a
|
|
132
|
+
* `RadioGroup.Root` and passes its own items through. A `renders*` constraint
|
|
133
|
+
* is a promise about the element a child produces, and a `ToggleGroup.Item`
|
|
134
|
+
* cannot make it — it produces a radio in one mode and a toggle button in the
|
|
135
|
+
* other — while naming both kinds here would make this module import the module
|
|
136
|
+
* that imports it. `Tabs.List` keeps the tighter promise because nothing else
|
|
137
|
+
* in this package renders a `tab`.
|
|
138
|
+
*
|
|
139
|
+
* `name` puts the answer where a form can submit it; see the hidden input
|
|
140
|
+
* below.
|
|
141
|
+
*/
|
|
142
|
+
export component RadioGroupRoot(
|
|
143
|
+
children: React.Node,
|
|
144
|
+
defaultValue?: string | null = null,
|
|
145
|
+
value?: string | null,
|
|
146
|
+
onValueChange?: (value: string) => void,
|
|
147
|
+
orientation?: Orientation = "vertical",
|
|
148
|
+
name?: string,
|
|
149
|
+
...rest: Rest
|
|
150
|
+
) {
|
|
151
|
+
// `onValueChange` promises a `string` while the group's *state* is
|
|
152
|
+
// `string | null`, and the two meet here rather than being flattened into one
|
|
153
|
+
// type that lies in one direction or the other: "nothing chosen yet" is a
|
|
154
|
+
// state a radio group starts in, and it is not an event it can ever report,
|
|
155
|
+
// because no gesture inside a radio group unchooses an answer. Widening the
|
|
156
|
+
// prop to `string | null` would also make every `(plan: string) => void` a
|
|
157
|
+
// caller already has a type error at the call site.
|
|
158
|
+
const report = useCallback(
|
|
159
|
+
(next: string | null) => {
|
|
160
|
+
if (next != null) {
|
|
161
|
+
onValueChange?.(next);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
[onValueChange],
|
|
165
|
+
);
|
|
166
|
+
const [selected, select] = useControlled<string | null>(value, defaultValue, report);
|
|
167
|
+
const rootRef = useRef<HTMLElement | null>(null);
|
|
168
|
+
// Only while nothing is chosen. Once there is an answer it holds the tab
|
|
169
|
+
// stop, and asking the document which item comes first is work with no reader.
|
|
170
|
+
const firstId = useFirstItem(rootRef, radioSet(orientation), selected == null);
|
|
171
|
+
|
|
172
|
+
const state = useMemo(() => ({ selected, select, firstId }), [selected, select, firstId]);
|
|
173
|
+
const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<RadioGroupContext.Provider value={state}>
|
|
177
|
+
<div
|
|
178
|
+
{...passed}
|
|
179
|
+
// A reader is told which axis this runs along, and it is also what says
|
|
180
|
+
// which pair of arrow keys is live.
|
|
181
|
+
aria-orientation={orientation}
|
|
182
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
183
|
+
const group: $FlowFixMe = event.currentTarget;
|
|
184
|
+
const next = moveOnKey(event, group, radioSet(orientation));
|
|
185
|
+
if (next != null) {
|
|
186
|
+
// Checking in the same key press is not a shortcut, it is the
|
|
187
|
+
// pattern: a radio group whose arrows moved focus without checking
|
|
188
|
+
// leaves a reader believing they have answered when they have not.
|
|
189
|
+
select(next.getAttribute("data-value") ?? "");
|
|
190
|
+
}
|
|
191
|
+
})}
|
|
192
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
193
|
+
rootRef.current = element;
|
|
194
|
+
})}
|
|
195
|
+
role="radiogroup"
|
|
196
|
+
>
|
|
197
|
+
{children}
|
|
198
|
+
{/*
|
|
199
|
+
A form submits `<input>` elements, and none of the parts above is one.
|
|
200
|
+
Without this the group is a control a reader can operate and a form
|
|
201
|
+
cannot read, which is the same hole `Combobox` still has.
|
|
202
|
+
|
|
203
|
+
`type="hidden"` rather than a visually hidden real radio, because the
|
|
204
|
+
buttons above already carry the whole of the accessible semantics: a
|
|
205
|
+
second set of native radios would be announced as a second set of
|
|
206
|
+
answers, and hiding them from the accessibility tree to stop that
|
|
207
|
+
leaves elements a form's own validation would then point its
|
|
208
|
+
"please choose one" at.
|
|
209
|
+
*/}
|
|
210
|
+
{name == null ? null : <input name={name} type="hidden" value={selected ?? ""} />}
|
|
211
|
+
</div>
|
|
212
|
+
</RadioGroupContext.Provider>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* One answer.
|
|
218
|
+
*
|
|
219
|
+
* A disabled item is `aria-disabled` rather than `disabled`, so it stays in the
|
|
220
|
+
* accessibility tree: a reader is told "Enterprise, radio button, dimmed, 3 of
|
|
221
|
+
* 3" and learns that the answer exists and is unavailable, where a native
|
|
222
|
+
* `disabled` leaves a gap they cannot ask about. The arrow keys step over it
|
|
223
|
+
* either way, and so does the search for the item that holds the tab stop.
|
|
224
|
+
*/
|
|
225
|
+
export component RadioGroupItem(
|
|
226
|
+
value: string,
|
|
227
|
+
children?: React.Node,
|
|
228
|
+
disabled?: boolean = false,
|
|
229
|
+
...rest: Rest
|
|
230
|
+
) {
|
|
231
|
+
const group = useRadioGroup("RadioGroup.Item");
|
|
232
|
+
const id = useId();
|
|
233
|
+
const checked = group.selected === value;
|
|
234
|
+
const item = useMemo(() => ({ checked }), [checked]);
|
|
235
|
+
const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<RadioItemContext.Provider value={item}>
|
|
239
|
+
<button
|
|
240
|
+
{...passed}
|
|
241
|
+
aria-checked={checked ? "true" : "false"}
|
|
242
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
243
|
+
// Read by the group's key handler, which finds items in the document
|
|
244
|
+
// rather than in a registry and so needs each one to carry its value.
|
|
245
|
+
data-value={value}
|
|
246
|
+
id={id}
|
|
247
|
+
onClick={composeHandlers(rest.onClick, () => {
|
|
248
|
+
if (!disabled) {
|
|
249
|
+
group.select(value);
|
|
250
|
+
}
|
|
251
|
+
})}
|
|
252
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
253
|
+
if (disabled || event.key !== " ") {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Stops `Space` scrolling the page — which is what makes a
|
|
257
|
+
// hand-written radio feel broken even when it works — and stops the
|
|
258
|
+
// browser's own click arriving afterwards to check this again.
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
group.select(value);
|
|
261
|
+
})}
|
|
262
|
+
role="radio"
|
|
263
|
+
// The roving tab stop: the chosen answer, or the first one while there
|
|
264
|
+
// is no answer, so `Tab` reaches the group in either state and leaves
|
|
265
|
+
// it in one press.
|
|
266
|
+
tabIndex={checked || (group.selected == null && group.firstId === id) ? 0 : -1}
|
|
267
|
+
type="button"
|
|
268
|
+
>
|
|
269
|
+
{children}
|
|
270
|
+
</button>
|
|
271
|
+
</RadioItemContext.Provider>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The mark inside the chosen answer, rendered only while it is chosen.
|
|
277
|
+
*
|
|
278
|
+
* `aria-hidden` because the item it sits in already says `aria-checked`: a dot
|
|
279
|
+
* that also announced itself would have a reader hear the answer's state twice,
|
|
280
|
+
* once as a state and once as a stray element. It exists so a caller can style
|
|
281
|
+
* a mark that appears and disappears without reaching for
|
|
282
|
+
* `[aria-checked="true"] > *`, and so the "only while chosen" part is not
|
|
283
|
+
* something each caller reimplements.
|
|
284
|
+
*/
|
|
285
|
+
export component RadioGroupIndicator(children?: React.Node, ...rest: Rest) {
|
|
286
|
+
const item = useContext(RadioItemContext);
|
|
287
|
+
if (item == null) {
|
|
288
|
+
throw new Error("RadioGroup.Indicator must be rendered inside a RadioGroup.Item");
|
|
289
|
+
}
|
|
290
|
+
if (!item.checked) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
return (
|
|
294
|
+
<span {...rest} aria-hidden="true">
|
|
295
|
+
{children}
|
|
296
|
+
</span>
|
|
297
|
+
);
|
|
298
|
+
}
|
package/resizable.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Two panes and the handle between them — a slider wearing a different role.
|
|
4
|
+
//
|
|
5
|
+
// The APG calls this a **window splitter**, and it defines it as a separator
|
|
6
|
+
// that behaves like a slider: `aria-valuenow`, `aria-valuemin` and
|
|
7
|
+
// `aria-valuemax` say how much of the space the primary pane has, and the
|
|
8
|
+
// arrow keys change it. That is why it is next to `slider.js` and shares
|
|
9
|
+
// `internal/range.js` with it rather than living with the layout components.
|
|
10
|
+
//
|
|
11
|
+
// Almost every resizable panel on the web is pointer-only, which is a WCAG
|
|
12
|
+
// 2.1.1 failure — no keyboard operation at all — and a 2.5.7 one on top of it.
|
|
13
|
+
// If uf ships one, the keyboard is the feature, so the keyboard is what this
|
|
14
|
+
// module is: there is no drag here yet, and there is a complete key map.
|
|
15
|
+
//
|
|
16
|
+
// # The two separators in this package are not the same thing
|
|
17
|
+
//
|
|
18
|
+
// A reader who greps for `separator` finds this and `menu.js`, and they are
|
|
19
|
+
// unrelated:
|
|
20
|
+
//
|
|
21
|
+
// * `Menu.Separator` is a rule between groups of items. It is not focusable,
|
|
22
|
+
// it has no value, and it exists so a reader moving through a menu is told
|
|
23
|
+
// the group changed.
|
|
24
|
+
// * `Resizable.Handle` is a *window splitter*. It is focusable, it carries a
|
|
25
|
+
// value, and operating it changes the layout.
|
|
26
|
+
//
|
|
27
|
+
// ARIA gives both the same role because both are separators; only the second
|
|
28
|
+
// one is a control. The difference is `tabindex` and `aria-valuenow`, which is
|
|
29
|
+
// also how a screen reader tells them apart.
|
|
30
|
+
//
|
|
31
|
+
// # `aria-orientation` is the separator's, not the layout's
|
|
32
|
+
//
|
|
33
|
+
// Two panes side by side are divided by a **vertical** line, so a
|
|
34
|
+
// `PanelGroup` whose `orientation` is `"horizontal"` renders a handle whose
|
|
35
|
+
// `aria-orientation` is `"vertical"`. That inversion is easy to get backwards
|
|
36
|
+
// and worth stating: `aria-orientation` on a separator describes the separator,
|
|
37
|
+
// and ARIA's default for the role is `horizontal` — so a vertical splitter
|
|
38
|
+
// that says nothing is announced as a horizontal rule.
|
|
39
|
+
//
|
|
40
|
+
// The APG's own pattern page does not mention `aria-orientation` at all, which
|
|
41
|
+
// is why implementations differ. This follows the role's definition rather
|
|
42
|
+
// than the pattern's silence.
|
|
43
|
+
//
|
|
44
|
+
// # Two panes, which is the pattern and not a limitation
|
|
45
|
+
//
|
|
46
|
+
// The window splitter is defined between two panes: a primary one whose size
|
|
47
|
+
// is the value, and the rest. A group of five panels is a layout-constraint
|
|
48
|
+
// problem — every handle's range depends on every other panel's minimum — and
|
|
49
|
+
// it is a different component with a different core, not a bigger version of
|
|
50
|
+
// this one. What is here is the pattern, complete.
|
|
51
|
+
//
|
|
52
|
+
// # Drawing it
|
|
53
|
+
//
|
|
54
|
+
// Each pane carries its share as `--uf-resizable-size`, a percentage, and the
|
|
55
|
+
// caller's stylesheet decides whether that is a width, a height, a `flex-basis`
|
|
56
|
+
// or nothing at all. There is no drag: the pointer half is tracked work, and a
|
|
57
|
+
// keyboard-only splitter is a working splitter, where a pointer-only one is
|
|
58
|
+
// not.
|
|
59
|
+
|
|
60
|
+
"use client";
|
|
61
|
+
|
|
62
|
+
import * as React from "@uniflowed/react";
|
|
63
|
+
import {
|
|
64
|
+
createContext,
|
|
65
|
+
useContext,
|
|
66
|
+
useEffect,
|
|
67
|
+
useId,
|
|
68
|
+
useMemo,
|
|
69
|
+
useRef,
|
|
70
|
+
useState,
|
|
71
|
+
} from "@uniflowed/react";
|
|
72
|
+
|
|
73
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
74
|
+
import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
|
|
75
|
+
import type { Orientation } from "./internal/roving-focus.js";
|
|
76
|
+
import { clamp, isReversed } from "./internal/range.js";
|
|
77
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
78
|
+
|
|
79
|
+
type ResizableState = {|
|
|
80
|
+
readonly base: string,
|
|
81
|
+
/** The primary pane's share of the group, as a percentage. */
|
|
82
|
+
readonly value: number,
|
|
83
|
+
readonly setValue: (value: number) => void,
|
|
84
|
+
readonly min: number,
|
|
85
|
+
readonly max: number,
|
|
86
|
+
readonly step: number,
|
|
87
|
+
/** How the panes are laid out; the handle's own orientation is the other one. */
|
|
88
|
+
readonly orientation: Orientation,
|
|
89
|
+
readonly disabled: boolean,
|
|
90
|
+
readonly hasPrimary: boolean,
|
|
91
|
+
readonly registerPrimary: (present: boolean) => void,
|
|
92
|
+
|};
|
|
93
|
+
|
|
94
|
+
const ResizableContext: React.Context<ResizableState | null> = createContext(null);
|
|
95
|
+
|
|
96
|
+
hook useResizable(part: string): ResizableState {
|
|
97
|
+
const state = useContext(ResizableContext);
|
|
98
|
+
if (state == null) {
|
|
99
|
+
throw new Error(`${part} must be rendered inside a Resizable.PanelGroup`);
|
|
100
|
+
}
|
|
101
|
+
return state;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The two panes and their handle.
|
|
106
|
+
*
|
|
107
|
+
* `value` is the primary pane's percentage of the group, which is what the
|
|
108
|
+
* handle announces — the APG's "a decimal value representing the current
|
|
109
|
+
* position of the separator", where 0 is collapsed and 100 is as large as it
|
|
110
|
+
* is allowed to be.
|
|
111
|
+
*
|
|
112
|
+
* `min` is the size the primary pane collapses to. It is 0 by default, so
|
|
113
|
+
* `Enter` collapses the pane entirely; a group whose primary pane should never
|
|
114
|
+
* disappear gives it a floor.
|
|
115
|
+
*/
|
|
116
|
+
export component ResizablePanelGroup(
|
|
117
|
+
children: React.Node,
|
|
118
|
+
value?: number,
|
|
119
|
+
defaultValue?: number = 50,
|
|
120
|
+
onValueChange?: (value: number) => void,
|
|
121
|
+
min?: number = 0,
|
|
122
|
+
max?: number = 100,
|
|
123
|
+
step?: number = 10,
|
|
124
|
+
orientation?: Orientation = "horizontal",
|
|
125
|
+
disabled?: boolean = false,
|
|
126
|
+
...rest: Rest
|
|
127
|
+
) {
|
|
128
|
+
const base = useId();
|
|
129
|
+
const [share, setShare] = useControlled(value, defaultValue, onValueChange);
|
|
130
|
+
const [hasPrimary, setHasPrimary] = useState(false);
|
|
131
|
+
|
|
132
|
+
const state = useMemo(
|
|
133
|
+
() => ({
|
|
134
|
+
base,
|
|
135
|
+
value: clamp(share, min, max),
|
|
136
|
+
setValue: setShare,
|
|
137
|
+
min,
|
|
138
|
+
max,
|
|
139
|
+
step,
|
|
140
|
+
orientation,
|
|
141
|
+
disabled,
|
|
142
|
+
hasPrimary,
|
|
143
|
+
registerPrimary: setHasPrimary,
|
|
144
|
+
}),
|
|
145
|
+
[base, share, setShare, min, max, step, orientation, disabled, hasPrimary],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<ResizableContext.Provider value={state}>
|
|
150
|
+
<div {...rest}>{children}</div>
|
|
151
|
+
</ResizableContext.Provider>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One pane.
|
|
157
|
+
*
|
|
158
|
+
* `primary` marks the one whose size is the value, and the one the handle
|
|
159
|
+
* names with `aria-controls`. Exactly one pane in a group is primary; the
|
|
160
|
+
* other takes what is left. It is a prop rather than "the first one", because
|
|
161
|
+
* the first one in the document is not the first one to mount the moment a
|
|
162
|
+
* caller renders a pane conditionally, and a handle pointing at the wrong pane
|
|
163
|
+
* is a handle that announces someone else's size.
|
|
164
|
+
*/
|
|
165
|
+
export component ResizablePanel(children: React.Node, primary?: boolean = false, ...rest: Rest) {
|
|
166
|
+
const group = useResizable("Resizable.Panel");
|
|
167
|
+
const passed = withoutComposed(rest, ["style"]);
|
|
168
|
+
const register = group.registerPrimary;
|
|
169
|
+
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!primary) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
register(true);
|
|
175
|
+
return () => register(false);
|
|
176
|
+
}, [primary, register]);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
{...passed}
|
|
181
|
+
id={primary ? `${group.base}-primary` : undefined}
|
|
182
|
+
style={{
|
|
183
|
+
...(rest.style as $FlowFixMe),
|
|
184
|
+
"--uf-resizable-size": `${String(primary ? group.value : 100 - group.value)}%`,
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{children}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The splitter: a separator that behaves like a slider.
|
|
194
|
+
*
|
|
195
|
+
* `label` is its accessible name and has a default, because a splitter is a
|
|
196
|
+
* bare bar with no text in it every time — and a focusable separator with no
|
|
197
|
+
* name is announced as "separator", which tells a reader there is a control
|
|
198
|
+
* here and nothing about what it does.
|
|
199
|
+
*/
|
|
200
|
+
export component ResizableHandle(label?: string = "Resize", ...rest: Rest) {
|
|
201
|
+
const group = useResizable("Resizable.Handle");
|
|
202
|
+
const passed = withoutComposed(rest, ["onKeyDown"]);
|
|
203
|
+
// Where the pane was before `Enter` collapsed it. A ref because nothing
|
|
204
|
+
// renders it: it is a fact about the last keystroke, not about the layout.
|
|
205
|
+
const restoreTo = useRef<number | null>(null);
|
|
206
|
+
|
|
207
|
+
const moveBy = (amount: number) => {
|
|
208
|
+
restoreTo.current = null;
|
|
209
|
+
group.setValue(clamp(group.value + amount, group.min, group.max));
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div
|
|
214
|
+
{...passed}
|
|
215
|
+
aria-label={label}
|
|
216
|
+
// Only while a primary pane is in the document, for the reason every
|
|
217
|
+
// part of this package repeats: an `aria-controls` naming an id nothing
|
|
218
|
+
// has is worse than saying nothing at all.
|
|
219
|
+
aria-controls={group.hasPrimary ? `${group.base}-primary` : undefined}
|
|
220
|
+
aria-disabled={group.disabled ? "true" : undefined}
|
|
221
|
+
// The separator's own orientation, which is the other axis from the one
|
|
222
|
+
// the panes are laid out along. See the module header.
|
|
223
|
+
aria-orientation={group.orientation === "horizontal" ? "vertical" : "horizontal"}
|
|
224
|
+
aria-valuemax={group.max}
|
|
225
|
+
aria-valuemin={group.min}
|
|
226
|
+
aria-valuenow={group.value}
|
|
227
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
|
|
228
|
+
if (group.disabled) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (event.key === "Enter") {
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
// Collapse, or put it back where it was. The APG gives `Enter` both
|
|
234
|
+
// jobs, and a collapse with no way back is a pane a keyboard reader
|
|
235
|
+
// has thrown away.
|
|
236
|
+
const previous = restoreTo.current;
|
|
237
|
+
if (previous != null) {
|
|
238
|
+
restoreTo.current = null;
|
|
239
|
+
group.setValue(previous);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (group.value === group.min) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
restoreTo.current = group.value;
|
|
246
|
+
group.setValue(group.min);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (event.key === "Home" || event.key === "End") {
|
|
251
|
+
event.preventDefault();
|
|
252
|
+
restoreTo.current = null;
|
|
253
|
+
group.setValue(event.key === "Home" ? group.min : group.max);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const amount = stepFor(
|
|
258
|
+
event.key,
|
|
259
|
+
group.step,
|
|
260
|
+
group.orientation,
|
|
261
|
+
isReversed(event.currentTarget, group.orientation),
|
|
262
|
+
);
|
|
263
|
+
if (amount != null) {
|
|
264
|
+
event.preventDefault();
|
|
265
|
+
moveBy(amount);
|
|
266
|
+
}
|
|
267
|
+
})}
|
|
268
|
+
role="separator"
|
|
269
|
+
// A separator that is not in the tab sequence is the WCAG 2.1.1 failure
|
|
270
|
+
// this module exists to avoid.
|
|
271
|
+
tabIndex={group.disabled ? -1 : 0}
|
|
272
|
+
/>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* How far a key moves the splitter, or nothing when the key is not ours.
|
|
278
|
+
*
|
|
279
|
+
* Only the keys along the axis the panes are laid out on: `ArrowUp` in a group
|
|
280
|
+
* of side-by-side panes is the page's, and swallowing it takes a scroll key
|
|
281
|
+
* away from every reader who uses one.
|
|
282
|
+
*
|
|
283
|
+
* The primary pane is the one before the handle, so moving the handle towards
|
|
284
|
+
* the end of the axis makes it larger — and in a right-to-left page the end of
|
|
285
|
+
* a horizontal axis is on the left, so the arrows mirror.
|
|
286
|
+
*/
|
|
287
|
+
function stepFor(
|
|
288
|
+
key: string,
|
|
289
|
+
step: number,
|
|
290
|
+
orientation: Orientation,
|
|
291
|
+
reversed: boolean,
|
|
292
|
+
): number | null {
|
|
293
|
+
const move = step <= 0 ? 1 : step;
|
|
294
|
+
if (orientation === "vertical") {
|
|
295
|
+
return match (key) {
|
|
296
|
+
"ArrowDown" => move,
|
|
297
|
+
"ArrowUp" => -move,
|
|
298
|
+
_ => null,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const forward = reversed ? -1 : 1;
|
|
302
|
+
return match (key) {
|
|
303
|
+
"ArrowRight" => move * forward,
|
|
304
|
+
"ArrowLeft" => -move * forward,
|
|
305
|
+
_ => null,
|
|
306
|
+
};
|
|
307
|
+
}
|