@uniflowed/ui 0.0.0-alpha.2 → 0.0.0-alpha.37

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.
Files changed (58) hide show
  1. package/accordion.js +360 -0
  2. package/alert-dialog.js +282 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +276 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +550 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +264 -0
  9. package/collapsible.js +169 -0
  10. package/combobox.js +728 -0
  11. package/context-menu.js +206 -0
  12. package/date-picker.js +346 -0
  13. package/dialog.js +523 -0
  14. package/drawer.js +490 -0
  15. package/field.js +387 -0
  16. package/hover-card.js +330 -0
  17. package/index.js +1699 -22
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2163 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/controlled-state.js +65 -0
  22. package/internal/date-grid.js +260 -0
  23. package/internal/disclosure.js +298 -0
  24. package/internal/focus.js +64 -0
  25. package/internal/form-value.js +83 -0
  26. package/internal/hover-intent.js +259 -0
  27. package/internal/menu-tree.js +228 -0
  28. package/internal/merge-props.js +285 -0
  29. package/internal/range.js +147 -0
  30. package/internal/roving-focus.js +430 -0
  31. package/menu.js +823 -0
  32. package/menubar.js +287 -0
  33. package/navigation-menu.js +251 -0
  34. package/package.json +9 -9
  35. package/pagination.js +209 -0
  36. package/popover.js +343 -0
  37. package/progress.js +91 -0
  38. package/radio-group.js +302 -0
  39. package/resizable.js +447 -0
  40. package/scroll-area.js +283 -0
  41. package/select.js +902 -0
  42. package/separator.js +97 -0
  43. package/sheet.js +189 -0
  44. package/sidebar.js +300 -0
  45. package/skeleton.js +159 -0
  46. package/slider.js +405 -0
  47. package/switch.js +81 -0
  48. package/table.js +502 -0
  49. package/tabs.js +289 -0
  50. package/toast.js +592 -0
  51. package/toggle-group.js +283 -0
  52. package/toggle.js +105 -0
  53. package/tooltip.js +400 -0
  54. package/internal/dialog.js +0 -236
  55. package/internal/field.js +0 -161
  56. package/internal/props.js +0 -78
  57. package/internal/switch.js +0 -122
  58. package/internal/tabs.js +0 -270
