@uniflowed/ui 0.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/accordion.js +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
package/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
|
+
}
|