@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/carousel.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A carousel: content that moves, which is the one thing on a page a
|
|
4
|
+
// specification tells you to let people stop.
|
|
5
|
+
//
|
|
6
|
+
// # What it gives that a list does not
|
|
7
|
+
//
|
|
8
|
+
// Nothing, for a reader who can see it. A carousel is a list of things shown
|
|
9
|
+
// one at a time, and the plain HTML it replaces — a list — is better at every
|
|
10
|
+
// job except fitting in a small space. So the whole of this module is the part
|
|
11
|
+
// that keeps the replacement from being worse than the list:
|
|
12
|
+
//
|
|
13
|
+
// * **It can be stopped.** WCAG 2.2.2, *Pause, Stop, Hide*: anything that
|
|
14
|
+
// starts automatically, moves, and lasts more than five seconds needs a
|
|
15
|
+
// mechanism to pause it. `Carousel.Pause` is that mechanism, and it must be
|
|
16
|
+
// the **first** focusable thing inside the carousel — a pause button after
|
|
17
|
+
// the slides is a pause button nobody reaches in time. That is enforced
|
|
18
|
+
// here rather than suggested: an autoplaying carousel whose first focus
|
|
19
|
+
// stop is not the pause control raises.
|
|
20
|
+
// * **It says what it is.** `aria-roledescription="carousel"` on a named
|
|
21
|
+
// group, and `aria-roledescription="slide"` with "3 of 7" on each slide.
|
|
22
|
+
// Without them a reader is told "group, group" and has no way to know
|
|
23
|
+
// where they are or how much of it there is.
|
|
24
|
+
// * **It stops announcing itself while it moves.** The slide container is
|
|
25
|
+
// `aria-live="off"` while it is rotating and `"polite"` while it is not.
|
|
26
|
+
// A live region that reads out every slide of an auto-rotating carousel is
|
|
27
|
+
// unusable, and one that never announces anything makes the Next button
|
|
28
|
+
// silent.
|
|
29
|
+
// * **`Tab` cannot walk into a slide nobody can see.** This is the bug that
|
|
30
|
+
// survives every other fix. The slides that are scrolled out of view are
|
|
31
|
+
// still in the DOM, so their links and buttons are still focus stops — and
|
|
32
|
+
// a reader who tabs into one is in content the page is not showing. They
|
|
33
|
+
// are `inert`, which takes them out of the tab order *and* out of the
|
|
34
|
+
// accessibility tree, and which `internal/focus.js` already skips.
|
|
35
|
+
// * **It respects `prefers-reduced-motion`.** A reader who asked their system
|
|
36
|
+
// to stop moving things gets a carousel that does not rotate on its own.
|
|
37
|
+
// `usePrefersReducedMotion` from `@uniflowed/hooks/browser`.
|
|
38
|
+
//
|
|
39
|
+
// Rotation also stops while the pointer is over it and while focus is inside
|
|
40
|
+
// it — a reader in the middle of reading a slide should not have it taken away
|
|
41
|
+
// — and once `Carousel.Pause` has been pressed it stays stopped, because that
|
|
42
|
+
// was a decision rather than a hover.
|
|
43
|
+
//
|
|
44
|
+
// # Why the caller counts the slides
|
|
45
|
+
//
|
|
46
|
+
// `Carousel.Root` takes `count` and `Carousel.Item` takes `index`, the same way
|
|
47
|
+
// `Table.Root` takes `rowCount` and `Table.Row` takes `index`. The alternative
|
|
48
|
+
// — counting the children — is wrong the first time a caller renders a slide
|
|
49
|
+
// conditionally, filters a list, or wraps one in a component of their own, and
|
|
50
|
+
// it is wrong silently: the label says "3 of 6" in a carousel with seven
|
|
51
|
+
// slides, which is exactly the sentence a reader is relying on.
|
|
52
|
+
//
|
|
53
|
+
// # It is a `group`, not a `region`
|
|
54
|
+
//
|
|
55
|
+
// A `region` is a landmark, and a landmark is a promise that this is one of the
|
|
56
|
+
// handful of places worth jumping to on the page. A gallery of photographs
|
|
57
|
+
// three screens down is not, and a page with four carousels in it would put
|
|
58
|
+
// four entries in a reader's landmark list. `role="group"` says the same thing
|
|
59
|
+
// about the relationship between the slides without making that claim; a caller
|
|
60
|
+
// whose carousel *is* the page can pass `role="region"` and get it.
|
|
61
|
+
|
|
62
|
+
"use client";
|
|
63
|
+
|
|
64
|
+
import * as React from "@uniflowed/react";
|
|
65
|
+
import {
|
|
66
|
+
createContext,
|
|
67
|
+
useCallback,
|
|
68
|
+
useContext,
|
|
69
|
+
useEffect,
|
|
70
|
+
useId,
|
|
71
|
+
useMemo,
|
|
72
|
+
useRef,
|
|
73
|
+
useState,
|
|
74
|
+
} from "@uniflowed/react";
|
|
75
|
+
import { usePrefersReducedMotion } from "@uniflowed/hooks/browser";
|
|
76
|
+
|
|
77
|
+
import type { Orientation } from "./internal/roving-focus.js";
|
|
78
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
79
|
+
import {
|
|
80
|
+
composeHandlers,
|
|
81
|
+
composeRefs,
|
|
82
|
+
forwarded,
|
|
83
|
+
withoutComposed,
|
|
84
|
+
} from "./internal/merge-props.js";
|
|
85
|
+
import { focusable } from "./internal/focus.js";
|
|
86
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
87
|
+
|
|
88
|
+
export type { Orientation } from "./internal/roving-focus.js";
|
|
89
|
+
|
|
90
|
+
type CarouselState = {|
|
|
91
|
+
readonly base: string,
|
|
92
|
+
readonly count: number,
|
|
93
|
+
readonly index: number,
|
|
94
|
+
readonly setIndex: (next: number) => void,
|
|
95
|
+
readonly loop: boolean,
|
|
96
|
+
readonly orientation: Orientation,
|
|
97
|
+
/** Whether it is rotating right now, which is what `aria-live` reads. */
|
|
98
|
+
readonly rotating: boolean,
|
|
99
|
+
/** Whether the reader stopped it on purpose, which nothing but they undo. */
|
|
100
|
+
readonly stopped: boolean,
|
|
101
|
+
readonly setStopped: (stopped: boolean) => void,
|
|
102
|
+
/** Whether a rotation was ever asked for, so `Carousel.Pause` can say so. */
|
|
103
|
+
readonly rotates: boolean,
|
|
104
|
+
readonly registerPause: (present: boolean) => void,
|
|
105
|
+
|};
|
|
106
|
+
|
|
107
|
+
const CarouselContext: React.Context<CarouselState | null> = createContext(null);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The carousel a part belongs to.
|
|
111
|
+
*
|
|
112
|
+
* Raising rather than returning null, for the reason `useDialog` gives: a
|
|
113
|
+
* `Carousel.Item` outside a root would render a slide labelled "1 of 0".
|
|
114
|
+
*/
|
|
115
|
+
hook useCarousel(part: string): CarouselState {
|
|
116
|
+
const state = useContext(CarouselContext);
|
|
117
|
+
if (state == null) {
|
|
118
|
+
throw new Error(`${part} must be rendered inside a Carousel.Root`);
|
|
119
|
+
}
|
|
120
|
+
return state;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The carousel: a named group of slides, one of them showing.
|
|
125
|
+
*
|
|
126
|
+
* `autoplay` is how many milliseconds each slide is shown for, or `null` for a
|
|
127
|
+
* carousel that only moves when it is asked to. A carousel that rotates must
|
|
128
|
+
* hold a `Carousel.Pause`, and that one must be the first thing `Tab` reaches
|
|
129
|
+
* inside it; both are checked.
|
|
130
|
+
*/
|
|
131
|
+
export component CarouselRoot(
|
|
132
|
+
children: React.Node,
|
|
133
|
+
autoplay?: number | null = null,
|
|
134
|
+
count: number,
|
|
135
|
+
defaultIndex?: number = 0,
|
|
136
|
+
index?: number,
|
|
137
|
+
label: string,
|
|
138
|
+
loop?: boolean = true,
|
|
139
|
+
onIndexChange?: (index: number) => void,
|
|
140
|
+
orientation?: Orientation = "horizontal",
|
|
141
|
+
...rest: Rest
|
|
142
|
+
) {
|
|
143
|
+
const base = useId();
|
|
144
|
+
const [current, setIndex] = useControlled(index, defaultIndex, onIndexChange);
|
|
145
|
+
const rootRef = useRef<HTMLElement | null>(null);
|
|
146
|
+
const paused = useRef(0);
|
|
147
|
+
const [stopped, setStopped] = useState(false);
|
|
148
|
+
// Whether the pointer or focus is resting on it. State rather than a ref,
|
|
149
|
+
// because the timer below is an effect and has to be torn down when it
|
|
150
|
+
// changes.
|
|
151
|
+
const [held, setHeld] = useState(false);
|
|
152
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
153
|
+
const passed = withoutComposed(rest, [
|
|
154
|
+
"onBlur",
|
|
155
|
+
"onFocus",
|
|
156
|
+
"onPointerEnter",
|
|
157
|
+
"onPointerLeave",
|
|
158
|
+
"ref",
|
|
159
|
+
]);
|
|
160
|
+
// Stable, so `Carousel.Pause`'s registration effect runs once rather than
|
|
161
|
+
// once per render of the root — which would decrement and re-increment the
|
|
162
|
+
// count, and leave it at zero for exactly as long as it takes the check
|
|
163
|
+
// below to read it.
|
|
164
|
+
const registerPause = useCallback((present: boolean) => {
|
|
165
|
+
paused.current += present ? 1 : -1;
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
// A reader who asked their system to stop moving things has answered this
|
|
169
|
+
// question already, and the answer is not "rotate anyway and offer a button".
|
|
170
|
+
const rotates = autoplay != null && !reducedMotion;
|
|
171
|
+
const rotating = rotates && !stopped && !held;
|
|
172
|
+
|
|
173
|
+
const state = useMemo(
|
|
174
|
+
() => ({
|
|
175
|
+
base,
|
|
176
|
+
count,
|
|
177
|
+
index: current,
|
|
178
|
+
loop,
|
|
179
|
+
orientation,
|
|
180
|
+
registerPause,
|
|
181
|
+
rotates,
|
|
182
|
+
rotating,
|
|
183
|
+
setIndex,
|
|
184
|
+
setStopped,
|
|
185
|
+
stopped,
|
|
186
|
+
}),
|
|
187
|
+
[base, count, current, loop, orientation, registerPause, rotates, rotating, setIndex, stopped],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (!rotating || count <= 1) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// The global timer rather than the document's, the same as
|
|
195
|
+
// `internal/hover-intent.js`: one clock for the package, and the one a
|
|
196
|
+
// caller's fake timers replace.
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
setIndex(current === count - 1 ? 0 : current + 1);
|
|
199
|
+
}, autoplay ?? 0);
|
|
200
|
+
return () => {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
};
|
|
203
|
+
// `current` is named, so each slide's turn is timed from the moment it
|
|
204
|
+
// arrived rather than from a repeating interval that keeps running while
|
|
205
|
+
// the reader presses Next.
|
|
206
|
+
}, [autoplay, count, current, rotating, setIndex]);
|
|
207
|
+
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
const root = rootRef.current;
|
|
210
|
+
if (!rotates || root == null) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (paused.current === 0) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
"A Carousel.Root with autoplay must hold a Carousel.Pause: WCAG 2.2.2 " +
|
|
216
|
+
"requires a mechanism to stop anything that moves by itself for more " +
|
|
217
|
+
"than five seconds.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const first = focusable(root)[0];
|
|
221
|
+
if (first == null || first.getAttribute("data-uf-carousel-pause") == null) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
"Carousel.Pause must be the first focusable element inside " +
|
|
224
|
+
"Carousel.Root: a pause control the reader reaches after the slides " +
|
|
225
|
+
"is one they reach after the thing they wanted to stop.",
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}, [rotates]);
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<CarouselContext.Provider value={state}>
|
|
232
|
+
<div
|
|
233
|
+
{...passed}
|
|
234
|
+
aria-label={label}
|
|
235
|
+
// What the reader is told instead of "group". Everything else here is
|
|
236
|
+
// arrangement; this is the sentence.
|
|
237
|
+
aria-roledescription="carousel"
|
|
238
|
+
data-orientation={orientation}
|
|
239
|
+
onBlur={composeHandlers(rest.onBlur, (event: $FlowFixMe) => {
|
|
240
|
+
if (!event.currentTarget?.contains?.(event.relatedTarget)) {
|
|
241
|
+
setHeld(false);
|
|
242
|
+
}
|
|
243
|
+
})}
|
|
244
|
+
// Rotation stops while a reader is in it and starts again when they
|
|
245
|
+
// leave — unless they stopped it deliberately, which `stopped` keeps.
|
|
246
|
+
onFocus={composeHandlers(rest.onFocus, () => setHeld(true))}
|
|
247
|
+
onPointerEnter={composeHandlers(rest.onPointerEnter, () => setHeld(true))}
|
|
248
|
+
onPointerLeave={composeHandlers(rest.onPointerLeave, () => setHeld(false))}
|
|
249
|
+
ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
250
|
+
rootRef.current = element;
|
|
251
|
+
})}
|
|
252
|
+
role="group"
|
|
253
|
+
>
|
|
254
|
+
{children}
|
|
255
|
+
</div>
|
|
256
|
+
</CarouselContext.Provider>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The slides, and the live region that says which one is showing.
|
|
262
|
+
*
|
|
263
|
+
* `aria-live="off"` while it rotates: a live region reading out a slide every
|
|
264
|
+
* four seconds is a page a screen reader cannot be used on. `"polite"` the rest
|
|
265
|
+
* of the time, so pressing Next says something.
|
|
266
|
+
*/
|
|
267
|
+
export component CarouselContent(children: React.Node, ...rest: Rest) {
|
|
268
|
+
const carousel = useCarousel("Carousel.Content");
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<div
|
|
272
|
+
{...rest}
|
|
273
|
+
aria-live={carousel.rotating ? "off" : "polite"}
|
|
274
|
+
data-orientation={carousel.orientation}
|
|
275
|
+
id={`${carousel.base}-content`}
|
|
276
|
+
>
|
|
277
|
+
{children}
|
|
278
|
+
</div>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* One slide, which says where in the set it is and gets out of the way when it
|
|
284
|
+
* is not the one showing.
|
|
285
|
+
*
|
|
286
|
+
* `inert` rather than a class: the slides that are not showing are still in the
|
|
287
|
+
* document, and without it `Tab` walks into a link nobody can see. It also
|
|
288
|
+
* takes the subtree out of the accessibility tree, which is what stops a reader
|
|
289
|
+
* being read six slides in a row.
|
|
290
|
+
*/
|
|
291
|
+
export component CarouselItem(children: React.Node, index: number, ...rest: Rest) {
|
|
292
|
+
const carousel = useCarousel("Carousel.Item");
|
|
293
|
+
const current = index === carousel.index;
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div
|
|
297
|
+
{...rest}
|
|
298
|
+
// "3 of 7", which is the only way a reader knows where they are. A caller
|
|
299
|
+
// who has a better name for the slide keeps it.
|
|
300
|
+
aria-label={
|
|
301
|
+
rest["aria-label"] == null && rest["aria-labelledby"] == null
|
|
302
|
+
? `${String(index + 1)} of ${String(carousel.count)}`
|
|
303
|
+
: undefined
|
|
304
|
+
}
|
|
305
|
+
aria-roledescription="slide"
|
|
306
|
+
data-state={current ? "active" : "inactive"}
|
|
307
|
+
// React renders `inert` from a boolean, and `undefined` removes it.
|
|
308
|
+
inert={current ? undefined : true}
|
|
309
|
+
role="group"
|
|
310
|
+
>
|
|
311
|
+
{children}
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The control WCAG 2.2.2 is about, and the first thing `Tab` reaches.
|
|
318
|
+
*
|
|
319
|
+
* It says which state pressing it produces, which is what a toggle button is
|
|
320
|
+
* for: `aria-pressed` on a pause button is the announcement "pause, pressed",
|
|
321
|
+
* and a reader who has stopped a carousel wants to be told it is stopped.
|
|
322
|
+
*/
|
|
323
|
+
export component CarouselPause(
|
|
324
|
+
children?: React.Node,
|
|
325
|
+
pauseLabel?: string = "Stop the carousel",
|
|
326
|
+
playLabel?: string = "Start the carousel",
|
|
327
|
+
...rest: Rest
|
|
328
|
+
) {
|
|
329
|
+
const carousel = useCarousel("Carousel.Pause");
|
|
330
|
+
const register = carousel.registerPause;
|
|
331
|
+
const passed = withoutComposed(rest, ["onClick"]);
|
|
332
|
+
const named = rest["aria-label"] != null || rest["aria-labelledby"] != null;
|
|
333
|
+
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
register(true);
|
|
336
|
+
return () => register(false);
|
|
337
|
+
}, [register]);
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<button
|
|
341
|
+
{...passed}
|
|
342
|
+
aria-controls={`${carousel.base}-content`}
|
|
343
|
+
aria-label={named ? undefined : carousel.stopped ? playLabel : pauseLabel}
|
|
344
|
+
aria-pressed={carousel.stopped ? "true" : "false"}
|
|
345
|
+
// How `Carousel.Root` recognises this button as the pause control without
|
|
346
|
+
// reaching into React's tree, which it has no way to do from an effect.
|
|
347
|
+
data-uf-carousel-pause=""
|
|
348
|
+
onClick={composeHandlers(rest.onClick, () => carousel.setStopped(!carousel.stopped))}
|
|
349
|
+
type="button"
|
|
350
|
+
>
|
|
351
|
+
{children}
|
|
352
|
+
</button>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The button that goes back one slide. */
|
|
357
|
+
export component CarouselPrevious(
|
|
358
|
+
children?: React.Node,
|
|
359
|
+
label?: string = "Previous slide",
|
|
360
|
+
...rest: Rest
|
|
361
|
+
) {
|
|
362
|
+
return (
|
|
363
|
+
<CarouselStep {...forwarded(rest)} label={label} step={-1}>
|
|
364
|
+
{children}
|
|
365
|
+
</CarouselStep>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The button that goes forward one slide. */
|
|
370
|
+
export component CarouselNext(children?: React.Node, label?: string = "Next slide", ...rest: Rest) {
|
|
371
|
+
return (
|
|
372
|
+
<CarouselStep {...forwarded(rest)} label={label} step={1}>
|
|
373
|
+
{children}
|
|
374
|
+
</CarouselStep>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Both of the stepping buttons.
|
|
380
|
+
*
|
|
381
|
+
* One component because the difference is a sign and a name, and two copies of
|
|
382
|
+
* the wrapping arithmetic is how a carousel comes to loop in one direction and
|
|
383
|
+
* stop in the other.
|
|
384
|
+
*/
|
|
385
|
+
component CarouselStep(children?: React.Node, label: string, step: number, ...rest: Rest) {
|
|
386
|
+
const carousel = useCarousel(step < 0 ? "Carousel.Previous" : "Carousel.Next");
|
|
387
|
+
const passed = withoutComposed(rest, ["onClick"]);
|
|
388
|
+
const last = carousel.count - 1;
|
|
389
|
+
const at = step < 0 ? 0 : last;
|
|
390
|
+
const wrapped = step < 0 ? last : 0;
|
|
391
|
+
const ends = carousel.index === at;
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
<button
|
|
395
|
+
{...passed}
|
|
396
|
+
aria-controls={`${carousel.base}-content`}
|
|
397
|
+
aria-label={rest["aria-label"] == null ? label : undefined}
|
|
398
|
+
// Disabled at the end of a carousel that does not loop, because a button
|
|
399
|
+
// that does nothing is a button a reader presses twice before believing
|
|
400
|
+
// it.
|
|
401
|
+
disabled={!carousel.loop && ends}
|
|
402
|
+
onClick={composeHandlers(rest.onClick, () => {
|
|
403
|
+
carousel.setIndex(ends ? wrapped : carousel.index + step);
|
|
404
|
+
})}
|
|
405
|
+
type="button"
|
|
406
|
+
>
|
|
407
|
+
{children}
|
|
408
|
+
</button>
|
|
409
|
+
);
|
|
410
|
+
}
|
package/checkbox.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A checkbox that is not an `<input>`, with the third state a checkbox has.
|
|
4
|
+
//
|
|
5
|
+
// A styled checkbox is almost always a `div` with a tick drawn in it, and the
|
|
6
|
+
// moment it stops being a real control it stops being announced, stops toggling
|
|
7
|
+
// on `Space`, and stops being reachable by `Tab`. This keeps all three while
|
|
8
|
+
// shipping no styles.
|
|
9
|
+
//
|
|
10
|
+
// # The third state is the reason this is not `switch.js`
|
|
11
|
+
//
|
|
12
|
+
// A checkbox has three states — checked, unchecked and *mixed* — and a switch
|
|
13
|
+
// has two. `aria-checked="mixed"` is what a "select all" box says when some of
|
|
14
|
+
// its rows are selected, and there is no way to express it with a switch, which
|
|
15
|
+
// is why this is a component of its own rather than `switch.js` with a
|
|
16
|
+
// different `role`.
|
|
17
|
+
//
|
|
18
|
+
// Mixed is the caller's to own. A control cannot decide on its own that it is
|
|
19
|
+
// no longer partly selected — that is a fact about the rows it summarises — so
|
|
20
|
+
// `indeterminate` is a prop, and clicking a mixed checkbox reports `true`,
|
|
21
|
+
// which is the state a reader expects "select all" to move to.
|
|
22
|
+
//
|
|
23
|
+
// # `Enter` is deliberately not handled
|
|
24
|
+
//
|
|
25
|
+
// `Space` toggles; `Enter` is left alone, so a checkbox inside a form still
|
|
26
|
+
// submits it. That is the difference between a control that answers a question
|
|
27
|
+
// and one that operates a thing — `switch.js` takes `Enter` because a switch is
|
|
28
|
+
// the second kind.
|
|
29
|
+
|
|
30
|
+
"use client";
|
|
31
|
+
|
|
32
|
+
import * as React from "@uniflowed/react";
|
|
33
|
+
|
|
34
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
35
|
+
import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
|
|
36
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
37
|
+
|
|
38
|
+
/** A checkbox, which may also be mixed. */
|
|
39
|
+
export component Checkbox(
|
|
40
|
+
checked?: boolean,
|
|
41
|
+
defaultChecked?: boolean = false,
|
|
42
|
+
indeterminate?: boolean = false,
|
|
43
|
+
onCheckedChange?: (checked: boolean) => void,
|
|
44
|
+
disabled?: boolean = false,
|
|
45
|
+
children?: React.Node,
|
|
46
|
+
...rest: Rest
|
|
47
|
+
) {
|
|
48
|
+
const [on, setOn] = useControlled(checked, defaultChecked, onCheckedChange);
|
|
49
|
+
// A mixed checkbox moves to checked, not to "the opposite of the boolean
|
|
50
|
+
// underneath it": a half-selected "select all" that clears itself on the
|
|
51
|
+
// first click is the behaviour every table in every application gets wrong.
|
|
52
|
+
const next = indeterminate ? true : !on;
|
|
53
|
+
const passed = withoutComposed(rest, ["onClick", "onKeyDown"]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<button
|
|
57
|
+
{...passed}
|
|
58
|
+
aria-checked={indeterminate ? "mixed" : on ? "true" : "false"}
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
onClick={composeHandlers(rest.onClick, () => {
|
|
61
|
+
if (!disabled) {
|
|
62
|
+
setOn(next);
|
|
63
|
+
}
|
|
64
|
+
})}
|
|
65
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
66
|
+
if (disabled || event.key !== " ") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Stops `Space` scrolling the page, and stops the browser's own click
|
|
70
|
+
// arriving afterwards and toggling this a second time.
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
setOn(next);
|
|
73
|
+
})}
|
|
74
|
+
role="checkbox"
|
|
75
|
+
type="button"
|
|
76
|
+
>
|
|
77
|
+
{children}
|
|
78
|
+
</button>
|
|
79
|
+
);
|
|
80
|
+
}
|
package/collapsible.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A button and the region it shows: the disclosure pattern, on its own.
|
|
4
|
+
//
|
|
5
|
+
// This is the smallest component in the package and it is here because the
|
|
6
|
+
// three attributes it gets right are the three everybody leaves out.
|
|
7
|
+
// `<button onClick={() => setOpen(!open)}>` with a `{open && <div>…</div>}`
|
|
8
|
+
// after it looks finished and tells a screen reader nothing: not that the
|
|
9
|
+
// button controls anything, not whether the thing is showing, and not which
|
|
10
|
+
// region it is. A reader hears "Details, button" and has no way to know that
|
|
11
|
+
// pressing it changed the page.
|
|
12
|
+
//
|
|
13
|
+
// So: `aria-expanded` on the trigger, `aria-controls` naming the content —
|
|
14
|
+
// and only while there is content to name, because an `aria-controls` pointing
|
|
15
|
+
// at an id nothing has is a promise the component cannot keep.
|
|
16
|
+
//
|
|
17
|
+
// # The closed content stays in the document
|
|
18
|
+
//
|
|
19
|
+
// `Tabs.Panel` returns `null` when it is not selected and that is right for a
|
|
20
|
+
// tab set. Here it is wrong, and the reason is the browser's find-in-page: text
|
|
21
|
+
// in a section that is not in the document cannot be found, so a page of
|
|
22
|
+
// collapsed sections is a page a reader has to open by hand to search.
|
|
23
|
+
// `internal/disclosure.js` explains what is done instead, and why React needs a
|
|
24
|
+
// hook to say it.
|
|
25
|
+
//
|
|
26
|
+
// # No height, yet
|
|
27
|
+
//
|
|
28
|
+
// A collapsible that animates open needs the height its content *would* have,
|
|
29
|
+
// which a stylesheet cannot compute — the obvious `useElementSize` from
|
|
30
|
+
// `@uniflowed/hooks/dom` measures the element while it is hidden and reports
|
|
31
|
+
// zero, which is exactly the moment the number is wanted. Getting it right
|
|
32
|
+
// means a measuring pass with the panel briefly laid out and not painted, and
|
|
33
|
+
// that is a piece of work of its own rather than a line to be added here — #330.
|
|
34
|
+
// This component ships without it rather than with a custom property that reads
|
|
35
|
+
// `0px`.
|
|
36
|
+
|
|
37
|
+
"use client";
|
|
38
|
+
|
|
39
|
+
import * as React from "@uniflowed/react";
|
|
40
|
+
import { createContext, useContext, useId, useMemo, useRef, useState } from "@uniflowed/react";
|
|
41
|
+
|
|
42
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
43
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
44
|
+
import { usePresence, useUntilFound } from "./internal/disclosure.js";
|
|
45
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
46
|
+
|
|
47
|
+
type CollapsibleState = {|
|
|
48
|
+
readonly contentId: string,
|
|
49
|
+
readonly open: boolean,
|
|
50
|
+
readonly setOpen: (open: boolean) => void,
|
|
51
|
+
/** Whether a `Collapsible.Content` is rendered, so the trigger names one that exists. */
|
|
52
|
+
readonly present: boolean,
|
|
53
|
+
readonly registerContent: (present: boolean) => void,
|
|
54
|
+
|};
|
|
55
|
+
|
|
56
|
+
const CollapsibleContext: React.Context<CollapsibleState | null> = createContext(null);
|
|
57
|
+
|
|
58
|
+
hook useCollapsible(part: string): CollapsibleState {
|
|
59
|
+
const state = useContext(CollapsibleContext);
|
|
60
|
+
if (state == null) {
|
|
61
|
+
throw new Error(`${part} must be rendered inside a Collapsible.Root`);
|
|
62
|
+
}
|
|
63
|
+
return state;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The pair, and the state they agree about.
|
|
68
|
+
*
|
|
69
|
+
* Renders no element of its own: a trigger and its content are siblings in
|
|
70
|
+
* whatever layout the caller wrote, and a wrapper would put a `<div>` between
|
|
71
|
+
* them that the caller then has to style around. `Menu.Root` makes the same
|
|
72
|
+
* choice for the same reason.
|
|
73
|
+
*/
|
|
74
|
+
export component CollapsibleRoot(
|
|
75
|
+
children: React.Node,
|
|
76
|
+
defaultOpen?: boolean = false,
|
|
77
|
+
open?: boolean,
|
|
78
|
+
onOpenChange?: (open: boolean) => void,
|
|
79
|
+
) {
|
|
80
|
+
const contentId = `${useId()}-content`;
|
|
81
|
+
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
82
|
+
const [present, setPresent] = useState(false);
|
|
83
|
+
|
|
84
|
+
const state = useMemo(
|
|
85
|
+
() => ({ contentId, open: isOpen, setOpen, present, registerContent: setPresent }),
|
|
86
|
+
[contentId, isOpen, setOpen, present],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return <CollapsibleContext.Provider value={state}>{children}</CollapsibleContext.Provider>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The button that shows and hides the content. */
|
|
93
|
+
export component CollapsibleTrigger(
|
|
94
|
+
children: React.Node,
|
|
95
|
+
disabled?: boolean = false,
|
|
96
|
+
...rest: Rest
|
|
97
|
+
) {
|
|
98
|
+
const collapsible = useCollapsible("Collapsible.Trigger");
|
|
99
|
+
const passed = withoutComposed(rest, ["onClick"]);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<button
|
|
103
|
+
{...passed}
|
|
104
|
+
// Named only while the content is in the document. A caller who renders
|
|
105
|
+
// the content conditionally — or not at all until data arrives — would
|
|
106
|
+
// otherwise have this button pointing at nothing.
|
|
107
|
+
aria-controls={collapsible.present ? collapsible.contentId : undefined}
|
|
108
|
+
aria-expanded={collapsible.open ? "true" : "false"}
|
|
109
|
+
disabled={disabled}
|
|
110
|
+
onClick={composeHandlers(rest.onClick, () => {
|
|
111
|
+
if (!disabled) {
|
|
112
|
+
collapsible.setOpen(!collapsible.open);
|
|
113
|
+
}
|
|
114
|
+
})}
|
|
115
|
+
type="button"
|
|
116
|
+
>
|
|
117
|
+
{children}
|
|
118
|
+
</button>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The region the trigger shows.
|
|
124
|
+
*
|
|
125
|
+
* It is always rendered and `hidden` while closed, rather than removed — see
|
|
126
|
+
* the module header, and `internal/disclosure.js` for what `hidden` is upgraded
|
|
127
|
+
* to and why that takes an effect.
|
|
128
|
+
*/
|
|
129
|
+
export component CollapsibleContent(children: React.Node, ...rest: Rest) {
|
|
130
|
+
const collapsible = useCollapsible("Collapsible.Content");
|
|
131
|
+
const contentRef = useRef<HTMLElement | null>(null);
|
|
132
|
+
usePresence(collapsible.registerContent);
|
|
133
|
+
useUntilFound(contentRef, collapsible.open);
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div
|
|
137
|
+
{...withoutComposed(rest, ["ref"])}
|
|
138
|
+
hidden={!collapsible.open}
|
|
139
|
+
id={collapsible.contentId}
|
|
140
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
141
|
+
contentRef.current = element;
|
|
142
|
+
})}
|
|
143
|
+
>
|
|
144
|
+
{children}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
}
|