@uniflowed/ui 0.0.0-alpha.9 → 0.2.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 +587 -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 +243 -178
- 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 +52 -52
- package/i18n-provider.js +89 -0
- package/index.js +1177 -31
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +71 -6
- package/internal/collection.js +562 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +85 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +317 -0
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -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 -28
- package/pagination.js +34 -22
- package/popover.js +116 -75
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +79 -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 +100 -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 +48 -55
- package/tree.js +8 -0
- package/visually-hidden.js +259 -0
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
import { visuallyHiddenStyle } from "./internal/visually-hidden-style.js";
|
|
12
|
+
export type { DateRange } from "./internal/date-range.js";
|
|
13
|
+
|
|
14
|
+
export component RangeCalendarRoot(
|
|
15
|
+
children?: React.Node = <CalendarMonth />,
|
|
16
|
+
value?: DateRange | null,
|
|
17
|
+
defaultValue?: DateRange | null = null,
|
|
18
|
+
onValueChange?: (value: DateRange | null) => void,
|
|
19
|
+
minValue?: string,
|
|
20
|
+
maxValue?: string,
|
|
21
|
+
isDateDisabled?: (date: PlainDate) => boolean,
|
|
22
|
+
defaultFocused?: string,
|
|
23
|
+
today?: string,
|
|
24
|
+
locale?: string,
|
|
25
|
+
focusedDayRef?: { current: HTMLElement | null },
|
|
26
|
+
...rest: Rest
|
|
27
|
+
) {
|
|
28
|
+
const [range, setRange] = useControlled(value, defaultValue, onValueChange);
|
|
29
|
+
const [anchor, setAnchor] = useState<string | null>(null);
|
|
30
|
+
const [announcement, announce] = useState("");
|
|
31
|
+
validateRange(range);
|
|
32
|
+
const disabled = (date: PlainDate) =>
|
|
33
|
+
(minValue != null && date.toString() < minValue) ||
|
|
34
|
+
(maxValue != null && date.toString() > maxValue) ||
|
|
35
|
+
isDateDisabled?.(date) === true;
|
|
36
|
+
const choose = (date: PlainDate) => {
|
|
37
|
+
const iso = date.toString();
|
|
38
|
+
if (disabled(date)) return;
|
|
39
|
+
if (anchor == null) {
|
|
40
|
+
setAnchor(iso);
|
|
41
|
+
announce(`Start ${iso}. Choose an end date.`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const start = anchor < iso ? anchor : iso,
|
|
45
|
+
end = anchor < iso ? iso : anchor;
|
|
46
|
+
if (unavailableInRange(start, end, isDateDisabled)) {
|
|
47
|
+
announce("The range contains an unavailable date");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
setRange({ start, end });
|
|
51
|
+
setAnchor(null);
|
|
52
|
+
announce(`Selected ${start} to ${end}`);
|
|
53
|
+
};
|
|
54
|
+
const selected = (date: PlainDate) => {
|
|
55
|
+
const iso = date.toString();
|
|
56
|
+
return anchor != null
|
|
57
|
+
? iso === anchor
|
|
58
|
+
: range != null && iso >= range.start && iso <= range.end;
|
|
59
|
+
};
|
|
60
|
+
return (
|
|
61
|
+
<div {...rest}>
|
|
62
|
+
<CalendarRoot
|
|
63
|
+
value={anchor ?? range?.start ?? null}
|
|
64
|
+
defaultFocused={defaultFocused}
|
|
65
|
+
today={today}
|
|
66
|
+
locale={locale}
|
|
67
|
+
focusedDayRef={focusedDayRef}
|
|
68
|
+
isDateDisabled={disabled}
|
|
69
|
+
isDateSelected={selected}
|
|
70
|
+
onValueChange={choose}
|
|
71
|
+
>
|
|
72
|
+
{children}
|
|
73
|
+
</CalendarRoot>
|
|
74
|
+
<span role="status" aria-live="polite" style={visuallyHiddenStyle}>
|
|
75
|
+
{announcement}
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
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.
|
package/scroll-area.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A scroll area: a scrollbar you drew yourself, and the keyboard you took away
|
|
4
|
+
// when you did.
|
|
5
|
+
//
|
|
6
|
+
// # What it gives that `overflow: auto` does not
|
|
7
|
+
//
|
|
8
|
+
// A `<div style="overflow: auto">` scrolls with the wheel, with a trackpad, and
|
|
9
|
+
// with a finger. What it does not reliably do is scroll from the keyboard,
|
|
10
|
+
// because it may not be focusable: Firefox makes a scrollable region focusable,
|
|
11
|
+
// Chromium historically does not, and Safari's answer depends on the setting
|
|
12
|
+
// that controls whether `Tab` reaches anything but form controls. So a region
|
|
13
|
+
// that must be scrolled to be read is, in most browsers, a region a keyboard
|
|
14
|
+
// reader can see the top of and nothing else. That is WCAG 2.1.1, and it is the
|
|
15
|
+
// entire reason to have this component rather than the `div`:
|
|
16
|
+
//
|
|
17
|
+
// * **`tabindex="0"`, `role="region"` and a name.** The tab stop is what
|
|
18
|
+
// makes the arrow keys and `PageDown` work; the role and the name are what
|
|
19
|
+
// keep a tab stop from being a mystery — a focusable `div` with no name is
|
|
20
|
+
// announced as nothing at all, which is a worse place to land than the
|
|
21
|
+
// `div` was.
|
|
22
|
+
// * **Nothing is intercepted.** No `onKeyDown`, no `onWheel`, no
|
|
23
|
+
// `scroll-behavior` written from JavaScript. Every key that scrolls a
|
|
24
|
+
// native overflow container scrolls this one, because this one *is* a
|
|
25
|
+
// native overflow container and the component's whole contribution is not
|
|
26
|
+
// getting in its way. A scroll area that reimplemented `PageDown` would
|
|
27
|
+
// have to reimplement `Home`, `End`, the space bar, caret browsing and
|
|
28
|
+
// whatever the reader's own software sends, and would get one of them
|
|
29
|
+
// wrong.
|
|
30
|
+
// * **`scrollIntoView({ block: "nearest" })` still works.** `combobox.js` and
|
|
31
|
+
// `select.js` both call it to keep the active option visible, so a
|
|
32
|
+
// `Combobox.List` inside a `ScrollArea` is a case that has to work. It does
|
|
33
|
+
// because the viewport is a plain scroll container and nothing here
|
|
34
|
+
// overrides `scrollTop`; the one time this module writes it is described
|
|
35
|
+
// below, and it is exactly the case where the browser has already thrown
|
|
36
|
+
// the position away.
|
|
37
|
+
// * **The position survives a re-render.** Replacing the content of a scroll
|
|
38
|
+
// container — a filtered list, a new page of results — makes the browser
|
|
39
|
+
// clamp `scrollTop` to a shorter document and it does not put it back. The
|
|
40
|
+
// viewport remembers where the reader actually scrolled to, from the
|
|
41
|
+
// `scroll` event, and restores it after a commit that lost it. A reader who
|
|
42
|
+
// scrolls to the top themselves fires a `scroll` event, so the remembered
|
|
43
|
+
// position is theirs and this never fights them.
|
|
44
|
+
//
|
|
45
|
+
// # The scrollbar is a picture
|
|
46
|
+
//
|
|
47
|
+
// `ScrollArea.Scrollbar` is `aria-hidden` and holds no controls. That is the
|
|
48
|
+
// decision the component is made of: the *region* is the thing that scrolls and
|
|
49
|
+
// the keyboard is how it is operated, so a drawn scrollbar has nothing it must
|
|
50
|
+
// be able to do — which means it never has to answer WCAG 2.5.7's question
|
|
51
|
+
// about dragging, because nothing here is achievable only by dragging. It
|
|
52
|
+
// reports where the content is as two custom properties and stays out of the
|
|
53
|
+
// accessibility tree, where a second, mouse-only copy of the scroll position
|
|
54
|
+
// would be noise.
|
|
55
|
+
|
|
56
|
+
"use client";
|
|
57
|
+
|
|
58
|
+
import * as React from "@uniflowed/react";
|
|
59
|
+
import { createContext, useContext, useEffect, useId, useMemo, useRef } from "@uniflowed/react";
|
|
60
|
+
import { useEventListener } from "@uniflowed/hooks/dom";
|
|
61
|
+
|
|
62
|
+
import type { Orientation } from "./internal/roving-focus.js";
|
|
63
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
64
|
+
import { composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
65
|
+
|
|
66
|
+
export type { Orientation } from "./internal/roving-focus.js";
|
|
67
|
+
|
|
68
|
+
/** Where the reader last actually was, per axis. */
|
|
69
|
+
type Offset = {| x: number, y: number |};
|
|
70
|
+
|
|
71
|
+
type ScrollAreaState = {|
|
|
72
|
+
readonly base: string,
|
|
73
|
+
readonly label: string,
|
|
74
|
+
readonly viewportRef: { current: HTMLElement | null },
|
|
75
|
+
readonly rememberedRef: { current: Offset },
|
|
76
|
+
/** Written by the viewport, read by every scrollbar. */
|
|
77
|
+
readonly report: () => void,
|
|
78
|
+
readonly scrollbarsRef: { current: Array<HTMLElement> },
|
|
79
|
+
|};
|
|
80
|
+
|
|
81
|
+
const ScrollAreaContext: React.Context<ScrollAreaState | null> = createContext(null);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The scroll area a part belongs to.
|
|
85
|
+
*
|
|
86
|
+
* Raising rather than returning null, for the reason `useDialog` gives: a
|
|
87
|
+
* `ScrollArea.Scrollbar` outside a root would draw a thumb for a viewport it
|
|
88
|
+
* has never measured, and it would look correct until the content moved.
|
|
89
|
+
*/
|
|
90
|
+
hook useScrollArea(part: string): ScrollAreaState {
|
|
91
|
+
const state = useContext(ScrollAreaContext);
|
|
92
|
+
if (state == null) {
|
|
93
|
+
throw new Error(`${part} must be rendered inside a ScrollArea.Root`);
|
|
94
|
+
}
|
|
95
|
+
return state;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The box the viewport and the scrollbars sit in.
|
|
100
|
+
*
|
|
101
|
+
* `label` is required and lives here rather than on the viewport, because the
|
|
102
|
+
* name belongs to the whole component: it is what a reader hears when `Tab`
|
|
103
|
+
* lands them in it, and a scroll area that has to be scrolled to be read and is
|
|
104
|
+
* announced as "region" has told them nothing.
|
|
105
|
+
*/
|
|
106
|
+
export component ScrollAreaRoot(children: React.Node, label: string, ...rest: Rest) {
|
|
107
|
+
const base = useId();
|
|
108
|
+
const viewportRef = useRef<HTMLElement | null>(null);
|
|
109
|
+
const rememberedRef = useRef<Offset>({ x: 0, y: 0 });
|
|
110
|
+
const scrollbarsRef = useRef<Array<HTMLElement>>([]);
|
|
111
|
+
|
|
112
|
+
const state = useMemo(
|
|
113
|
+
() => ({
|
|
114
|
+
base,
|
|
115
|
+
label,
|
|
116
|
+
rememberedRef,
|
|
117
|
+
report: () => {
|
|
118
|
+
const viewport = viewportRef.current;
|
|
119
|
+
if (viewport == null) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const scrollbar of scrollbarsRef.current) {
|
|
123
|
+
write(scrollbar, viewport);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
scrollbarsRef,
|
|
127
|
+
viewportRef,
|
|
128
|
+
}),
|
|
129
|
+
[base, label],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<ScrollAreaContext.Provider value={state}>
|
|
134
|
+
<div {...rest}>{children}</div>
|
|
135
|
+
</ScrollAreaContext.Provider>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The element that actually scrolls: a named region, in the tab sequence.
|
|
141
|
+
*
|
|
142
|
+
* It carries no key handling at all. See the module header — every key that
|
|
143
|
+
* scrolls a native overflow container scrolls this one because it is one, and
|
|
144
|
+
* the component's contribution is the tab stop that lets those keys arrive.
|
|
145
|
+
*/
|
|
146
|
+
export component ScrollAreaViewport(children: React.Node, ...rest: Rest) {
|
|
147
|
+
const area = useScrollArea("ScrollArea.Viewport");
|
|
148
|
+
const { rememberedRef, report, viewportRef } = area;
|
|
149
|
+
const passed = withoutComposed(rest, ["ref"]);
|
|
150
|
+
|
|
151
|
+
useEventListener(viewportRef, "scroll", () => {
|
|
152
|
+
const viewport = viewportRef.current;
|
|
153
|
+
if (viewport == null) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// The reader's own position, including a deliberate scroll back to the
|
|
157
|
+
// top — which is why the restore below never fights them.
|
|
158
|
+
rememberedRef.current = { x: viewport.scrollLeft, y: viewport.scrollTop };
|
|
159
|
+
report();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// After every commit, because a commit is what replaces the content: a
|
|
163
|
+
// shorter document makes the browser clamp the offset to fit and it does not
|
|
164
|
+
// put it back when the content grows again.
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
const viewport = viewportRef.current;
|
|
167
|
+
if (viewport == null) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const { x, y } = rememberedRef.current;
|
|
171
|
+
if (y !== 0 && viewport.scrollTop === 0) {
|
|
172
|
+
viewport.scrollTop = y;
|
|
173
|
+
}
|
|
174
|
+
if (x !== 0 && viewport.scrollLeft === 0) {
|
|
175
|
+
viewport.scrollLeft = x;
|
|
176
|
+
}
|
|
177
|
+
report();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div
|
|
182
|
+
{...passed}
|
|
183
|
+
aria-label={area.label}
|
|
184
|
+
id={`${area.base}-viewport`}
|
|
185
|
+
ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
186
|
+
viewportRef.current = element;
|
|
187
|
+
})}
|
|
188
|
+
// A named region, which is what makes the tab stop below explicable
|
|
189
|
+
// rather than a place a reader lands and cannot account for.
|
|
190
|
+
role="region"
|
|
191
|
+
// The whole component. Without it the arrow keys and `PageDown` never
|
|
192
|
+
// arrive, and the bottom of this box is unreachable from a keyboard in
|
|
193
|
+
// every browser that does not make scroll containers focusable.
|
|
194
|
+
tabIndex={0}
|
|
195
|
+
>
|
|
196
|
+
{children}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The drawn scrollbar: two numbers and no semantics.
|
|
203
|
+
*
|
|
204
|
+
* `--uf-scroll-thumb-size` is the thumb's length as a fraction of the track and
|
|
205
|
+
* `--uf-scroll-thumb-offset` is where along it the thumb sits, both between 0
|
|
206
|
+
* and 1, so a stylesheet can draw one with a `scale` and a `translate` and
|
|
207
|
+
* measure nothing. `aria-hidden`, because the region it belongs to is already
|
|
208
|
+
* the thing a reader operates.
|
|
209
|
+
*/
|
|
210
|
+
export component ScrollAreaScrollbar(
|
|
211
|
+
children?: React.Node,
|
|
212
|
+
orientation?: Orientation = "vertical",
|
|
213
|
+
...rest: Rest
|
|
214
|
+
) {
|
|
215
|
+
const area = useScrollArea("ScrollArea.Scrollbar");
|
|
216
|
+
const { report, scrollbarsRef } = area;
|
|
217
|
+
const passed = withoutComposed(rest, ["ref"]);
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<div
|
|
221
|
+
{...passed}
|
|
222
|
+
// A picture of the scroll position is not something a screen reader has
|
|
223
|
+
// any use for: it cannot be operated, and the region it describes
|
|
224
|
+
// announces itself.
|
|
225
|
+
aria-hidden="true"
|
|
226
|
+
data-orientation={orientation}
|
|
227
|
+
ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
228
|
+
const kept = scrollbarsRef.current.filter((each) => each !== element);
|
|
229
|
+
scrollbarsRef.current = element == null ? kept : [...kept, element];
|
|
230
|
+
report();
|
|
231
|
+
})}
|
|
232
|
+
>
|
|
233
|
+
{children}
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Write where the content is onto a scrollbar.
|
|
240
|
+
*
|
|
241
|
+
* Imperatively, and only these two properties, for the reason
|
|
242
|
+
* `internal/anchor.js` gives about a placement: they change on every scroll
|
|
243
|
+
* frame, and re-rendering the scroll area and everything in it sixty times a
|
|
244
|
+
* second to move a thumb is the cost this package does not pay. React sets
|
|
245
|
+
* neither property, so a caller's `style` keeps everything in it.
|
|
246
|
+
*
|
|
247
|
+
* Both axes are written on every scrollbar rather than the one its
|
|
248
|
+
* `data-orientation` names, because a stylesheet reads the pair it wants and a
|
|
249
|
+
* branch here would be a second place the orientation is decided.
|
|
250
|
+
*/
|
|
251
|
+
function write(scrollbar: HTMLElement, viewport: HTMLElement): void {
|
|
252
|
+
const style = scrollbar.style;
|
|
253
|
+
style.setProperty(
|
|
254
|
+
"--uf-scroll-thumb-size",
|
|
255
|
+
String(fraction(viewport.clientHeight, viewport.scrollHeight)),
|
|
256
|
+
);
|
|
257
|
+
style.setProperty(
|
|
258
|
+
"--uf-scroll-thumb-offset",
|
|
259
|
+
String(fraction(viewport.scrollTop, viewport.scrollHeight - viewport.clientHeight)),
|
|
260
|
+
);
|
|
261
|
+
style.setProperty(
|
|
262
|
+
"--uf-scroll-thumb-size-x",
|
|
263
|
+
String(fraction(viewport.clientWidth, viewport.scrollWidth)),
|
|
264
|
+
);
|
|
265
|
+
style.setProperty(
|
|
266
|
+
"--uf-scroll-thumb-offset-x",
|
|
267
|
+
String(fraction(viewport.scrollLeft, viewport.scrollWidth - viewport.clientWidth)),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* `part / whole`, clamped, and 1 when there is no whole.
|
|
273
|
+
*
|
|
274
|
+
* A document that computes no layout reports every measurement as zero, and
|
|
275
|
+
* `0 / 0` is `NaN` — which a stylesheet reading the custom property renders as
|
|
276
|
+
* a thumb of no size at all rather than as a full-length one.
|
|
277
|
+
*/
|
|
278
|
+
function fraction(part: number, whole: number): number {
|
|
279
|
+
if (!(whole > 0)) {
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
return Math.min(1, Math.max(0, part / whole));
|
|
283
|
+
}
|