@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.40
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 +360 -0
- package/alert-dialog.js +282 -0
- package/alert.js +142 -0
- package/avatar.js +276 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +547 -0
- package/carousel.js +410 -0
- package/checkbox.js +216 -31
- package/collapsible.js +169 -0
- package/combobox.js +209 -40
- package/context-menu.js +206 -0
- package/date-picker.js +346 -0
- package/dialog.js +229 -197
- package/drawer.js +490 -0
- package/field.js +257 -42
- package/hover-card.js +330 -0
- package/index.js +1548 -24
- package/input-otp.js +218 -0
- package/interactions.js +2323 -0
- package/internal/anchor.js +565 -0
- package/internal/date-grid.js +260 -0
- package/internal/disclosure.js +298 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +206 -7
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +205 -11
- package/menu.js +521 -336
- package/menubar.js +288 -0
- package/navigation-menu.js +251 -0
- package/package.json +8 -12
- package/pagination.js +209 -0
- package/popover.js +344 -0
- package/progress.js +91 -0
- package/radio-group.js +302 -0
- package/resizable.js +447 -0
- package/scroll-area.js +283 -0
- package/select.js +888 -0
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +313 -0
- package/skeleton.js +159 -0
- package/slider.js +405 -0
- package/switch.js +43 -34
- package/table.js +520 -0
- package/tabs.js +99 -96
- package/toast.js +592 -0
- package/toggle-group.js +282 -0
- package/toggle.js +105 -0
- package/tooltip.js +400 -0
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
CHANGED
|
@@ -19,54 +19,63 @@
|
|
|
19
19
|
//
|
|
20
20
|
// The keyboard follows from the same distinction. `Space` toggles both. `Enter`
|
|
21
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
|
-
//
|
|
24
|
-
//
|
|
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.
|
|
25
27
|
|
|
26
28
|
"use client";
|
|
27
29
|
|
|
28
30
|
import * as React from "@uniflowed/react";
|
|
29
31
|
|
|
30
|
-
import {
|
|
32
|
+
import type { PartEvent, RenderProp, Rest } from "./internal/merge-props.js";
|
|
33
|
+
import { composeHandlers, withProps, withoutComposed } from "./internal/merge-props.js";
|
|
31
34
|
import { useControlled } from "./internal/controlled-state.js";
|
|
32
35
|
|
|
33
|
-
/**
|
|
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
|
+
*/
|
|
34
44
|
export component Switch(
|
|
35
45
|
checked?: boolean,
|
|
36
46
|
defaultChecked?: boolean = false,
|
|
37
47
|
onCheckedChange?: (checked: boolean) => void,
|
|
38
48
|
disabled?: boolean = false,
|
|
39
49
|
children?: React.Node,
|
|
40
|
-
|
|
50
|
+
render?: RenderProp,
|
|
51
|
+
...rest: Rest
|
|
41
52
|
) {
|
|
42
53
|
const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
disabled={disabled}
|
|
50
|
-
onClick={composeHandlers(rest.onClick, () => {
|
|
51
|
-
if (!disabled) {
|
|
52
|
-
setOn(!on);
|
|
53
|
-
}
|
|
54
|
-
})}
|
|
55
|
-
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
56
|
-
if (disabled || (event.key !== " " && event.key !== "Enter")) {
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
// Preventing the default is not decoration. It stops `Space` scrolling
|
|
60
|
-
// the page — which is what makes a hand-written toggle feel broken even
|
|
61
|
-
// when it works — and it stops the browser's own click from arriving
|
|
62
|
-
// after this handler and toggling the switch a second time.
|
|
63
|
-
event.preventDefault();
|
|
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) {
|
|
64
60
|
setOn(!on);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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" />;
|
|
72
81
|
}
|