package/skeleton.js ADDED
@@ -0,0 +1,159 @@
1
+ // @flow
2
+ //
3
+ // A loading placeholder, and the reader it is usually invisible to.
4
+ //
5
+ // A skeleton is the one component on the presentational list that silently
6
+ // makes a page *worse*. A screen of grey rounded rectangles tells a sighted
7
+ // reader that content is coming and something is happening. To everybody else
8
+ // it is a screen of empty `<div>`s: nothing is announced, nothing is described,
9
+ // and the honest summary of the page is that it has no content — which is
10
+ // exactly the conclusion somebody reaches before they leave.
11
+ //
12
+ // Three attributes fix it and none of them is on the grey box:
13
+ //
14
+ // * the skeletons themselves are **`aria-hidden="true"`**, because a
15
+ // placeholder is a picture of content and not content;
16
+ // * the region they stand in is **`aria-busy="true"`**, which is the
17
+ // attribute that says "this is being filled in" and stops assistive
18
+ // technology reporting a half-built subtree;
19
+ // * and something has to **say so out loud**, because `aria-busy` is a
20
+ // property a reader can ask about rather than an announcement they are
21
+ // given.
22
+ //
23
+ // # The live region, and why it is empty for one commit
24
+ //
25
+ // This is ubugeeei-prod/uf#289's rule met at the worst possible moment.
26
+ // `combobox.js` states it: a live region added to the page in the same commit
27
+ // as the text it holds is usually not announced, because the technology
28
+ // watching it had nothing to watch until it was already too late.
29
+ //
30
+ // A skeleton screen is busy on its *first* render. So the naive version —
31
+ // render `<div role="status">Loading…</div>` while `pending` — mounts the
32
+ // region with the sentence already in it and is silent, then unmounts the whole
33
+ // thing when the content arrives and is silent again. It announces nothing,
34
+ // ever, which is the same as not having been written.
35
+ //
36
+ // `Skeleton.Root` therefore renders the region empty and fills it in an effect,
37
+ // one commit later. The region existed before the text did, which is the whole
38
+ // of what the rule asks for, and it costs a second commit on mount and nothing
39
+ // afterwards.
40
+ //
41
+ // # Keep the root mounted across the load
42
+ //
43
+ // Which is the one thing this component asks of a caller, and the reason
44
+ // `busy` is a prop rather than the root's presence. A root that is unmounted
45
+ // when the content arrives takes its live region with it, so "loaded" is said
46
+ // to nobody and the reader is left with the last thing they heard, which was
47
+ // "loading". Wrap the thing that loads and toggle `busy`; that is also what
48
+ // lets `aria-busy` go from true to false on one element, which is what it is
49
+ // for.
50
+ //
51
+ // # Not `Progress`
52
+ //
53
+ // A skeleton says *that* something is loading. `Progress` says *how far along*
54
+ // it is, has `aria-valuenow` and lives in `progress.js`. A skeleton with a
55
+ // percentage is a progress bar that has been drawn as boxes, and a progress bar
56
+ // with no number is the indeterminate one that module already ships.
57
+
58
+ "use client";
59
+
60
+ import * as React from "@uniflowed/react";
61
+ import { useEffect, useRef, useState } from "@uniflowed/react";
62
+
63
+ import type { RenderProp, Rest } from "./internal/merge-props.js";
64
+ import { withProps } from "./internal/merge-props.js";
65
+
66
+ /**
67
+ * The region that is being filled in, and the sentence that says so.
68
+ *
69
+ * `children` is the skeletons while `busy`, and the real content once it is
70
+ * not — both go inside, because it is one region either way and `aria-busy`
71
+ * describes it in both states.
72
+ *
73
+ * <Skeleton.Root busy={pending}>
74
+ * {pending ? (
75
+ * <>
76
+ * <Skeleton.Box />
77
+ * <Skeleton.Box />
78
+ * </>
79
+ * ) : (
80
+ * <Invoices rows={invoices} />
81
+ * )}
82
+ * </Skeleton.Root>
83
+ *
84
+ * `label` and `doneLabel` are what is announced. English defaults, because a
85
+ * component that announces nothing by default is the component this one exists
86
+ * to replace; a real application passes its translation.
87
+ *
88
+ * `doneLabel` is announced only after a spell of `busy`, so a region that was
89
+ * never loading never says it has loaded.
90
+ *
91
+ * `render` changes the element that owns the busy state. The live region stays
92
+ * beside it, mounted by this component, because that timing is the accessibility
93
+ * contract rather than markup the caller can safely recreate by sight.
94
+ */
95
+ export component SkeletonRoot(
96
+ children: React.Node,
97
+ busy?: boolean = true,
98
+ label?: string = "Loading…",
99
+ doneLabel?: string = "Loaded",
100
+ render?: RenderProp,
101
+ ...rest: Rest
102
+ ) {
103
+ const [message, setMessage] = useState("");
104
+ // Whether there has been anything to finish. Written and read in effects
105
+ // only, and nothing renders it — the promise `index.js` makes about refs.
106
+ const waited = useRef(false);
107
+
108
+ useEffect(() => {
109
+ if (busy) {
110
+ waited.current = true;
111
+ setMessage(label);
112
+ return;
113
+ }
114
+ setMessage(waited.current ? doneLabel : "");
115
+ }, [busy, doneLabel, label]);
116
+
117
+ const props = withProps(rest, {
118
+ "aria-busy": busy ? "true" : undefined,
119
+ children,
120
+ });
121
+
122
+ return (
123
+ <>
124
+ {render == null ? <div {...props} /> : render(props)}
125
+ {/*
126
+ Beside the region rather than inside it, so a reader walking into the
127
+ content does not find a sentence about it sitting among the rows — and
128
+ mounted from the first render holding nothing, because a live region
129
+ that appears together with its text is not announced at all. The module
130
+ header says why that matters more here than anywhere else.
131
+ */}
132
+ <div aria-atomic="true" aria-live="polite" data-uf-skeleton-status="" role="status">
133
+ {message}
134
+ </div>
135
+ </>
136
+ );
137
+ }
138
+
139
+ /**
140
+ * One grey box.
141
+ *
142
+ * `aria-hidden="true"`, which is the entire component: a placeholder is a
143
+ * picture of content, and content it is not. Everything about its size, its
144
+ * shape and its shimmer is a class name the caller brings.
145
+ *
146
+ * `children` is allowed and is hidden with the rest of it, because sizing a
147
+ * box by putting the text it stands in for inside it is a real technique and
148
+ * there is no reason to make a caller reach for a second element to do it.
149
+ *
150
+ * `render` changes the placeholder element, not the fact that it is hidden
151
+ * from the accessibility tree.
152
+ */
153
+ export component SkeletonBox(children?: React.Node, render?: RenderProp, ...rest: Rest) {
154
+ const props = withProps(rest, { "aria-hidden": "true", children });
155
+ if (render != null) {
156
+ return render(props);
157
+ }
158
+ return <div {...props} />;
159
+ }
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 { RenderProp, Rest } from "./internal/merge-props.js";
80
+ import {
81
+ composeHandlers,
82
+ composeRefs,
83
+ withProps,
84
+ withoutComposed,
85
+ } from "./internal/merge-props.js";
86
+ import type { Orientation } from "./internal/roving-focus.js";
87
+ import { clamp, fraction, isReversed, snap } from "./internal/range.js";
88
+ import { useControlled } from "./internal/controlled-state.js";
89
+
90
+ type SliderState = {|
91
+ readonly values: $ReadOnlyArray<number>,
92
+ readonly min: number,
93
+ readonly max: number,
94
+ readonly step: number,
95
+ readonly largeStep: number,
96
+ readonly orientation: Orientation,
97
+ readonly disabled: boolean,
98
+ readonly valueText: ((value: number, index: number) => string) | void,
99
+ /** Move one thumb, holding it inside its neighbours. */
100
+ readonly setAt: (index: number, value: number) => void,
101
+ /** The thumb nearest a value, which is the one a press on the track moves. */
102
+ readonly nearest: (value: number) => number,
103
+ readonly trackRef: { current: HTMLElement | null },
104
+ |};
105
+
106
+ const SliderContext: React.Context<SliderState | null> = createContext(null);
107
+
108
+ hook useSlider(part: string): SliderState {
109
+ const state = useContext(SliderContext);
110
+ if (state == null) {
111
+ throw new Error(`${part} must be rendered inside a Slider.Root`);
112
+ }
113
+ return state;
114
+ }
115
+
116
+ /**
117
+ * The slider.
118
+ *
119
+ * `value` is always an array, with one entry per thumb, because a range slider
120
+ * is not a different component from a single one — it is the same component
121
+ * with two thumbs, and a `number | [number, number]` prop would make every
122
+ * caller narrow a union to read their own value back.
123
+ */
124
+ export component SliderRoot(
125
+ children: React.Node,
126
+ value?: $ReadOnlyArray<number>,
127
+ defaultValue?: $ReadOnlyArray<number> = [0],
128
+ onValueChange?: (value: $ReadOnlyArray<number>) => void,
129
+ min?: number = 0,
130
+ max?: number = 100,
131
+ step?: number = 1,
132
+ largeStep?: number = 10,
133
+ orientation?: Orientation = "horizontal",
134
+ disabled?: boolean = false,
135
+ valueText?: (value: number, index: number) => string,
136
+ render?: RenderProp,
137
+ ...rest: Rest
138
+ ) {
139
+ const [values, setValues] = useControlled(value, defaultValue, onValueChange);
140
+ const trackRef = useRef<HTMLElement | null>(null);
141
+
142
+ const setAt = useStableCallback((index: number, next: number) => {
143
+ if (disabled) {
144
+ return;
145
+ }
146
+ const current = values[index];
147
+ if (current === undefined) {
148
+ return;
149
+ }
150
+ // Bounded by the neighbours rather than by the slider, which is what stops
151
+ // the thumbs being dragged through each other — and it is the same pair of
152
+ // numbers the thumb announces as its own min and max, so what a reader is
153
+ // told and what the control does cannot drift apart.
154
+ const [lower, upper] = boundsOf(values, index, min, max);
155
+ const settled = snap(next, lower, upper, step);
156
+ if (settled === current) {
157
+ return;
158
+ }
159
+ const changed = values.slice();
160
+ changed[index] = settled;
161
+ setValues(changed);
162
+ });
163
+
164
+ const nearest = useStableCallback((target: number): number => {
165
+ let at = 0;
166
+ let best = Infinity;
167
+ for (let index = 0; index < values.length; index += 1) {
168
+ const distance = Math.abs((values[index] ?? 0) - target);
169
+ // Strictly nearer, so a press exactly between two thumbs takes the first
170
+ // one rather than the last — arbitrary either way, and consistent is
171
+ // what stops it feeling random.
172
+ if (distance < best) {
173
+ best = distance;
174
+ at = index;
175
+ }
176
+ }
177
+ return at;
178
+ });
179
+
180
+ const state = useMemo(
181
+ () => ({
182
+ values,
183
+ min,
184
+ max,
185
+ step,
186
+ largeStep,
187
+ orientation,
188
+ disabled,
189
+ valueText,
190
+ setAt,
191
+ nearest,
192
+ trackRef,
193
+ }),
194
+ [values, min, max, step, largeStep, orientation, disabled, valueText, setAt, nearest],
195
+ );
196
+
197
+ return (
198
+ <SliderContext.Provider value={state}>
199
+ {render == null ? <div {...rest}>{children}</div> : render(withProps(rest, { children }))}
200
+ </SliderContext.Provider>
201
+ );
202
+ }
203
+
204
+ /**
205
+ * The bar the thumbs sit on, and the half of WCAG 2.5.7 that is not a key.
206
+ *
207
+ * A press anywhere on it moves the nearest thumb there, and holding the
208
+ * pointer down drags that thumb. The pointer is captured, so a drag that
209
+ * wanders off the track — which every drag does — keeps arriving here instead
210
+ * of being lost to whatever it wandered over.
211
+ */
212
+ export component SliderTrack(children: React.Node, render?: RenderProp, ...rest: Rest) {
213
+ const slider = useSlider("Slider.Track");
214
+ const passed = withoutComposed(rest, ["onPointerDown", "onPointerMove", "onPointerUp", "ref"]);
215
+ const dragging = useRef<number | null>(null);
216
+
217
+ /** The value the pointer is over, from the track's own box. */
218
+ const valueAt = (event: $FlowFixMe): number | null => {
219
+ const track = slider.trackRef.current;
220
+ if (track == null) {
221
+ return null;
222
+ }
223
+ const box = track.getBoundingClientRect();
224
+ const vertical = slider.orientation === "vertical";
225
+ const size = vertical ? box.height : box.width;
226
+ if (size <= 0) {
227
+ return null;
228
+ }
229
+ const along = vertical ? box.bottom - event.clientY : event.clientX - box.left;
230
+ // A vertical slider's minimum is at the *bottom*, which is why the reading
231
+ // above is taken from `bottom` rather than `top`: a slider that grows
232
+ // downwards is the one arrangement no reader expects.
233
+ const part = clamp(along / size, 0, 1);
234
+ const forward = isReversed(track, slider.orientation) ? 1 - part : part;
235
+ return slider.min + forward * (slider.max - slider.min);
236
+ };
237
+
238
+ const moveTo = (event: $FlowFixMe, index: number | null) => {
239
+ const target = valueAt(event);
240
+ if (target == null) {
241
+ return;
242
+ }
243
+ const at = index ?? slider.nearest(target);
244
+ dragging.current = at;
245
+ slider.setAt(at, target);
246
+ };
247
+
248
+ const props = withProps(passed, {
249
+ children,
250
+ onPointerDown: composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
251
+ if (slider.disabled) {
252
+ return;
253
+ }
254
+ // Otherwise the press selects the page's text on the way past, which
255
+ // makes a drag paint everything blue.
256
+ event.preventDefault();
257
+ event.currentTarget?.setPointerCapture?.(event.pointerId);
258
+ moveTo(event, null);
259
+ }),
260
+ onPointerMove: composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
261
+ if (dragging.current != null) {
262
+ moveTo(event, dragging.current);
263
+ }
264
+ }),
265
+ onPointerUp: composeHandlers(rest.onPointerUp, (event: $FlowFixMe) => {
266
+ dragging.current = null;
267
+ event.currentTarget?.releasePointerCapture?.(event.pointerId);
268
+ }),
269
+ ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
270
+ slider.trackRef.current = element;
271
+ }),
272
+ });
273
+
274
+ return render == null ? <div {...props} /> : render(props);
275
+ }
276
+
277
+ /**
278
+ * The filled part of the track.
279
+ *
280
+ * From the lowest thumb to the highest, which for a single thumb is from the
281
+ * slider's minimum to that thumb — the difference between a volume control and
282
+ * a price range, expressed by how many thumbs there are rather than by a prop.
283
+ *
284
+ * Presentational: it is inside the track and carries no role, because a reader
285
+ * is told the value by the thumb and telling them again here would be telling
286
+ * them twice.
287
+ */
288
+ export component SliderRange(render?: RenderProp, ...rest: Rest) {
289
+ const slider = useSlider("Slider.Range");
290
+ const passed = withoutComposed(rest, ["style"]);
291
+ const ends = [...slider.values].sort((first, second) => first - second);
292
+ const start = slider.values.length > 1 ? (ends[0] ?? slider.min) : slider.min;
293
+ const end = ends[ends.length - 1] ?? slider.min;
294
+ const props = withProps(passed, {
295
+ "aria-hidden": "true",
296
+ style: {
297
+ ...(rest.style as $FlowFixMe),
298
+ "--uf-slider-start": fraction(start, slider.min, slider.max),
299
+ "--uf-slider-end": fraction(end, slider.min, slider.max),
300
+ },
301
+ });
302
+
303
+ return render == null ? <div {...props} /> : render(props);
304
+ }
305
+
306
+ /**
307
+ * One thumb, which is the slider as far as a screen reader is concerned.
308
+ *
309
+ * `index` is which of the root's values this thumb owns, and it defaults to
310
+ * zero so a one-thumb slider never mentions it. It is a prop rather than
311
+ * something counted from the document, because the tab order of a range slider
312
+ * has to stay put while the thumbs move — the APG is explicit that a thumb
313
+ * passing another does not reorder them — and a position counted from the page
314
+ * is a position that changes when the page does.
315
+ *
316
+ * A name is the caller's, and for a range it is two names: "Minimum" and
317
+ * "Maximum" told apart is the whole difference between a control a reader can
318
+ * operate and two identical "slider"s.
319
+ */
320
+ export component SliderThumb(index?: number = 0, render?: RenderProp, ...rest: Rest) {
321
+ const slider = useSlider("Slider.Thumb");
322
+ const passed = withoutComposed(rest, ["onKeyDown", "style"]);
323
+ const value = slider.values[index] ?? slider.min;
324
+ const [lower, upper] = boundsOf(slider.values, index, slider.min, slider.max);
325
+ const props = withProps(passed, {
326
+ "aria-disabled": slider.disabled ? "true" : undefined,
327
+ "aria-orientation": slider.orientation,
328
+ // The neighbour's value, not the slider's end. A reader told they may
329
+ // set this thumb to 90 while the control refuses at 60 has been told
330
+ // something the control disagrees with.
331
+ "aria-valuemax": upper,
332
+ "aria-valuemin": lower,
333
+ "aria-valuenow": value,
334
+ "aria-valuetext": slider.valueText?.(value, index),
335
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
336
+ if (slider.disabled) {
337
+ return;
338
+ }
339
+ const reversed = isReversed(event.currentTarget, slider.orientation);
340
+ const move = stepFor(event.key, slider.step, slider.largeStep, reversed);
341
+ if (move != null) {
342
+ // Before moving: the arrow keys scroll the page, and a slider that
343
+ // moves the page under the reader as it moves the value is a control
344
+ // they cannot watch.
345
+ event.preventDefault();
346
+ slider.setAt(index, value + move);
347
+ return;
348
+ }
349
+ if (event.key === "Home" || event.key === "End") {
350
+ event.preventDefault();
351
+ // This thumb's own ends, which for the lower thumb of a range is its
352
+ // neighbour rather than the slider's maximum.
353
+ slider.setAt(index, event.key === "Home" ? lower : upper);
354
+ }
355
+ }),
356
+ role: "slider",
357
+ style: {
358
+ ...(rest.style as $FlowFixMe),
359
+ "--uf-slider-fraction": fraction(value, slider.min, slider.max),
360
+ },
361
+ // In the tab sequence, and out of it while disabled — the browser does
362
+ // this for a real control and there is no real control here to do it.
363
+ tabIndex: slider.disabled ? -1 : 0,
364
+ });
365
+
366
+ return render == null ? <span {...props} /> : render(props);
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,81 @@
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` turns
23
+ // `Enter` into the submission of the form the checkbox is in, which is what a
24
+ // native `<input type="checkbox">` does with the key and what a reader
25
+ // answering a question on their way to a submit button is asking for. That is
26
+ // the whole reason these are not one file with a flag.
27
+
28
+ "use client";
29
+
30
+ import * as React from "@uniflowed/react";
31
+
32
+ import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
33
+ import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
34
+ import { useControlled } from "./internal/controlled-state.js";
35
+
36
+ /**
37
+ * A two-state switch: on or off.
38
+ *
39
+ * `render` is the escape hatch, and on a switch it is what a caller with their
40
+ * own `<Pressable>` or a `<div role="switch">` in a design system reaches for.
41
+ * `type="button"` is the one thing it does not hand over, because that is true
42
+ * of the element rather than of the switch.
43
+ */
44
+ export component Switch(
45
+ checked?: boolean,
46
+ defaultChecked?: boolean = false,
47
+ onCheckedChange?: (checked: boolean) => void,
48
+ disabled?: boolean = false,
49
+ children?: React.Node,
50
+ render?: RenderProp,
51
+ ...rest: Rest
52
+ ) {
53
+ const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
54
+ const props = withProps(withoutComposed(rest, ["onClick", "onKeyDown"]), {
55
+ "aria-checked": on ? "true" : "false",
56
+ children,
57
+ disabled,
58
+ onClick: composeHandlers(rest.onClick, () => {
59
+ if (!disabled) {
60
+ setOn(!on);
61
+ }
62
+ }),
63
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: PartEvent) => {
64
+ if (disabled || (event.key !== " " && event.key !== "Enter")) {
65
+ return;
66
+ }
67
+ // Preventing the default is not decoration. It stops `Space` scrolling
68
+ // the page — which is what makes a hand-written toggle feel broken even
69
+ // when it works — and it stops the browser's own click from arriving
70
+ // after this handler and toggling the switch a second time.
71
+ event.preventDefault();
72
+ setOn(!on);
73
+ }),
74
+ role: "switch",
75
+ });
76
+
77
+ if (render != null) {
78
+ return render(props);
79
+ }
80
+ return <button {...props} type="button" />;
81
+ }