@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/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 remembered: { current: Offset },
76
+ /** Written by the viewport, read by every scrollbar. */
77
+ readonly report: () => void,
78
+ readonly scrollbars: { 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 remembered = useRef<Offset>({ x: 0, y: 0 });
110
+ const scrollbars = useRef<Array<HTMLElement>>([]);
111
+
112
+ const state = useMemo(
113
+ () => ({
114
+ base,
115
+ label,
116
+ remembered,
117
+ report: () => {
118
+ const viewport = viewportRef.current;
119
+ if (viewport == null) {
120
+ return;
121
+ }
122
+ for (const scrollbar of scrollbars.current) {
123
+ write(scrollbar, viewport);
124
+ }
125
+ },
126
+ scrollbars,
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 { remembered, 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
+ remembered.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 } = remembered.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, scrollbars } = 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 = scrollbars.current.filter((each) => each !== element);
229
+ scrollbars.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
+ }