@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/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
+ }