@uniflowed/ui 0.0.0-alpha.5 → 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 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}>{children}</div>
204
+ <div {...rest}>
205
+ {children}
206
+ {name == null ? null : <FormValue name={name} value={chosen} />}
207
+ </div>
195
208
  </ComboboxContext.Provider>
196
209
  );
197
210
  }