@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/slider.js ADDED
@@ -0,0 +1,405 @@
1
+ // @flow
2
+ //
3
+ // A slider, and the one decision that makes it reachable at all.
4
+ //
5
+ // `role="slider"` goes on the **thumb**. Not on the track, not on the wrapper.
6
+ // That single placement is the difference between a control a keyboard reader
7
+ // can operate and a decorative div, because the element carrying the role is
8
+ // the element that carries `tabindex="0"`, and a track with the role is a
9
+ // track nobody can focus with a thumb nobody can find.
10
+ //
11
+ // It follows that a range slider is **two** sliders. Two thumbs are two
12
+ // elements with `role="slider"`, each in the tab order, each with its own
13
+ // name — "Minimum" and "Maximum" — and each with its own bounds. Announcing
14
+ // both as 0–100 while the behaviour stops them passing each other is worse
15
+ // than not shipping the range at all: the reader is told they may set the low
16
+ // thumb to 90, they try, and the control silently refuses.
17
+ //
18
+ // So each thumb's `aria-valuemin` and `aria-valuemax` are bounded by its
19
+ // neighbour's current value, and they move when the neighbour moves. That is
20
+ // what the APG means by "when the range of another slider is dependent on the
21
+ // current value of a slider, the values of `aria-valuemin` or `aria-valuemax`
22
+ // of the dependent sliders are updated when the value changes".
23
+ //
24
+ // # `aria-valuetext` is the reason to write this component
25
+ //
26
+ // `aria-valuenow="3"` is announced as "3". If the scale is Low, Medium, High,
27
+ // or a price, or a date, then 3 is not the meaning and the reader is being
28
+ // given the implementation. `aria-valuetext="Medium"` is the meaning.
29
+ //
30
+ // It is a function on the root rather than a string on the thumb, because an
31
+ // uncontrolled slider's value is the component's and a caller cannot write
32
+ // down a string for a number they have not been told. `valueText(value, index)`
33
+ // is called with both, so a range can say "from £20" and "to £60".
34
+ //
35
+ // # The keyboard
36
+ //
37
+ // * `ArrowRight` / `ArrowUp` add a step, `ArrowLeft` / `ArrowDown` subtract
38
+ // one. Both axes work on both orientations, because a reader on a vertical
39
+ // slider still reaches for the horizontal keys about as often as not.
40
+ // * `PageUp` / `PageDown` move by `largeStep`, which is what makes a slider
41
+ // from 0 to 10,000 crossable without holding a key down for a minute.
42
+ // * `Home` and `End` go to that thumb's own ends — which for the lower thumb
43
+ // of a range is its neighbour, not the slider's maximum, so the two
44
+ // announcements and the two behaviours agree.
45
+ // * The horizontal keys mirror in a right-to-left page. `ArrowRight` means
46
+ // "further along", and further along is to the left there;
47
+ // `internal/range.js` reads the direction off the element the key arrived
48
+ // on. The vertical keys and `Home`/`End` are unaffected.
49
+ //
50
+ // # WCAG 2.5.7, and why the track is a part
51
+ //
52
+ // *Dragging Movements* says a control operated by dragging needs a way that is
53
+ // not a drag. The arrow keys are one; a press on the track is the other, and
54
+ // it is the one a pointer reader expects — so `Slider.Track` moves the nearest
55
+ // thumb to wherever it was pressed, and the drag that follows is a
56
+ // convenience on top of a control that already worked without it.
57
+ //
58
+ // That is also why the track is a part of this component rather than a `div`
59
+ // the caller draws: the press has to be turned into a value, which means
60
+ // measuring the track, and a caller who did it themselves would have to
61
+ // re-derive the snapping, the clamping and the direction.
62
+ //
63
+ // # Drawing it
64
+ //
65
+ // Nothing here has a width, a colour or a position, and an uncontrolled
66
+ // slider's value is not the caller's to compute from. So each thumb and the
67
+ // range carry the fraction they are at as custom properties —
68
+ // `--uf-slider-fraction` on a thumb, `--uf-slider-start` and `--uf-slider-end`
69
+ // on the range — and the caller's stylesheet decides what to do with them. A
70
+ // caller who writes no CSS sees nothing, which is the same promise every other
71
+ // module here makes.
72
+
73
+ "use client";
74
+
75
+ import * as React from "@uniflowed/react";
76
+ import { createContext, useContext, useMemo, useRef } from "@uniflowed/react";
77
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
78
+
79
+ import type { Rest } from "./internal/merge-props.js";
80
+ import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
81
+ import type { Orientation } from "./internal/roving-focus.js";
82
+ import { clamp, fraction, isReversed, snap } from "./internal/range.js";
83
+ import { useControlled } from "./internal/controlled-state.js";
84
+
85
+ type SliderState = {|
86
+ readonly values: $ReadOnlyArray<number>,
87
+ readonly min: number,
88
+ readonly max: number,
89
+ readonly step: number,
90
+ readonly largeStep: number,
91
+ readonly orientation: Orientation,
92
+ readonly disabled: boolean,
93
+ readonly valueText: ((value: number, index: number) => string) | void,
94
+ /** Move one thumb, holding it inside its neighbours. */
95
+ readonly setAt: (index: number, value: number) => void,
96
+ /** The thumb nearest a value, which is the one a press on the track moves. */
97
+ readonly nearest: (value: number) => number,
98
+ readonly trackRef: { current: HTMLElement | null },
99
+ |};
100
+
101
+ const SliderContext: React.Context<SliderState | null> = createContext(null);
102
+
103
+ hook useSlider(part: string): SliderState {
104
+ const state = useContext(SliderContext);
105
+ if (state == null) {
106
+ throw new Error(`${part} must be rendered inside a Slider.Root`);
107
+ }
108
+ return state;
109
+ }
110
+
111
+ /**
112
+ * The slider.
113
+ *
114
+ * `value` is always an array, with one entry per thumb, because a range slider
115
+ * is not a different component from a single one — it is the same component
116
+ * with two thumbs, and a `number | [number, number]` prop would make every
117
+ * caller narrow a union to read their own value back.
118
+ */
119
+ export component SliderRoot(
120
+ children: React.Node,
121
+ value?: $ReadOnlyArray<number>,
122
+ defaultValue?: $ReadOnlyArray<number> = [0],
123
+ onValueChange?: (value: $ReadOnlyArray<number>) => void,
124
+ min?: number = 0,
125
+ max?: number = 100,
126
+ step?: number = 1,
127
+ largeStep?: number = 10,
128
+ orientation?: Orientation = "horizontal",
129
+ disabled?: boolean = false,
130
+ valueText?: (value: number, index: number) => string,
131
+ ...rest: Rest
132
+ ) {
133
+ const [values, setValues] = useControlled(value, defaultValue, onValueChange);
134
+ const trackRef = useRef<HTMLElement | null>(null);
135
+
136
+ const setAt = useStableCallback((index: number, next: number) => {
137
+ if (disabled) {
138
+ return;
139
+ }
140
+ const current = values[index];
141
+ if (current === undefined) {
142
+ return;
143
+ }
144
+ // Bounded by the neighbours rather than by the slider, which is what stops
145
+ // the thumbs being dragged through each other — and it is the same pair of
146
+ // numbers the thumb announces as its own min and max, so what a reader is
147
+ // told and what the control does cannot drift apart.
148
+ const [lower, upper] = boundsOf(values, index, min, max);
149
+ const settled = snap(next, lower, upper, step);
150
+ if (settled === current) {
151
+ return;
152
+ }
153
+ const changed = values.slice();
154
+ changed[index] = settled;
155
+ setValues(changed);
156
+ });
157
+
158
+ const nearest = useStableCallback((target: number): number => {
159
+ let at = 0;
160
+ let best = Infinity;
161
+ for (let index = 0; index < values.length; index += 1) {
162
+ const distance = Math.abs((values[index] ?? 0) - target);
163
+ // Strictly nearer, so a press exactly between two thumbs takes the first
164
+ // one rather than the last — arbitrary either way, and consistent is
165
+ // what stops it feeling random.
166
+ if (distance < best) {
167
+ best = distance;
168
+ at = index;
169
+ }
170
+ }
171
+ return at;
172
+ });
173
+
174
+ const state = useMemo(
175
+ () => ({
176
+ values,
177
+ min,
178
+ max,
179
+ step,
180
+ largeStep,
181
+ orientation,
182
+ disabled,
183
+ valueText,
184
+ setAt,
185
+ nearest,
186
+ trackRef,
187
+ }),
188
+ [values, min, max, step, largeStep, orientation, disabled, valueText, setAt, nearest],
189
+ );
190
+
191
+ return (
192
+ <SliderContext.Provider value={state}>
193
+ <div {...rest}>{children}</div>
194
+ </SliderContext.Provider>
195
+ );
196
+ }
197
+
198
+ /**
199
+ * The bar the thumbs sit on, and the half of WCAG 2.5.7 that is not a key.
200
+ *
201
+ * A press anywhere on it moves the nearest thumb there, and holding the
202
+ * pointer down drags that thumb. The pointer is captured, so a drag that
203
+ * wanders off the track — which every drag does — keeps arriving here instead
204
+ * of being lost to whatever it wandered over.
205
+ */
206
+ export component SliderTrack(children: React.Node, ...rest: Rest) {
207
+ const slider = useSlider("Slider.Track");
208
+ const passed = withoutComposed(rest, ["onPointerDown", "onPointerMove", "onPointerUp", "ref"]);
209
+ const dragging = useRef<number | null>(null);
210
+
211
+ /** The value the pointer is over, from the track's own box. */
212
+ const valueAt = (event: $FlowFixMe): number | null => {
213
+ const track = slider.trackRef.current;
214
+ if (track == null) {
215
+ return null;
216
+ }
217
+ const box = track.getBoundingClientRect();
218
+ const vertical = slider.orientation === "vertical";
219
+ const size = vertical ? box.height : box.width;
220
+ if (size <= 0) {
221
+ return null;
222
+ }
223
+ const along = vertical ? box.bottom - event.clientY : event.clientX - box.left;
224
+ // A vertical slider's minimum is at the *bottom*, which is why the reading
225
+ // above is taken from `bottom` rather than `top`: a slider that grows
226
+ // downwards is the one arrangement no reader expects.
227
+ const part = clamp(along / size, 0, 1);
228
+ const forward = isReversed(track, slider.orientation) ? 1 - part : part;
229
+ return slider.min + forward * (slider.max - slider.min);
230
+ };
231
+
232
+ const moveTo = (event: $FlowFixMe, index: number | null) => {
233
+ const target = valueAt(event);
234
+ if (target == null) {
235
+ return;
236
+ }
237
+ const at = index ?? slider.nearest(target);
238
+ dragging.current = at;
239
+ slider.setAt(at, target);
240
+ };
241
+
242
+ return (
243
+ <div
244
+ {...passed}
245
+ onPointerDown={composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
246
+ if (slider.disabled) {
247
+ return;
248
+ }
249
+ // Otherwise the press selects the page's text on the way past, which
250
+ // makes a drag paint everything blue.
251
+ event.preventDefault();
252
+ event.currentTarget?.setPointerCapture?.(event.pointerId);
253
+ moveTo(event, null);
254
+ })}
255
+ onPointerMove={composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
256
+ if (dragging.current != null) {
257
+ moveTo(event, dragging.current);
258
+ }
259
+ })}
260
+ onPointerUp={composeHandlers(rest.onPointerUp, (event: $FlowFixMe) => {
261
+ dragging.current = null;
262
+ event.currentTarget?.releasePointerCapture?.(event.pointerId);
263
+ })}
264
+ ref={composeRefs(rest.ref, (element) => {
265
+ slider.trackRef.current = element;
266
+ })}
267
+ >
268
+ {children}
269
+ </div>
270
+ );
271
+ }
272
+
273
+ /**
274
+ * The filled part of the track.
275
+ *
276
+ * From the lowest thumb to the highest, which for a single thumb is from the
277
+ * slider's minimum to that thumb — the difference between a volume control and
278
+ * a price range, expressed by how many thumbs there are rather than by a prop.
279
+ *
280
+ * Presentational: it is inside the track and carries no role, because a reader
281
+ * is told the value by the thumb and telling them again here would be telling
282
+ * them twice.
283
+ */
284
+ export component SliderRange(...rest: Rest) {
285
+ const slider = useSlider("Slider.Range");
286
+ const passed = withoutComposed(rest, ["style"]);
287
+ const ends = [...slider.values].sort((first, second) => first - second);
288
+ const start = slider.values.length > 1 ? (ends[0] ?? slider.min) : slider.min;
289
+ const end = ends[ends.length - 1] ?? slider.min;
290
+
291
+ return (
292
+ <div
293
+ {...passed}
294
+ aria-hidden="true"
295
+ style={{
296
+ ...(rest.style as $FlowFixMe),
297
+ "--uf-slider-start": fraction(start, slider.min, slider.max),
298
+ "--uf-slider-end": fraction(end, slider.min, slider.max),
299
+ }}
300
+ />
301
+ );
302
+ }
303
+
304
+ /**
305
+ * One thumb, which is the slider as far as a screen reader is concerned.
306
+ *
307
+ * `index` is which of the root's values this thumb owns, and it defaults to
308
+ * zero so a one-thumb slider never mentions it. It is a prop rather than
309
+ * something counted from the document, because the tab order of a range slider
310
+ * has to stay put while the thumbs move — the APG is explicit that a thumb
311
+ * passing another does not reorder them — and a position counted from the page
312
+ * is a position that changes when the page does.
313
+ *
314
+ * A name is the caller's, and for a range it is two names: "Minimum" and
315
+ * "Maximum" told apart is the whole difference between a control a reader can
316
+ * operate and two identical "slider"s.
317
+ */
318
+ export component SliderThumb(index?: number = 0, ...rest: Rest) {
319
+ const slider = useSlider("Slider.Thumb");
320
+ const passed = withoutComposed(rest, ["onKeyDown", "style"]);
321
+ const value = slider.values[index] ?? slider.min;
322
+ const [lower, upper] = boundsOf(slider.values, index, slider.min, slider.max);
323
+
324
+ return (
325
+ <span
326
+ {...passed}
327
+ aria-disabled={slider.disabled ? "true" : undefined}
328
+ aria-orientation={slider.orientation}
329
+ // The neighbour's value, not the slider's end. A reader told they may
330
+ // set this thumb to 90 while the control refuses at 60 has been told
331
+ // something the control disagrees with.
332
+ aria-valuemax={upper}
333
+ aria-valuemin={lower}
334
+ aria-valuenow={value}
335
+ aria-valuetext={slider.valueText?.(value, index)}
336
+ onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
337
+ if (slider.disabled) {
338
+ return;
339
+ }
340
+ const reversed = isReversed(event.currentTarget, slider.orientation);
341
+ const move = stepFor(event.key, slider.step, slider.largeStep, reversed);
342
+ if (move != null) {
343
+ // Before moving: the arrow keys scroll the page, and a slider that
344
+ // moves the page under the reader as it moves the value is a control
345
+ // they cannot watch.
346
+ event.preventDefault();
347
+ slider.setAt(index, value + move);
348
+ return;
349
+ }
350
+ if (event.key === "Home" || event.key === "End") {
351
+ event.preventDefault();
352
+ // This thumb's own ends, which for the lower thumb of a range is its
353
+ // neighbour rather than the slider's maximum.
354
+ slider.setAt(index, event.key === "Home" ? lower : upper);
355
+ }
356
+ })}
357
+ role="slider"
358
+ style={{
359
+ ...(rest.style as $FlowFixMe),
360
+ "--uf-slider-fraction": fraction(value, slider.min, slider.max),
361
+ }}
362
+ // In the tab sequence, and out of it while disabled — the browser does
363
+ // this for a real control and there is no real control here to do it.
364
+ tabIndex={slider.disabled ? -1 : 0}
365
+ />
366
+ );
367
+ }
368
+
369
+ /**
370
+ * How far a key moves the value, or nothing when the key is not ours.
371
+ *
372
+ * `PageUp` and `PageDown` are never mirrored: they mean "a lot more" and "a
373
+ * lot less", which is not a direction on the page. Neither are `ArrowUp` and
374
+ * `ArrowDown`, since writing direction does not flip the vertical axis.
375
+ */
376
+ function stepFor(key: string, step: number, largeStep: number, reversed: boolean): number | null {
377
+ const forward = reversed ? -1 : 1;
378
+ const move = step <= 0 ? 1 : step;
379
+ return match (key) {
380
+ "ArrowRight" => move * forward,
381
+ "ArrowLeft" => -move * forward,
382
+ "ArrowUp" => move,
383
+ "ArrowDown" => -move,
384
+ "PageUp" => largeStep <= 0 ? move : largeStep,
385
+ "PageDown" => -(largeStep <= 0 ? move : largeStep),
386
+ _ => null,
387
+ };
388
+ }
389
+
390
+ /**
391
+ * What a thumb may be set to: its neighbours' values, or the slider's ends.
392
+ *
393
+ * One function, called by the thumb to announce its bounds and by the root to
394
+ * enforce them, so the two cannot disagree.
395
+ */
396
+ function boundsOf(
397
+ values: $ReadOnlyArray<number>,
398
+ index: number,
399
+ min: number,
400
+ max: number,
401
+ ): [number, number] {
402
+ const below = index > 0 ? values[index - 1] : undefined;
403
+ const above = index < values.length - 1 ? values[index + 1] : undefined;
404
+ return [below ?? min, above ?? max];
405
+ }
package/switch.js ADDED
@@ -0,0 +1,73 @@
1
+ // @flow
2
+ //
3
+ // A switch: two states, and a screen reader that says which.
4
+ //
5
+ // It exists because the styled version of an on/off control is almost always a
6
+ // `div` with a knob drawn in it, and the moment it stops being a real control it
7
+ // stops being announced, stops toggling on `Space`, and stops being reachable by
8
+ // `Tab`. This keeps all three — the role, the `aria-checked` state, and the
9
+ // keys — while shipping no styles at all.
10
+ //
11
+ // # A switch is not a checkbox
12
+ //
13
+ // A screen reader says "on" and "off" for a switch and "checked" and
14
+ // "unchecked" for a checkbox, and the two are not interchangeable: a checkbox
15
+ // answers a question ("include me in the mailing list") and a switch operates a
16
+ // thing ("notifications, on"). A checkbox also has a third state that a switch
17
+ // does not, which is why `checkbox.js` is a separate component rather than this
18
+ // one with a different `role`.
19
+ //
20
+ // The keyboard follows from the same distinction. `Space` toggles both. `Enter`
21
+ // toggles a *switch*, because a switch is an operation and pressing Enter on
22
+ // something that operates is what a reader expects — while `checkbox.js`
23
+ // deliberately leaves `Enter` alone so that a checkbox inside a form still
24
+ // submits it. That is the whole reason these are not one file with a flag.
25
+
26
+ "use client";
27
+
28
+ import * as React from "@uniflowed/react";
29
+
30
+ import type { Rest } from "./internal/merge-props.js";
31
+ import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
32
+ import { useControlled } from "./internal/controlled-state.js";
33
+
34
+ /** A two-state switch: on or off. */
35
+ export component Switch(
36
+ checked?: boolean,
37
+ defaultChecked?: boolean = false,
38
+ onCheckedChange?: (checked: boolean) => void,
39
+ disabled?: boolean = false,
40
+ children?: React.Node,
41
+ ...rest: Rest
42
+ ) {
43
+ const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
44
+ const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
45
+
46
+ return (
47
+ <button
48
+ {...passed}
49
+ aria-checked={on ? "true" : "false"}
50
+ disabled={disabled}
51
+ onClick={composeHandlers(rest.onClick, () => {
52
+ if (!disabled) {
53
+ setOn(!on);
54
+ }
55
+ })}
56
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
57
+ if (disabled || (event.key !== " " && event.key !== "Enter")) {
58
+ return;
59
+ }
60
+ // Preventing the default is not decoration. It stops `Space` scrolling
61
+ // the page — which is what makes a hand-written toggle feel broken even
62
+ // when it works — and it stops the browser's own click from arriving
63
+ // after this handler and toggling the switch a second time.
64
+ event.preventDefault();
65
+ setOn(!on);
66
+ })}
67
+ role="switch"
68
+ type="button"
69
+ >
70
+ {children}
71
+ </button>
72
+ );
73
+ }