@uniflowed/ui 0.0.0-alpha.8 → 0.1.0
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 +84 -57
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +560 -0
- package/carousel.js +410 -0
- package/checkbox.js +215 -31
- package/collapsible.js +72 -48
- package/color-picker.js +172 -0
- package/combobox.js +216 -39
- package/context-menu.js +215 -0
- package/date-field.js +9 -0
- package/date-picker.js +357 -0
- package/date-range-picker.js +120 -0
- package/dialog.js +235 -198
- package/drag-drop.js +125 -0
- package/drawer.js +504 -0
- package/field.js +260 -43
- package/grid-list.js +8 -0
- package/hover-card.js +334 -0
- package/i18n-provider.js +89 -0
- package/index.js +1254 -32
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +565 -0
- package/internal/collection.js +395 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/focus.js +64 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +117 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +316 -0
- package/list-box.js +13 -0
- package/menu.js +553 -361
- package/menubar.js +295 -0
- package/number-field.js +263 -0
- package/package.json +8 -25
- package/pagination.js +34 -22
- package/popover.js +367 -0
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +78 -0
- package/resizable.js +155 -9
- package/scroll-area.js +283 -0
- package/select.js +83 -37
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +95 -89
- package/switch.js +42 -34
- package/table.js +112 -71
- package/tabs.js +100 -91
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/toast.js +36 -66
- package/toggle-group.js +53 -49
- package/toggle.js +41 -27
- package/tooltip.js +404 -0
- package/tree.js +8 -0
package/radio-group.js
CHANGED
|
@@ -76,8 +76,13 @@
|
|
|
76
76
|
import * as React from "@uniflowed/react";
|
|
77
77
|
import { createContext, useCallback, useContext, useId, useMemo, useRef } from "@uniflowed/react";
|
|
78
78
|
|
|
79
|
-
import type { Rest } from "./internal/merge-props.js";
|
|
80
|
-
import {
|
|
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";
|
|
81
86
|
import { moveOnKey, useFirstItem } from "./internal/roving-focus.js";
|
|
82
87
|
import type { Orientation, RovingSet } from "./internal/roving-focus.js";
|
|
83
88
|
import { useControlled } from "./internal/controlled-state.js";
|
|
@@ -146,6 +151,7 @@ export component RadioGroupRoot(
|
|
|
146
151
|
onValueChange?: (value: string) => void,
|
|
147
152
|
orientation?: Orientation = "vertical",
|
|
148
153
|
name?: string,
|
|
154
|
+
render?: RenderProp,
|
|
149
155
|
...rest: Rest
|
|
150
156
|
) {
|
|
151
157
|
// `onValueChange` promises a `string` while the group's *state* is
|
|
@@ -171,44 +177,48 @@ export component RadioGroupRoot(
|
|
|
171
177
|
|
|
172
178
|
const state = useMemo(() => ({ selected, select, firstId }), [selected, select, firstId]);
|
|
173
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
|
+
});
|
|
174
218
|
|
|
175
219
|
return (
|
|
176
220
|
<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>
|
|
221
|
+
{render == null ? <div {...props} /> : render(props)}
|
|
212
222
|
</RadioGroupContext.Provider>
|
|
213
223
|
);
|
|
214
224
|
}
|
|
@@ -226,6 +236,7 @@ export component RadioGroupItem(
|
|
|
226
236
|
value: string,
|
|
227
237
|
children?: React.Node,
|
|
228
238
|
disabled?: boolean = false,
|
|
239
|
+
render?: RenderProp,
|
|
229
240
|
...rest: Rest
|
|
230
241
|
) {
|
|
231
242
|
const group = useRadioGroup("RadioGroup.Item");
|
|
@@ -233,41 +244,39 @@ export component RadioGroupItem(
|
|
|
233
244
|
const checked = group.selected === value;
|
|
234
245
|
const item = useMemo(() => ({ checked }), [checked]);
|
|
235
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
|
+
});
|
|
236
276
|
|
|
237
277
|
return (
|
|
238
278
|
<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>
|
|
279
|
+
{render == null ? <button {...props} type="button" /> : render(props)}
|
|
271
280
|
</RadioItemContext.Provider>
|
|
272
281
|
);
|
|
273
282
|
}
|
|
@@ -282,7 +291,7 @@ export component RadioGroupItem(
|
|
|
282
291
|
* `[aria-checked="true"] > *`, and so the "only while chosen" part is not
|
|
283
292
|
* something each caller reimplements.
|
|
284
293
|
*/
|
|
285
|
-
export component RadioGroupIndicator(children?: React.Node, ...rest: Rest) {
|
|
294
|
+
export component RadioGroupIndicator(children?: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
286
295
|
const item = useContext(RadioItemContext);
|
|
287
296
|
if (item == null) {
|
|
288
297
|
throw new Error("RadioGroup.Indicator must be rendered inside a RadioGroup.Item");
|
|
@@ -290,9 +299,6 @@ export component RadioGroupIndicator(children?: React.Node, ...rest: Rest) {
|
|
|
290
299
|
if (!item.checked) {
|
|
291
300
|
return null;
|
|
292
301
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
{children}
|
|
296
|
-
</span>
|
|
297
|
-
);
|
|
302
|
+
const props = withProps(rest, { "aria-hidden": "true", children });
|
|
303
|
+
return render == null ? <span {...props} /> : render(props);
|
|
298
304
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
"use client";
|
|
3
|
+
import * as React from "@uniflowed/react";
|
|
4
|
+
import { useState } from "@uniflowed/react";
|
|
5
|
+
import type { PlainDate } from "@uniflowed/core/temporal";
|
|
6
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
7
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
8
|
+
import type { DateRange } from "./internal/date-range.js";
|
|
9
|
+
import { validateRange, unavailableInRange } from "./internal/date-range.js";
|
|
10
|
+
import { CalendarRoot, CalendarMonth } from "./calendar.js";
|
|
11
|
+
export type { DateRange } from "./internal/date-range.js";
|
|
12
|
+
|
|
13
|
+
export component RangeCalendarRoot(
|
|
14
|
+
children?: React.Node = <CalendarMonth />,
|
|
15
|
+
value?: DateRange | null,
|
|
16
|
+
defaultValue?: DateRange | null = null,
|
|
17
|
+
onValueChange?: (value: DateRange | null) => void,
|
|
18
|
+
minValue?: string,
|
|
19
|
+
maxValue?: string,
|
|
20
|
+
isDateDisabled?: (date: PlainDate) => boolean,
|
|
21
|
+
defaultFocused?: string,
|
|
22
|
+
today?: string,
|
|
23
|
+
locale?: string,
|
|
24
|
+
focusedDayRef?: { current: HTMLElement | null },
|
|
25
|
+
...rest: Rest
|
|
26
|
+
) {
|
|
27
|
+
const [range, setRange] = useControlled(value, defaultValue, onValueChange);
|
|
28
|
+
const [anchor, setAnchor] = useState<string | null>(null);
|
|
29
|
+
const [announcement, announce] = useState("");
|
|
30
|
+
validateRange(range);
|
|
31
|
+
const disabled = (date: PlainDate) =>
|
|
32
|
+
(minValue != null && date.toString() < minValue) ||
|
|
33
|
+
(maxValue != null && date.toString() > maxValue) ||
|
|
34
|
+
isDateDisabled?.(date) === true;
|
|
35
|
+
const choose = (date: PlainDate) => {
|
|
36
|
+
const iso = date.toString();
|
|
37
|
+
if (disabled(date)) return;
|
|
38
|
+
if (anchor == null) {
|
|
39
|
+
setAnchor(iso);
|
|
40
|
+
announce(`Start ${iso}. Choose an end date.`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const start = anchor < iso ? anchor : iso,
|
|
44
|
+
end = anchor < iso ? iso : anchor;
|
|
45
|
+
if (unavailableInRange(start, end, isDateDisabled)) {
|
|
46
|
+
announce("The range contains an unavailable date");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
setRange({ start, end });
|
|
50
|
+
setAnchor(null);
|
|
51
|
+
announce(`Selected ${start} to ${end}`);
|
|
52
|
+
};
|
|
53
|
+
const selected = (date: PlainDate) => {
|
|
54
|
+
const iso = date.toString();
|
|
55
|
+
return anchor != null
|
|
56
|
+
? iso === anchor
|
|
57
|
+
: range != null && iso >= range.start && iso <= range.end;
|
|
58
|
+
};
|
|
59
|
+
return (
|
|
60
|
+
<div {...rest}>
|
|
61
|
+
<CalendarRoot
|
|
62
|
+
value={anchor ?? range?.start ?? null}
|
|
63
|
+
defaultFocused={defaultFocused}
|
|
64
|
+
today={today}
|
|
65
|
+
locale={locale}
|
|
66
|
+
focusedDayRef={focusedDayRef}
|
|
67
|
+
isDateDisabled={disabled}
|
|
68
|
+
isDateSelected={selected}
|
|
69
|
+
onValueChange={choose}
|
|
70
|
+
>
|
|
71
|
+
{children}
|
|
72
|
+
</CalendarRoot>
|
|
73
|
+
<span role="status" aria-live="polite">
|
|
74
|
+
{announcement}
|
|
75
|
+
</span>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
package/resizable.js
CHANGED
|
@@ -10,8 +10,42 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Almost every resizable panel on the web is pointer-only, which is a WCAG
|
|
12
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
|
|
14
|
-
//
|
|
13
|
+
// If uf ships one the keyboard is the feature, so the keyboard was written
|
|
14
|
+
// first and shipped on its own: a keyboard-only splitter is a working
|
|
15
|
+
// splitter, where a pointer-only one is not.
|
|
16
|
+
//
|
|
17
|
+
// It was not the finished control. A bar between two panes is a bar that
|
|
18
|
+
// approximately everybody takes hold of first, and this one could not be
|
|
19
|
+
// moved that way at all. Both halves are here now, and the key map is exactly
|
|
20
|
+
// what it was — a drag that had quietly replaced it would be the first failure
|
|
21
|
+
// in the other direction.
|
|
22
|
+
//
|
|
23
|
+
// # The drag, and the step it does not use
|
|
24
|
+
//
|
|
25
|
+
// It is `slider.js`'s `Slider.Track` arithmetic measured against the *group's*
|
|
26
|
+
// box rather than a track's. `pointerdown` captures the pointer, so a drag
|
|
27
|
+
// that wanders off a bar four pixels wide — which every drag does — keeps
|
|
28
|
+
// arriving at the handle instead of being lost to whatever it wandered over;
|
|
29
|
+
// `pointermove` turns the position into a percentage from
|
|
30
|
+
// `getBoundingClientRect`; and the percentage goes through `internal/range.js`
|
|
31
|
+
// like every other value here, so a drag cannot leave the pane anywhere an
|
|
32
|
+
// arrow key could not put it, and `aria-valuenow` is true about it while it
|
|
33
|
+
// moves.
|
|
34
|
+
//
|
|
35
|
+
// Pressing the handle does not move it. A press on a *track* means "put the
|
|
36
|
+
// value here", which is what `Slider.Track` does with one and why it is half
|
|
37
|
+
// of WCAG 2.5.7 there; a press on a handle means "take hold of this". A
|
|
38
|
+
// splitter that also jumped by the distance between the pointer and its own
|
|
39
|
+
// centre would move a little every time it was clicked, which is the one thing
|
|
40
|
+
// a person who clicked it did not ask for.
|
|
41
|
+
//
|
|
42
|
+
// `step` stays the keyboard's, and the pointer has one of its own. `step`
|
|
43
|
+
// defaults to 10 because ten presses of an arrow key ought to cross the pane,
|
|
44
|
+
// and 10 is an absurd granularity for a bar being dragged under a pointer that
|
|
45
|
+
// moves smoothly. The pointer's is one percent, and it is a constant rather
|
|
46
|
+
// than a second prop because the value is already a percentage of the group:
|
|
47
|
+
// one is the smallest move that means anything, and a splitter announcing
|
|
48
|
+
// 47.382 would be reading out its arithmetic rather than its size.
|
|
15
49
|
//
|
|
16
50
|
// # The two separators in this package are not the same thing
|
|
17
51
|
//
|
|
@@ -53,9 +87,9 @@
|
|
|
53
87
|
//
|
|
54
88
|
// Each pane carries its share as `--uf-resizable-size`, a percentage, and the
|
|
55
89
|
// caller's stylesheet decides whether that is a width, a height, a `flex-basis`
|
|
56
|
-
// or nothing at all.
|
|
57
|
-
//
|
|
58
|
-
//
|
|
90
|
+
// or nothing at all. The group is measured rather than drawn — the drag reads
|
|
91
|
+
// its box and never writes to it — so a caller whose panes are flex children,
|
|
92
|
+
// grid tracks or absolutely positioned gets the same splitter.
|
|
59
93
|
|
|
60
94
|
"use client";
|
|
61
95
|
|
|
@@ -71,11 +105,21 @@ import {
|
|
|
71
105
|
} from "@uniflowed/react";
|
|
72
106
|
|
|
73
107
|
import type { Rest } from "./internal/merge-props.js";
|
|
74
|
-
import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
|
|
108
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
75
109
|
import type { Orientation } from "./internal/roving-focus.js";
|
|
76
|
-
import { clamp, isReversed } from "./internal/range.js";
|
|
110
|
+
import { clamp, isReversed, snap } from "./internal/range.js";
|
|
77
111
|
import { useControlled } from "./internal/controlled-state.js";
|
|
78
112
|
|
|
113
|
+
/**
|
|
114
|
+
* How finely a drag may move the splitter, in percentage points.
|
|
115
|
+
*
|
|
116
|
+
* Not `step`, which is the keyboard's and defaults to ten; the module header
|
|
117
|
+
* says why the two cannot be the same number. One is a constant rather than a
|
|
118
|
+
* prop because the value is already a percentage of the group, so one is the
|
|
119
|
+
* smallest move that means anything.
|
|
120
|
+
*/
|
|
121
|
+
const POINTER_STEP = 1;
|
|
122
|
+
|
|
79
123
|
type ResizableState = {|
|
|
80
124
|
readonly base: string,
|
|
81
125
|
/** The primary pane's share of the group, as a percentage. */
|
|
@@ -89,6 +133,14 @@ type ResizableState = {|
|
|
|
89
133
|
readonly disabled: boolean,
|
|
90
134
|
readonly hasPrimary: boolean,
|
|
91
135
|
readonly registerPrimary: (present: boolean) => void,
|
|
136
|
+
/**
|
|
137
|
+
* The element a drag is measured against.
|
|
138
|
+
*
|
|
139
|
+
* The group rather than the handle, because the value is the primary pane's
|
|
140
|
+
* share *of the group* — the handle is a few pixels wide and has no idea how
|
|
141
|
+
* much space there is to divide.
|
|
142
|
+
*/
|
|
143
|
+
readonly groupRef: { current: HTMLElement | null },
|
|
92
144
|
|};
|
|
93
145
|
|
|
94
146
|
const ResizableContext: React.Context<ResizableState | null> = createContext(null);
|
|
@@ -128,6 +180,7 @@ export component ResizablePanelGroup(
|
|
|
128
180
|
const base = useId();
|
|
129
181
|
const [share, setShare] = useControlled(value, defaultValue, onValueChange);
|
|
130
182
|
const [hasPrimary, setHasPrimary] = useState(false);
|
|
183
|
+
const groupRef = useRef<HTMLElement | null>(null);
|
|
131
184
|
|
|
132
185
|
const state = useMemo(
|
|
133
186
|
() => ({
|
|
@@ -141,13 +194,23 @@ export component ResizablePanelGroup(
|
|
|
141
194
|
disabled,
|
|
142
195
|
hasPrimary,
|
|
143
196
|
registerPrimary: setHasPrimary,
|
|
197
|
+
groupRef,
|
|
144
198
|
}),
|
|
145
199
|
[base, share, setShare, min, max, step, orientation, disabled, hasPrimary],
|
|
146
200
|
);
|
|
147
201
|
|
|
202
|
+
const passed = withoutComposed(rest, ["ref"]);
|
|
203
|
+
|
|
148
204
|
return (
|
|
149
205
|
<ResizableContext.Provider value={state}>
|
|
150
|
-
<div
|
|
206
|
+
<div
|
|
207
|
+
{...passed}
|
|
208
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
209
|
+
groupRef.current = element;
|
|
210
|
+
})}
|
|
211
|
+
>
|
|
212
|
+
{children}
|
|
213
|
+
</div>
|
|
151
214
|
</ResizableContext.Provider>
|
|
152
215
|
);
|
|
153
216
|
}
|
|
@@ -199,16 +262,61 @@ export component ResizablePanel(children: React.Node, primary?: boolean = false,
|
|
|
199
262
|
*/
|
|
200
263
|
export component ResizableHandle(label?: string = "Resize", ...rest: Rest) {
|
|
201
264
|
const group = useResizable("Resizable.Handle");
|
|
202
|
-
const passed = withoutComposed(rest, [
|
|
265
|
+
const passed = withoutComposed(rest, [
|
|
266
|
+
"onKeyDown",
|
|
267
|
+
"onPointerCancel",
|
|
268
|
+
"onPointerDown",
|
|
269
|
+
"onPointerMove",
|
|
270
|
+
"onPointerUp",
|
|
271
|
+
]);
|
|
203
272
|
// Where the pane was before `Enter` collapsed it. A ref because nothing
|
|
204
273
|
// renders it: it is a fact about the last keystroke, not about the layout.
|
|
205
274
|
const restoreTo = useRef<number | null>(null);
|
|
275
|
+
// Whether the pointer is down on this handle. Also a ref, and for the same
|
|
276
|
+
// reason: it changes between renders and no render depends on it.
|
|
277
|
+
const dragging = useRef(false);
|
|
206
278
|
|
|
207
279
|
const moveBy = (amount: number) => {
|
|
208
280
|
restoreTo.current = null;
|
|
209
281
|
group.setValue(clamp(group.value + amount, group.min, group.max));
|
|
210
282
|
};
|
|
211
283
|
|
|
284
|
+
/** The primary pane's share at the pointer, or null with no box to read. */
|
|
285
|
+
const shareAt = (event: $FlowFixMe): number | null => {
|
|
286
|
+
const element = group.groupRef.current;
|
|
287
|
+
if (element == null) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
const box = element.getBoundingClientRect();
|
|
291
|
+
const vertical = group.orientation === "vertical";
|
|
292
|
+
const size = vertical ? box.height : box.width;
|
|
293
|
+
if (size <= 0) {
|
|
294
|
+
// A group with no box has no percentages in it, and dividing by its
|
|
295
|
+
// width would put `Infinity` into `aria-valuenow`.
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
// From the top for stacked panes, where `Slider.Track` reads from the
|
|
299
|
+
// bottom: a slider's minimum is at the bottom of its track, and a group's
|
|
300
|
+
// primary pane is the one *before* the handle, which is the top one.
|
|
301
|
+
const along = vertical ? event.clientY - box.top : event.clientX - box.left;
|
|
302
|
+
const part = clamp(along / size, 0, 1);
|
|
303
|
+
// In a right-to-left page the pane before the handle is the one on the
|
|
304
|
+
// right, so the reading runs the other way. `isReversed` mirrors nothing on
|
|
305
|
+
// a stacked group, because writing direction does not flip the vertical
|
|
306
|
+
// axis.
|
|
307
|
+
const forward = isReversed(element, group.orientation) ? 1 - part : part;
|
|
308
|
+
// The value *is* the percentage, so the position becomes one directly
|
|
309
|
+
// rather than being mapped across `min`–`max` the way a slider's is: those
|
|
310
|
+
// two are bounds on how far the pane may be dragged, not the ends of a
|
|
311
|
+
// scale. Mapping them would put the handle somewhere the pointer is not.
|
|
312
|
+
return snap(forward * 100, group.min, group.max, POINTER_STEP);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const endDrag = (event: $FlowFixMe) => {
|
|
316
|
+
dragging.current = false;
|
|
317
|
+
event.currentTarget?.releasePointerCapture?.(event.pointerId);
|
|
318
|
+
};
|
|
319
|
+
|
|
212
320
|
return (
|
|
213
321
|
<div
|
|
214
322
|
{...passed}
|
|
@@ -224,6 +332,8 @@ export component ResizableHandle(label?: string = "Resize", ...rest: Rest) {
|
|
|
224
332
|
aria-valuemax={group.max}
|
|
225
333
|
aria-valuemin={group.min}
|
|
226
334
|
aria-valuenow={group.value}
|
|
335
|
+
// Key and pointer handlers keep gesture state in refs between events.
|
|
336
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
227
337
|
onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
|
|
228
338
|
if (group.disabled) {
|
|
229
339
|
return;
|
|
@@ -265,6 +375,42 @@ export component ResizableHandle(label?: string = "Resize", ...rest: Rest) {
|
|
|
265
375
|
moveBy(amount);
|
|
266
376
|
}
|
|
267
377
|
})}
|
|
378
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
379
|
+
onPointerCancel={composeHandlers(rest.onPointerCancel, endDrag)}
|
|
380
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
381
|
+
onPointerDown={composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
|
|
382
|
+
if (group.disabled) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// Otherwise the press selects the text in the panes either side on the
|
|
386
|
+
// way past, so a drag paints half the page blue.
|
|
387
|
+
event.preventDefault();
|
|
388
|
+
// Which also means the browser will not focus this element, and a
|
|
389
|
+
// reader who has just dragged the splitter is the reader most likely to
|
|
390
|
+
// want an arrow key next.
|
|
391
|
+
event.currentTarget?.focus?.();
|
|
392
|
+
event.currentTarget?.setPointerCapture?.(event.pointerId);
|
|
393
|
+
dragging.current = true;
|
|
394
|
+
// A drag is a move, so the pane `Enter` would put back is no longer
|
|
395
|
+
// where it was. Leaving it would make the next `Enter` restore a size
|
|
396
|
+
// from before the drag.
|
|
397
|
+
restoreTo.current = null;
|
|
398
|
+
// Deliberately no value change: taking hold of the handle is not asking
|
|
399
|
+
// it to move. The module header says what a press does on a track
|
|
400
|
+
// instead, and why the two are not the same gesture.
|
|
401
|
+
})}
|
|
402
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
403
|
+
onPointerMove={composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
|
|
404
|
+
if (!dragging.current) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const share = shareAt(event);
|
|
408
|
+
if (share != null) {
|
|
409
|
+
group.setValue(share);
|
|
410
|
+
}
|
|
411
|
+
})}
|
|
412
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
413
|
+
onPointerUp={composeHandlers(rest.onPointerUp, endDrag)}
|
|
268
414
|
role="separator"
|
|
269
415
|
// A separator that is not in the tab sequence is the WCAG 2.1.1 failure
|
|
270
416
|
// this module exists to avoid.
|