@uniflowed/ui 0.0.0-alpha.6 → 0.0.0-alpha.7
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/collapsible.js +147 -0
- package/combobox.js +14 -1
- package/index.js +405 -12
- package/internal/disclosure.js +97 -0
- package/internal/form-value.js +83 -0
- package/internal/merge-props.js +33 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +205 -11
- package/menu.js +29 -4
- package/navigation-menu.js +251 -0
- package/package.json +17 -4
- package/pagination.js +197 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/select.js +855 -0
- package/slider.js +405 -0
- package/table.js +479 -0
- package/tabs.js +13 -16
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
package/accordion.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// An accordion: a stack of disclosures that know about each other.
|
|
4
|
+
//
|
|
5
|
+
// The disclosure itself is `collapsible.js` and the argument for the closed
|
|
6
|
+
// panel staying in the document is `internal/disclosure.js`. What this adds is
|
|
7
|
+
// everything that follows from the sections being a *stack*, and every one of
|
|
8
|
+
// them is a thing a hand-written accordion leaves out.
|
|
9
|
+
//
|
|
10
|
+
// # The heading level is the caller's
|
|
11
|
+
//
|
|
12
|
+
// The trigger is a button inside a heading, and which heading depends entirely
|
|
13
|
+
// on where the accordion sits: inside a section titled by an `<h2>` it must be
|
|
14
|
+
// an `<h3>`, and at the top of a page it might be an `<h2>`. A component that
|
|
15
|
+
// hard-codes one produces a document outline nobody can navigate — and skimming
|
|
16
|
+
// by heading is how a screen-reader user reads a long page, so the outline is
|
|
17
|
+
// not decoration. `Dialog.Title` hard-codes `<h2>`, which is defensible for a
|
|
18
|
+
// dialog, because a dialog is a document of its own with one title in it. It is
|
|
19
|
+
// not defensible here.
|
|
20
|
+
//
|
|
21
|
+
// # The panel is a named region
|
|
22
|
+
//
|
|
23
|
+
// `role="region"` with `aria-labelledby` naming the trigger that opens it, so
|
|
24
|
+
// the panel turns up in a screen reader's list of landmarks under the name the
|
|
25
|
+
// reader just pressed. An unnamed region is a landmark that says "region" and
|
|
26
|
+
// nothing else, which is why the naming is wired here rather than left as
|
|
27
|
+
// advice — and why the trigger's id and the panel's are made in one place.
|
|
28
|
+
//
|
|
29
|
+
// # `single`, `multiple`, and the section that cannot be closed
|
|
30
|
+
//
|
|
31
|
+
// A `single` accordion keeps one section open; `multiple` lets any number be.
|
|
32
|
+
// `collapsible` asks whether the open section of a `single` accordion may be
|
|
33
|
+
// closed again, leaving nothing open. When it may not, the open section's
|
|
34
|
+
// trigger says `aria-disabled="true"` rather than `disabled`, so a reader is
|
|
35
|
+
// told "pressing this does nothing" instead of finding that a header they can
|
|
36
|
+
// see has vanished from the accessibility tree — the same distinction
|
|
37
|
+
// `menu.js` and `tabs.js` make, for the same reason.
|
|
38
|
+
//
|
|
39
|
+
// It has one consequence worth stating, because it is the opposite of every
|
|
40
|
+
// other set in this package: the arrow keys **land on** a disabled header here
|
|
41
|
+
// rather than stepping over it. `moveTo`'s `skipDisabled` is where that lives.
|
|
42
|
+
// An accordion's headers are ordinary buttons in the page's tab order — `Tab`
|
|
43
|
+
// reaches every one of them — so arrows that skipped one would disagree with
|
|
44
|
+
// `Tab` about which headers exist.
|
|
45
|
+
//
|
|
46
|
+
// # The headers are not a roving tab stop
|
|
47
|
+
//
|
|
48
|
+
// This is the difference between an accordion and a tab list, and it is easy to
|
|
49
|
+
// get backwards. A tab list is *one* control, so it takes one stop in the tab
|
|
50
|
+
// order and the arrows move inside it. An accordion is a stack of ordinary
|
|
51
|
+
// buttons that happen to be near each other: `Tab` reaches every header,
|
|
52
|
+
// because each one is a real control a reader might want to press. The arrow
|
|
53
|
+
// keys between headers are a convenience the practices call optional, and they
|
|
54
|
+
// are here because a long FAQ is nicer with them — but nothing about them takes
|
|
55
|
+
// a header out of the tab order.
|
|
56
|
+
//
|
|
57
|
+
// # How the sections are found
|
|
58
|
+
//
|
|
59
|
+
// By a `data-*` attribute of this package's own rather than by role, which is
|
|
60
|
+
// the exception to what `roving-focus.js`'s other sets do. There is no ARIA
|
|
61
|
+
// role for an accordion, nor for one of its headers — the pattern is built out
|
|
62
|
+
// of headings and buttons — so there is no role to ask for, and an accordion
|
|
63
|
+
// nested inside another accordion's panel still has to keep its own arrow keys
|
|
64
|
+
// to itself. That needs a name for the container and a name for the header, and
|
|
65
|
+
// this package has to be the one to give them.
|
|
66
|
+
|
|
67
|
+
"use client";
|
|
68
|
+
|
|
69
|
+
import * as React from "@uniflowed/react";
|
|
70
|
+
import {
|
|
71
|
+
createContext,
|
|
72
|
+
useCallback,
|
|
73
|
+
useContext,
|
|
74
|
+
useId,
|
|
75
|
+
useMemo,
|
|
76
|
+
useRef,
|
|
77
|
+
useState,
|
|
78
|
+
} from "@uniflowed/react";
|
|
79
|
+
|
|
80
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
81
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
82
|
+
import { moveOnKey } from "./internal/roving-focus.js";
|
|
83
|
+
import type { RovingSet } from "./internal/roving-focus.js";
|
|
84
|
+
import { usePresence, useUntilFound } from "./internal/disclosure.js";
|
|
85
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
86
|
+
|
|
87
|
+
/** Whether one section is open at a time, or any number of them. */
|
|
88
|
+
export type AccordionType = "single" | "multiple";
|
|
89
|
+
|
|
90
|
+
/** Nothing open, as one array rather than a new one per render. */
|
|
91
|
+
const NOTHING: $ReadOnlyArray<string> = [];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The headers the arrow keys run across, and what owns them.
|
|
95
|
+
*
|
|
96
|
+
* `skipDisabled` is false, which is this set's one departure from every other
|
|
97
|
+
* one here; the module header says why.
|
|
98
|
+
*/
|
|
99
|
+
const HEADERS: RovingSet = {
|
|
100
|
+
item: "[data-accordion-trigger]",
|
|
101
|
+
owner: "[data-accordion]",
|
|
102
|
+
orientation: "vertical",
|
|
103
|
+
wrap: true,
|
|
104
|
+
skipDisabled: false,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
type AccordionState = {|
|
|
108
|
+
readonly open: $ReadOnlyArray<string>,
|
|
109
|
+
readonly toggle: (value: string) => void,
|
|
110
|
+
/** Whether closing the last open section is allowed; only meaningful for `single`. */
|
|
111
|
+
readonly closable: boolean,
|
|
112
|
+
readonly type: AccordionType,
|
|
113
|
+
|};
|
|
114
|
+
|
|
115
|
+
const AccordionContext: React.Context<AccordionState | null> = createContext(null);
|
|
116
|
+
|
|
117
|
+
type AccordionItemState = {|
|
|
118
|
+
readonly triggerId: string,
|
|
119
|
+
readonly contentId: string,
|
|
120
|
+
readonly open: boolean,
|
|
121
|
+
readonly toggle: () => void,
|
|
122
|
+
/** True when this section is open and the accordion will not let it close. */
|
|
123
|
+
readonly locked: boolean,
|
|
124
|
+
readonly disabled: boolean,
|
|
125
|
+
/** Whether an `Accordion.Content` is rendered, so the trigger names one that exists. */
|
|
126
|
+
readonly present: boolean,
|
|
127
|
+
readonly registerContent: (present: boolean) => void,
|
|
128
|
+
|};
|
|
129
|
+
|
|
130
|
+
const AccordionItemContext: React.Context<AccordionItemState | null> = createContext(null);
|
|
131
|
+
|
|
132
|
+
hook useAccordion(part: string): AccordionState {
|
|
133
|
+
const state = useContext(AccordionContext);
|
|
134
|
+
if (state == null) {
|
|
135
|
+
throw new Error(`${part} must be rendered inside an Accordion.Root`);
|
|
136
|
+
}
|
|
137
|
+
return state;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
hook useAccordionItem(part: string): AccordionItemState {
|
|
141
|
+
const state = useContext(AccordionItemContext);
|
|
142
|
+
if (state == null) {
|
|
143
|
+
throw new Error(`${part} must be rendered inside an Accordion.Item`);
|
|
144
|
+
}
|
|
145
|
+
return state;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The stack, and the one place the arrow keys are handled.
|
|
150
|
+
*
|
|
151
|
+
* `value` is the list of open sections in both modes, for the reason
|
|
152
|
+
* `toggle-group.js` gives at greater length: in `single` mode it holds at most
|
|
153
|
+
* one, and keeping that invariant is the component's job rather than the
|
|
154
|
+
* caller's to remember.
|
|
155
|
+
*/
|
|
156
|
+
export component AccordionRoot(
|
|
157
|
+
children: renders* AccordionItem,
|
|
158
|
+
type?: AccordionType = "single",
|
|
159
|
+
collapsible?: boolean = true,
|
|
160
|
+
defaultValue?: $ReadOnlyArray<string> = NOTHING,
|
|
161
|
+
value?: $ReadOnlyArray<string>,
|
|
162
|
+
onValueChange?: (value: $ReadOnlyArray<string>) => void,
|
|
163
|
+
...rest: Rest
|
|
164
|
+
) {
|
|
165
|
+
const [open, setOpen] = useControlled<$ReadOnlyArray<string>>(value, defaultValue, onValueChange);
|
|
166
|
+
|
|
167
|
+
const toggle = useCallback(
|
|
168
|
+
(item: string) => {
|
|
169
|
+
const isOpen = open.includes(item);
|
|
170
|
+
if (type === "multiple") {
|
|
171
|
+
setOpen(isOpen ? open.filter((each) => each !== item) : [...open, item]);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// Opening one closes the other, which is the whole of `single`. Closing
|
|
175
|
+
// the open one is a separate question, and `collapsible` answers it.
|
|
176
|
+
if (isOpen && !collapsible) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
setOpen(isOpen ? NOTHING : [item]);
|
|
180
|
+
},
|
|
181
|
+
[open, setOpen, type, collapsible],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const state = useMemo(
|
|
185
|
+
// `collapsible` only ever narrows a `single` accordion: in `multiple` mode
|
|
186
|
+
// every section closes on its own, and there is no last one to protect.
|
|
187
|
+
() => ({ open, toggle, closable: type === "multiple" || collapsible, type }),
|
|
188
|
+
[open, toggle, type, collapsible],
|
|
189
|
+
);
|
|
190
|
+
const passed = withoutComposed(rest, ["onKeyDown"]);
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<AccordionContext.Provider value={state}>
|
|
194
|
+
<div
|
|
195
|
+
{...passed}
|
|
196
|
+
// The name the arrow keys use to tell this accordion's headers from
|
|
197
|
+
// those of an accordion nested inside one of its panels.
|
|
198
|
+
data-accordion=""
|
|
199
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
200
|
+
const stack: $FlowFixMe = event.currentTarget;
|
|
201
|
+
moveOnKey(event, stack, HEADERS);
|
|
202
|
+
})}
|
|
203
|
+
>
|
|
204
|
+
{children}
|
|
205
|
+
</div>
|
|
206
|
+
</AccordionContext.Provider>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* One section: a header and the panel it shows.
|
|
212
|
+
*
|
|
213
|
+
* Renders a `<div>` because a section needs an element to be styled as one, and
|
|
214
|
+
* because the heading and the panel have to be siblings rather than nested —
|
|
215
|
+
* a panel inside its own heading would be part of the heading's accessible
|
|
216
|
+
* name.
|
|
217
|
+
*/
|
|
218
|
+
export component AccordionItem(
|
|
219
|
+
value: string,
|
|
220
|
+
children: renders* (AccordionHeader | AccordionContent),
|
|
221
|
+
disabled?: boolean = false,
|
|
222
|
+
...rest: Rest
|
|
223
|
+
) {
|
|
224
|
+
const accordion = useAccordion("Accordion.Item");
|
|
225
|
+
const base = useId();
|
|
226
|
+
const [present, setPresent] = useState(false);
|
|
227
|
+
const open = accordion.open.includes(value);
|
|
228
|
+
const toggle = accordion.toggle;
|
|
229
|
+
|
|
230
|
+
const state = useMemo(
|
|
231
|
+
() => ({
|
|
232
|
+
triggerId: `${base}-trigger`,
|
|
233
|
+
contentId: `${base}-content`,
|
|
234
|
+
open,
|
|
235
|
+
toggle: () => toggle(value),
|
|
236
|
+
locked: open && !accordion.closable,
|
|
237
|
+
disabled,
|
|
238
|
+
present,
|
|
239
|
+
registerContent: setPresent,
|
|
240
|
+
}),
|
|
241
|
+
[base, open, toggle, value, accordion.closable, disabled, present],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<AccordionItemContext.Provider value={state}>
|
|
246
|
+
<div {...rest}>{children}</div>
|
|
247
|
+
</AccordionItemContext.Provider>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The heading the trigger lives in.
|
|
253
|
+
*
|
|
254
|
+
* `level` is the caller's and has no sensible default beyond a guess, so the
|
|
255
|
+
* guess is stated: `3`, which is right for an accordion inside a section that
|
|
256
|
+
* has a title of its own. HTML has six levels and `<h7>` is not an element, so
|
|
257
|
+
* a level outside that range is clamped rather than rendered — a tag nobody
|
|
258
|
+
* recognises is announced as nothing at all, which loses the heading entirely.
|
|
259
|
+
*/
|
|
260
|
+
export component AccordionHeader(
|
|
261
|
+
children: renders AccordionTrigger,
|
|
262
|
+
level?: number = 3,
|
|
263
|
+
...rest: Rest
|
|
264
|
+
) {
|
|
265
|
+
const clamped = Math.min(6, Math.max(1, Math.trunc(level)));
|
|
266
|
+
const Heading = `h${String(clamped)}`;
|
|
267
|
+
|
|
268
|
+
return <Heading {...rest}>{children}</Heading>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The button that opens and closes the section.
|
|
273
|
+
*
|
|
274
|
+
* It carries no `tabIndex` of its own on purpose: every header stays in the
|
|
275
|
+
* page's tab order, which is what makes this an accordion and not a tab list.
|
|
276
|
+
*/
|
|
277
|
+
export component AccordionTrigger(children: React.Node, ...rest: Rest) {
|
|
278
|
+
const item = useAccordionItem("Accordion.Trigger");
|
|
279
|
+
const passed = withoutComposed(rest, ["onClick"]);
|
|
280
|
+
// Locked and disabled are two different sentences a reader hears the same
|
|
281
|
+
// way, and both are `aria-disabled` rather than `disabled` so the header
|
|
282
|
+
// stays where they can find it: "this section will not close" and "this
|
|
283
|
+
// section is unavailable".
|
|
284
|
+
const inert = item.locked || item.disabled;
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<button
|
|
288
|
+
{...passed}
|
|
289
|
+
aria-controls={item.present ? item.contentId : undefined}
|
|
290
|
+
aria-disabled={inert ? "true" : undefined}
|
|
291
|
+
aria-expanded={item.open ? "true" : "false"}
|
|
292
|
+
// What the arrow keys look for. Not a role, because the accordion pattern
|
|
293
|
+
// has none to look for; see the module header.
|
|
294
|
+
data-accordion-trigger=""
|
|
295
|
+
id={item.triggerId}
|
|
296
|
+
onClick={composeHandlers(rest.onClick, () => {
|
|
297
|
+
if (!inert) {
|
|
298
|
+
item.toggle();
|
|
299
|
+
}
|
|
300
|
+
})}
|
|
301
|
+
type="button"
|
|
302
|
+
>
|
|
303
|
+
{children}
|
|
304
|
+
</button>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The panel, which is a named region and stays in the document while closed.
|
|
310
|
+
*
|
|
311
|
+
* `internal/disclosure.js` explains what "stays in the document" is worth and
|
|
312
|
+
* what `hidden` is upgraded to for it.
|
|
313
|
+
*/
|
|
314
|
+
export component AccordionContent(children: React.Node, ...rest: Rest) {
|
|
315
|
+
const item = useAccordionItem("Accordion.Content");
|
|
316
|
+
const contentRef = useRef<HTMLElement | null>(null);
|
|
317
|
+
usePresence(item.registerContent);
|
|
318
|
+
useUntilFound(contentRef, item.open);
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<div
|
|
322
|
+
{...withoutComposed(rest, ["ref"])}
|
|
323
|
+
// The name a reader hears for this landmark is the header they pressed.
|
|
324
|
+
aria-labelledby={item.triggerId}
|
|
325
|
+
hidden={!item.open}
|
|
326
|
+
id={item.contentId}
|
|
327
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
328
|
+
contentRef.current = element;
|
|
329
|
+
})}
|
|
330
|
+
role="region"
|
|
331
|
+
>
|
|
332
|
+
{children}
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
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
|
+
}
|
package/combobox.js
CHANGED
|
@@ -68,6 +68,7 @@ import type { Rest } from "./internal/merge-props.js";
|
|
|
68
68
|
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
69
69
|
import { itemsOf, moveTo } from "./internal/roving-focus.js";
|
|
70
70
|
import { useControlled } from "./internal/controlled-state.js";
|
|
71
|
+
import { FormValue } from "./internal/form-value.js";
|
|
71
72
|
|
|
72
73
|
const OPTION_SELECTOR = '[role="option"]';
|
|
73
74
|
const LISTBOX_SELECTOR = '[role="listbox"]';
|
|
@@ -123,6 +124,14 @@ hook useCombobox(part: string): ComboboxState {
|
|
|
123
124
|
* `open` is whether the list is showing. A search box owns the text and nothing
|
|
124
125
|
* else; a form field owns the value; a page with a "browse all" button owns
|
|
125
126
|
* `open`. Tying them together would make two of those three impossible.
|
|
127
|
+
*
|
|
128
|
+
* `name` is what a form submits, and it exists because that same distinction
|
|
129
|
+
* had a hole in it. `Combobox.Input` renders the *text* — the label the reader
|
|
130
|
+
* sees — so a combobox named `country` inside a `<form>` submitted "United
|
|
131
|
+
* Kingdom" where the application meant `GB`, silently and only in production.
|
|
132
|
+
* Given a `name`, the root renders a hidden control carrying `value` instead;
|
|
133
|
+
* `internal/form-value.js` says why it is an `<input>` and why
|
|
134
|
+
* `@uniflowed/form` does not need it.
|
|
126
135
|
*/
|
|
127
136
|
export component ComboboxRoot(
|
|
128
137
|
children: React.Node,
|
|
@@ -135,6 +144,7 @@ export component ComboboxRoot(
|
|
|
135
144
|
open?: boolean,
|
|
136
145
|
defaultOpen?: boolean = false,
|
|
137
146
|
onOpenChange?: (open: boolean) => void,
|
|
147
|
+
name?: string,
|
|
138
148
|
...rest: Rest
|
|
139
149
|
) {
|
|
140
150
|
const base = useId();
|
|
@@ -191,7 +201,10 @@ export component ComboboxRoot(
|
|
|
191
201
|
|
|
192
202
|
return (
|
|
193
203
|
<ComboboxContext.Provider value={state}>
|
|
194
|
-
<div {...rest}>
|
|
204
|
+
<div {...rest}>
|
|
205
|
+
{children}
|
|
206
|
+
{name == null ? null : <FormValue name={name} value={chosen} />}
|
|
207
|
+
</div>
|
|
195
208
|
</ComboboxContext.Provider>
|
|
196
209
|
);
|
|
197
210
|
}
|