@uniflowed/ui 0.0.0-alpha.12 → 0.0.0-alpha.14
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 +21 -3
- package/calendar.js +550 -0
- package/checkbox.js +188 -10
- package/collapsible.js +29 -12
- package/combobox.js +176 -5
- package/context-menu.js +198 -0
- package/date-picker.js +346 -0
- package/field.js +192 -25
- package/hover-card.js +3 -3
- package/index.js +196 -10
- package/internal/anchor.js +71 -6
- package/internal/date-grid.js +260 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/menu.js +309 -163
- package/menubar.js +285 -0
- package/package.json +8 -3
- package/popover.js +21 -8
- package/resizable.js +149 -9
- package/select.js +29 -0
- package/switch.js +5 -3
- package/toggle.js +3 -2
- package/tooltip.js +3 -3
package/checkbox.js
CHANGED
|
@@ -20,12 +20,49 @@
|
|
|
20
20
|
// `indeterminate` is a prop, and clicking a mixed checkbox reports `true`,
|
|
21
21
|
// which is the state a reader expects "select all" to move to.
|
|
22
22
|
//
|
|
23
|
-
// # `Enter`
|
|
23
|
+
// # `Enter` submits the form; it does not toggle
|
|
24
24
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// the
|
|
25
|
+
// This is the difference between a control that answers a question and one that
|
|
26
|
+
// operates a thing — `switch.js` takes `Enter` because a switch is the second
|
|
27
|
+
// kind — and for a long time this header claimed it by *leaving the key alone*.
|
|
28
|
+
// Neither half of that claim survived contact with the component, which is
|
|
29
|
+
// ubugeeei-prod/uf#324:
|
|
30
|
+
//
|
|
31
|
+
// * **A `<button type="button">` never submits a form.** That is the whole of
|
|
32
|
+
// what `type="button"` means, and this renders one. So the form was not
|
|
33
|
+
// submitted by anybody.
|
|
34
|
+
// * **And leaving a key unhandled does not make it inert.** It lets the
|
|
35
|
+
// *default action* happen, and the default action of `Enter` on a focused
|
|
36
|
+
// `<button>` is a click — which this component's own `onClick` turns into a
|
|
37
|
+
// toggle. So a focused checkbox both failed to submit the form and changed
|
|
38
|
+
// its own state, which is the opposite of the intent on both counts.
|
|
39
|
+
//
|
|
40
|
+
// What a native `<input type="checkbox">` does with `Enter` is *implicit
|
|
41
|
+
// submission*: the key does not touch the checkbox, and the form around it is
|
|
42
|
+
// submitted as though its default button had been pressed. That is the
|
|
43
|
+
// behaviour this replaces, so it is the behaviour it owes, and it is written
|
|
44
|
+
// out here because a styled substitute that silently drops it is exactly the
|
|
45
|
+
// kind of regression `index.js` says this package exists to prevent.
|
|
46
|
+
//
|
|
47
|
+
// So `Enter` is claimed — `preventDefault()`, which is what stops the browser's
|
|
48
|
+
// click and the toggle behind it — and turned back into a submission of the
|
|
49
|
+
// `<form>` the control is in. Outside a form it does nothing at all, which is
|
|
50
|
+
// again what the native control does. `requestSubmit` rather than `submit`
|
|
51
|
+
// because only the first fires the `submit` event and runs constraint
|
|
52
|
+
// validation, and a `<form onSubmit>` that never heard about the submission is
|
|
53
|
+
// the failure this would otherwise trade for the old one.
|
|
54
|
+
//
|
|
55
|
+
// The default button is passed to it rather than left out. `requestSubmit()`
|
|
56
|
+
// with no argument submits with *no* submitter, so a form whose handler reads
|
|
57
|
+
// `event.submitter` — a Server Action's `formAction`, a "save" and a "save and
|
|
58
|
+
// close" beside each other — would be told nobody pressed anything. Implicit
|
|
59
|
+
// submission names the default button, so this does too.
|
|
60
|
+
//
|
|
61
|
+
// Which button that is, and whether a form with no button submits at all, are
|
|
62
|
+
// both the specification's questions rather than this component's, and both are
|
|
63
|
+
// answered below: a submit button belongs to the form that *owns* it rather
|
|
64
|
+
// than to the form it sits inside, and a form with no submit button submits
|
|
65
|
+
// itself only while at most one of its fields blocks implicit submission.
|
|
29
66
|
|
|
30
67
|
"use client";
|
|
31
68
|
|
|
@@ -35,6 +72,137 @@ import type { Rest } from "./internal/merge-props.js";
|
|
|
35
72
|
import { composeHandlers, withoutComposed } from "./internal/merge-props.js";
|
|
36
73
|
import { useControlled } from "./internal/controlled-state.js";
|
|
37
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The button a form would submit itself through, or nothing.
|
|
77
|
+
*
|
|
78
|
+
* The specification's "default button" is the first submit button in tree order
|
|
79
|
+
* **whose form owner is this form**, and neither half of that is "a
|
|
80
|
+
* descendant". A control's form owner is the `form` attribute when it has one
|
|
81
|
+
* and the nearest ancestor `<form>` otherwise, so the two cases a subtree
|
|
82
|
+
* search gets wrong are both real markup:
|
|
83
|
+
*
|
|
84
|
+
* * `<button type="submit" form="signup">` *beside* the form — the pattern a
|
|
85
|
+
* dialog's footer is written in — is the default button and a subtree
|
|
86
|
+
* search never sees it. Missing it is not a small error: it falls through
|
|
87
|
+
* to the no-submitter branch, which is the exact `event.submitter` this
|
|
88
|
+
* component exists to answer.
|
|
89
|
+
* * `<button type="submit" form="other">` *inside* the form belongs to the
|
|
90
|
+
* other one, and handing it to `requestSubmit` throws `NotFoundError` —
|
|
91
|
+
* which reaches a reader as a key that does nothing and a console the page
|
|
92
|
+
* did not write.
|
|
93
|
+
*
|
|
94
|
+
* So the search is over the form's root and each candidate is asked which form
|
|
95
|
+
* it belongs to. The root rather than the document, because a form in a shadow
|
|
96
|
+
* tree, or one rendered but not yet inserted, has to find its own buttons and
|
|
97
|
+
* only its own.
|
|
98
|
+
*
|
|
99
|
+
* A `<button>` with no `type` is a submit button, which is the case most easily
|
|
100
|
+
* missed. A disabled one is skipped, because the browser skips it — implicit
|
|
101
|
+
* submission through a button nobody could press is not a thing the platform
|
|
102
|
+
* does.
|
|
103
|
+
*/
|
|
104
|
+
function defaultButtonOf(form: HTMLElement): HTMLElement | null {
|
|
105
|
+
const searched: $FlowFixMe = (form as $FlowFixMe).getRootNode?.() ?? form.ownerDocument;
|
|
106
|
+
if (searched == null || typeof searched.querySelectorAll !== "function") {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const candidates = searched.querySelectorAll(
|
|
110
|
+
'button:not([type]), button[type="submit"], input[type="submit"], input[type="image"]',
|
|
111
|
+
);
|
|
112
|
+
for (const candidate of candidates) {
|
|
113
|
+
const button: $FlowFixMe = candidate;
|
|
114
|
+
if (button.form === form && button.disabled !== true) {
|
|
115
|
+
return button as $FlowFixMe;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The `<input>` types that block implicit submission.
|
|
123
|
+
*
|
|
124
|
+
* The specification's list, copied rather than reasoned about, because what is
|
|
125
|
+
* being reproduced is what the browser does. A checkbox, a radio, a hidden
|
|
126
|
+
* field, a `<select>` and a `<textarea>` are not on it.
|
|
127
|
+
*/
|
|
128
|
+
const BLOCKING_TYPES: Set<string> = new Set([
|
|
129
|
+
"date",
|
|
130
|
+
"datetime-local",
|
|
131
|
+
"email",
|
|
132
|
+
"month",
|
|
133
|
+
"number",
|
|
134
|
+
"password",
|
|
135
|
+
"search",
|
|
136
|
+
"tel",
|
|
137
|
+
"text",
|
|
138
|
+
"time",
|
|
139
|
+
"url",
|
|
140
|
+
"week",
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Whether the platform would decline to submit `form` from the form itself.
|
|
145
|
+
*
|
|
146
|
+
* The other half of the implicit submission rule, and the half a script never
|
|
147
|
+
* meets: a form with **no** submit button is submitted implicitly only when at
|
|
148
|
+
* most one of its fields blocks implicit submission. A login form with a
|
|
149
|
+
* username and a password and no button is the everyday case — `Enter` in
|
|
150
|
+
* either field does nothing at all in every browser.
|
|
151
|
+
*
|
|
152
|
+
* `requestSubmit()` does not apply that rule, and is right not to: it is the
|
|
153
|
+
* route a script takes to submit deliberately. A component reproducing the
|
|
154
|
+
* *implicit* mechanism has to apply it here, or `Enter` on a checkbox submits
|
|
155
|
+
* forms that `Enter` in the text field beside it would not — which is the same
|
|
156
|
+
* class of divergence this whole change is about, pointing the other way.
|
|
157
|
+
*/
|
|
158
|
+
function moreThanOneFieldBlocks(form: $FlowFixMe): boolean {
|
|
159
|
+
const fields: $FlowFixMe = form.elements;
|
|
160
|
+
if (fields == null) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
let blocking = 0;
|
|
164
|
+
for (const field of fields) {
|
|
165
|
+
const control: $FlowFixMe = field;
|
|
166
|
+
// `.type` rather than the attribute: it is missing on `<input>` and any
|
|
167
|
+
// value the specification does not know is the Text state, and both of
|
|
168
|
+
// those block.
|
|
169
|
+
if (control.tagName === "INPUT" && BLOCKING_TYPES.has(String(control.type))) {
|
|
170
|
+
blocking += 1;
|
|
171
|
+
if (blocking > 1) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Submit the form this control is in, the way `Enter` on a native checkbox does.
|
|
181
|
+
*
|
|
182
|
+
* Does nothing when there is no form, when the browser has no `requestSubmit` —
|
|
183
|
+
* it is everywhere current, and a checkbox that threw on an old one would be
|
|
184
|
+
* worse than a key that does nothing — or when the form is one the platform
|
|
185
|
+
* would not submit implicitly either, which is the rule
|
|
186
|
+
* `moreThanOneFieldBlocks` states.
|
|
187
|
+
*/
|
|
188
|
+
function submitImplicitly(control: HTMLElement): void {
|
|
189
|
+
const form: $FlowFixMe = (control as $FlowFixMe).form;
|
|
190
|
+
if (form == null || typeof form.requestSubmit !== "function") {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const submitter = defaultButtonOf(form);
|
|
194
|
+
if (submitter == null) {
|
|
195
|
+
// A form with no submit button submits itself, but only under the rule
|
|
196
|
+
// `moreThanOneFieldBlocks` carries — `requestSubmit()` will not apply it,
|
|
197
|
+
// so this does.
|
|
198
|
+
if (!moreThanOneFieldBlocks(form)) {
|
|
199
|
+
form.requestSubmit();
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
form.requestSubmit(submitter);
|
|
204
|
+
}
|
|
205
|
+
|
|
38
206
|
/** A checkbox, which may also be mixed. */
|
|
39
207
|
export component Checkbox(
|
|
40
208
|
checked?: boolean,
|
|
@@ -63,13 +231,23 @@ export component Checkbox(
|
|
|
63
231
|
}
|
|
64
232
|
})}
|
|
65
233
|
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
66
|
-
if (disabled
|
|
234
|
+
if (disabled) {
|
|
67
235
|
return;
|
|
68
236
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
237
|
+
if (event.key === " ") {
|
|
238
|
+
// Stops `Space` scrolling the page, and stops the browser's own click
|
|
239
|
+
// arriving afterwards and toggling this a second time.
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
setOn(next);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (event.key === "Enter") {
|
|
245
|
+
// Claimed, and *not* to make the key inert: the default action here
|
|
246
|
+
// is a click on this button, and a click on this button toggles. See
|
|
247
|
+
// the module header for the whole of it.
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
submitImplicitly(event.currentTarget as $FlowFixMe);
|
|
250
|
+
}
|
|
73
251
|
})}
|
|
74
252
|
role="checkbox"
|
|
75
253
|
type="button"
|
package/collapsible.js
CHANGED
|
@@ -23,16 +23,29 @@
|
|
|
23
23
|
// `internal/disclosure.js` explains what is done instead, and why React needs a
|
|
24
24
|
// hook to say it.
|
|
25
25
|
//
|
|
26
|
-
// #
|
|
26
|
+
// # The height, for a stylesheet that animates it
|
|
27
27
|
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
28
|
+
// `measure` puts the height the content *would* have on
|
|
29
|
+
// `Collapsible.Content` as `--uf-collapsible-height`, correct while the panel
|
|
30
|
+
// is still closed — which is the only moment it is any use, because
|
|
31
|
+
// `height: 0 → var(--uf-collapsible-height)` is a transition that has to know
|
|
32
|
+
// its destination before it starts. `internal/disclosure.js` holds the
|
|
33
|
+
// measuring pass and says why the obvious ways of asking all answer zero, and
|
|
34
|
+
// why the prop is opt-in rather than always on.
|
|
35
|
+
//
|
|
36
|
+
// [data-collapsible-content] {
|
|
37
|
+
// overflow: hidden;
|
|
38
|
+
// transition: height 150ms;
|
|
39
|
+
// height: 0;
|
|
40
|
+
// }
|
|
41
|
+
// [data-collapsible-content]:not([hidden]) {
|
|
42
|
+
// height: var(--uf-collapsible-height);
|
|
43
|
+
// }
|
|
44
|
+
//
|
|
45
|
+
// The selector is the caller's — a class, a `data-*` of their own, whatever
|
|
46
|
+
// they already style with. This package emits the number and no styles at all,
|
|
47
|
+
// which is the same division `internal/anchor.js` keeps for a popover: a
|
|
48
|
+
// stylesheet can say *how* to move, and only the component can say how far.
|
|
36
49
|
|
|
37
50
|
"use client";
|
|
38
51
|
|
|
@@ -41,7 +54,7 @@ import { createContext, useContext, useId, useMemo, useRef, useState } from "@un
|
|
|
41
54
|
|
|
42
55
|
import type { Rest } from "./internal/merge-props.js";
|
|
43
56
|
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
44
|
-
import { usePresence, useUntilFound } from "./internal/disclosure.js";
|
|
57
|
+
import { useMeasuredHeight, usePresence, useUntilFound } from "./internal/disclosure.js";
|
|
45
58
|
import { useControlled } from "./internal/controlled-state.js";
|
|
46
59
|
|
|
47
60
|
type CollapsibleState = {|
|
|
@@ -51,6 +64,8 @@ type CollapsibleState = {|
|
|
|
51
64
|
/** Whether a `Collapsible.Content` is rendered, so the trigger names one that exists. */
|
|
52
65
|
readonly present: boolean,
|
|
53
66
|
readonly registerContent: (present: boolean) => void,
|
|
67
|
+
/** Whether the content carries its measured height; see the module header. */
|
|
68
|
+
readonly measure: boolean,
|
|
54
69
|
|};
|
|
55
70
|
|
|
56
71
|
const CollapsibleContext: React.Context<CollapsibleState | null> = createContext(null);
|
|
@@ -76,14 +91,15 @@ export component CollapsibleRoot(
|
|
|
76
91
|
defaultOpen?: boolean = false,
|
|
77
92
|
open?: boolean,
|
|
78
93
|
onOpenChange?: (open: boolean) => void,
|
|
94
|
+
measure?: boolean = false,
|
|
79
95
|
) {
|
|
80
96
|
const contentId = `${useId()}-content`;
|
|
81
97
|
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
82
98
|
const [present, setPresent] = useState(false);
|
|
83
99
|
|
|
84
100
|
const state = useMemo(
|
|
85
|
-
() => ({ contentId, open: isOpen, setOpen, present, registerContent: setPresent }),
|
|
86
|
-
[contentId, isOpen, setOpen, present],
|
|
101
|
+
() => ({ contentId, open: isOpen, setOpen, present, registerContent: setPresent, measure }),
|
|
102
|
+
[contentId, isOpen, setOpen, present, measure],
|
|
87
103
|
);
|
|
88
104
|
|
|
89
105
|
return <CollapsibleContext.Provider value={state}>{children}</CollapsibleContext.Provider>;
|
|
@@ -131,6 +147,7 @@ export component CollapsibleContent(children: React.Node, ...rest: Rest) {
|
|
|
131
147
|
const contentRef = useRef<HTMLElement | null>(null);
|
|
132
148
|
usePresence(collapsible.registerContent);
|
|
133
149
|
useUntilFound(contentRef, collapsible.open);
|
|
150
|
+
useMeasuredHeight(contentRef, collapsible.measure);
|
|
134
151
|
|
|
135
152
|
return (
|
|
136
153
|
<div
|
package/combobox.js
CHANGED
|
@@ -49,6 +49,69 @@
|
|
|
49
49
|
// the active option is cleared when the option it named is filtered away, the
|
|
50
50
|
// count is remeasured, and `aria-activedescendant` never names an id that has
|
|
51
51
|
// left the document.
|
|
52
|
+
//
|
|
53
|
+
// # Groups, and the two elements that had to change to have them
|
|
54
|
+
//
|
|
55
|
+
// A `listbox` may own `option` and `group` elements, and nothing else. This
|
|
56
|
+
// module rendered a `<ul>` of `<li>`s, which is the right shape for a flat list
|
|
57
|
+
// and the wrong one the moment a group appears: a group's options belong inside
|
|
58
|
+
// the group, a group inside a `<ul>` is an `<li>`, and an `<li>` inside an
|
|
59
|
+
// `<li>` is not something HTML has. The parser closes the outer one, so the
|
|
60
|
+
// markup a server sent and the tree a browser built would disagree — which
|
|
61
|
+
// React finds at hydration, in production, on the one page that had groups.
|
|
62
|
+
//
|
|
63
|
+
// The way out that keeps the list is a second `<ul role="presentation">` around
|
|
64
|
+
// each group's options, and it was rejected twice over. It works by an
|
|
65
|
+
// inheritance rule — a presentational role propagating to the elements its own
|
|
66
|
+
// role requires, except where a child carries an explicit role — which is
|
|
67
|
+
// correct in the specification and up to the software, and this package's whole
|
|
68
|
+
// premise is not building on that distinction. It would also leave the two
|
|
69
|
+
// halves of one pattern with two differently shaped listboxes, for a reason
|
|
70
|
+
// neither module could state.
|
|
71
|
+
//
|
|
72
|
+
// So `Combobox.List` and `Combobox.Option` are `div`s, exactly as `select.js`'s
|
|
73
|
+
// are and for the reason its header already gives at length. That is a change
|
|
74
|
+
// to what this component renders, and a caller whose stylesheet names `ul` or
|
|
75
|
+
// `li` will see it; nothing else moved, because the roles were always the part
|
|
76
|
+
// that carried the meaning.
|
|
77
|
+
//
|
|
78
|
+
// `Combobox.Group` and `Combobox.GroupLabel` are then `Select.Group` and
|
|
79
|
+
// `Select.GroupLabel`. The second name is deliberate rather than clumsy:
|
|
80
|
+
// `Combobox.Label` already means the *field's* label, so the heading over a
|
|
81
|
+
// group of options cannot also be `Combobox.Label`, and shadcn's single
|
|
82
|
+
// `SelectLabel` — which is the group's — has no name left for the field's.
|
|
83
|
+
//
|
|
84
|
+
// There is no `Combobox.Separator`, and that is the same decision `select.js`
|
|
85
|
+
// made about the tree rather than a different one about the part. A rule
|
|
86
|
+
// between two groups of options cannot be a `role="separator"`, because a
|
|
87
|
+
// listbox may not own one; it is `aria-hidden` decoration, and a
|
|
88
|
+
// `<div aria-hidden="true">` is something a caller writes without needing a
|
|
89
|
+
// part for it. `Select.Separator` exists because a select's options are a fixed
|
|
90
|
+
// list somebody wrote out and the rule between two of them is fixed too. A
|
|
91
|
+
// combobox's options are whatever survived the filter, so a rule that stays put
|
|
92
|
+
// while the groups either side of it disappear is decoration in the wrong
|
|
93
|
+
// place, and the caller who filtered is the one who knows where it goes.
|
|
94
|
+
//
|
|
95
|
+
// # A command palette is a composition, not a seventh module
|
|
96
|
+
//
|
|
97
|
+
// `crates/uf_lib/src/ui.rs` lists a `Command` with `Root`, `Input`, `List`,
|
|
98
|
+
// `Item`, `Group` and `Empty`, and with groups here every one of those parts
|
|
99
|
+
// now exists: a palette is a `Combobox` inside a `Dialog`, opened by
|
|
100
|
+
// `useKeyCombo("mod+k", …)` from `@uniflowed/hooks/keyboard`, with
|
|
101
|
+
// `Combobox.Group` for the sections, `Combobox.Empty` for the no-results state
|
|
102
|
+
// and `Combobox.Status` for the count. `ubugeeei-redundancy.md`'s objection to
|
|
103
|
+
// small lookalikes is an objection to shipping a module whose entire content is
|
|
104
|
+
// a composition the reader could have written, so the answer is the
|
|
105
|
+
// documentation page — `docs/app/reference/ui`, under "A command palette" —
|
|
106
|
+
// and not a seventh module.
|
|
107
|
+
//
|
|
108
|
+
// One behaviour a `Command` module would genuinely add is not in that page,
|
|
109
|
+
// because it is not implemented anywhere: a palette whose filter matched
|
|
110
|
+
// nothing still traps focus, so `Tab` cycles between a text field and a close
|
|
111
|
+
// button while the reader is told there are no results. That is `Dialog`'s
|
|
112
|
+
// question rather than this module's — a modal with nothing in it to reach is
|
|
113
|
+
// the general case — and it is left open on purpose rather than answered here
|
|
114
|
+
// by a component that would only look like it had.
|
|
52
115
|
|
|
53
116
|
"use client";
|
|
54
117
|
|
|
@@ -64,12 +127,16 @@ import {
|
|
|
64
127
|
} from "@uniflowed/react";
|
|
65
128
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
66
129
|
|
|
130
|
+
import type { Align, LogicalSide } from "./internal/anchor.js";
|
|
131
|
+
import { useAnchor } from "./internal/anchor.js";
|
|
67
132
|
import type { Rest } from "./internal/merge-props.js";
|
|
68
133
|
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
69
134
|
import { itemsOf, moveTo } from "./internal/roving-focus.js";
|
|
70
135
|
import { useControlled } from "./internal/controlled-state.js";
|
|
71
136
|
import { FormValue } from "./internal/form-value.js";
|
|
72
137
|
|
|
138
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
139
|
+
|
|
73
140
|
const OPTION_SELECTOR = '[role="option"]';
|
|
74
141
|
const LISTBOX_SELECTOR = '[role="listbox"]';
|
|
75
142
|
|
|
@@ -116,6 +183,14 @@ hook useCombobox(part: string): ComboboxState {
|
|
|
116
183
|
return state;
|
|
117
184
|
}
|
|
118
185
|
|
|
186
|
+
/** The id of a group's label, so `Combobox.Group` only claims one that exists. */
|
|
187
|
+
type ComboboxGroupState = {|
|
|
188
|
+
readonly labelId: string,
|
|
189
|
+
readonly registerLabel: (present: boolean) => void,
|
|
190
|
+
|};
|
|
191
|
+
|
|
192
|
+
const ComboboxGroupContext: React.Context<ComboboxGroupState | null> = createContext(null);
|
|
193
|
+
|
|
119
194
|
/**
|
|
120
195
|
* The combobox.
|
|
121
196
|
*
|
|
@@ -347,11 +422,24 @@ export component ComboboxInput(...rest: Rest) {
|
|
|
347
422
|
/**
|
|
348
423
|
* The list of options, in the document only while it is open.
|
|
349
424
|
*
|
|
425
|
+
* A `div` rather than the `ul` this was, because a listbox that owns groups
|
|
426
|
+
* cannot be a list without a second `list` role between a group and the options
|
|
427
|
+
* it holds. The module header has the argument and what it costs a caller.
|
|
428
|
+
*
|
|
350
429
|
* It also keeps the two things that have to stay true as the caller filters:
|
|
351
430
|
* the count the live region announces, and the invariant that
|
|
352
431
|
* `aria-activedescendant` never names an option that has left the list.
|
|
353
432
|
*/
|
|
354
|
-
export component ComboboxList(
|
|
433
|
+
export component ComboboxList(
|
|
434
|
+
children: renders* (ComboboxOption | ComboboxGroup),
|
|
435
|
+
align?: Align = "start",
|
|
436
|
+
alignOffset?: number = 0,
|
|
437
|
+
avoidCollisions?: boolean = true,
|
|
438
|
+
collisionPadding?: number = 0,
|
|
439
|
+
side?: LogicalSide = "bottom",
|
|
440
|
+
sideOffset?: number = 0,
|
|
441
|
+
...rest: Rest
|
|
442
|
+
) {
|
|
355
443
|
const combobox = useCombobox("Combobox.List");
|
|
356
444
|
const { activeId, count, listRef, inputRef, pendingActive, setActiveId, setCount } = combobox;
|
|
357
445
|
const close = useStableCallback(() => {
|
|
@@ -359,6 +447,22 @@ export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest)
|
|
|
359
447
|
combobox.setActiveId(null);
|
|
360
448
|
});
|
|
361
449
|
|
|
450
|
+
// Anchored to the *field*, not to a wrapper the caller may not have written.
|
|
451
|
+
// `align="start"` because a list of options belongs under the edge the text
|
|
452
|
+
// starts at, and `--uf-anchor-trigger-width` is what a stylesheet reads to
|
|
453
|
+
// make it exactly as wide as the field.
|
|
454
|
+
const anchored = useAnchor({
|
|
455
|
+
align,
|
|
456
|
+
alignOffset,
|
|
457
|
+
anchorRef: inputRef,
|
|
458
|
+
avoidCollisions,
|
|
459
|
+
collisionPadding,
|
|
460
|
+
open: combobox.open,
|
|
461
|
+
overlayRef: listRef,
|
|
462
|
+
side,
|
|
463
|
+
sideOffset,
|
|
464
|
+
});
|
|
465
|
+
|
|
362
466
|
// No dependency list on purpose: what this reads is the *rendered* options,
|
|
363
467
|
// and they change whenever the caller re-filters — which is a change to
|
|
364
468
|
// `children` that no dependency list can describe. Every write below is
|
|
@@ -427,9 +531,11 @@ export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest)
|
|
|
427
531
|
const passed = withoutComposed(rest, ["ref"]);
|
|
428
532
|
|
|
429
533
|
return (
|
|
430
|
-
<
|
|
534
|
+
<div
|
|
431
535
|
{...passed}
|
|
432
536
|
aria-labelledby={combobox.labelled ? `${combobox.base}-label` : undefined}
|
|
537
|
+
data-align={anchored.align}
|
|
538
|
+
data-side={anchored.side}
|
|
433
539
|
id={`${combobox.base}-list`}
|
|
434
540
|
ref={composeRefs(rest.ref, (element) => {
|
|
435
541
|
listRef.current = element;
|
|
@@ -437,7 +543,7 @@ export component ComboboxList(children: renders* ComboboxOption, ...rest: Rest)
|
|
|
437
543
|
role="listbox"
|
|
438
544
|
>
|
|
439
545
|
{children}
|
|
440
|
-
</
|
|
546
|
+
</div>
|
|
441
547
|
);
|
|
442
548
|
}
|
|
443
549
|
|
|
@@ -463,7 +569,7 @@ export component ComboboxOption(
|
|
|
463
569
|
const passed = withoutComposed(rest, ["onClick", "onPointerDown", "onPointerMove"]);
|
|
464
570
|
|
|
465
571
|
return (
|
|
466
|
-
<
|
|
572
|
+
<div
|
|
467
573
|
{...passed}
|
|
468
574
|
aria-disabled={disabled ? "true" : undefined}
|
|
469
575
|
aria-selected={combobox.value === value ? "true" : "false"}
|
|
@@ -495,7 +601,72 @@ export component ComboboxOption(
|
|
|
495
601
|
role="option"
|
|
496
602
|
>
|
|
497
603
|
{children}
|
|
498
|
-
</
|
|
604
|
+
</div>
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* A named group of options.
|
|
610
|
+
*
|
|
611
|
+
* The name reaches the group through `aria-labelledby`, and only while a
|
|
612
|
+
* `Combobox.GroupLabel` is rendered — the same rule, and the same reason, as
|
|
613
|
+
* `Select.Group` and `Menu.Group` before it.
|
|
614
|
+
*
|
|
615
|
+
* Nothing about `Combobox.Input` had to learn that groups exist. It asks for
|
|
616
|
+
* `[role="option"]` elements whose nearest `[role="listbox"]` is this list, and
|
|
617
|
+
* a group is not a listbox — so the arrow keys walk an option at a time across
|
|
618
|
+
* a boundary they cannot see, and the heading is never a place the cursor can
|
|
619
|
+
* land, because it is not an option.
|
|
620
|
+
*
|
|
621
|
+
* `children` is narrower than `Select.Group`'s `React.Node`, and the narrower
|
|
622
|
+
* one is the true statement: a `group` inside a `listbox` may own options and
|
|
623
|
+
* its own heading, and nothing else. `Select.Group` should say the same and
|
|
624
|
+
* does not yet.
|
|
625
|
+
*/
|
|
626
|
+
export component ComboboxGroup(
|
|
627
|
+
children: renders* (ComboboxOption | ComboboxGroupLabel),
|
|
628
|
+
...rest: Rest
|
|
629
|
+
) {
|
|
630
|
+
const base = useId();
|
|
631
|
+
const [labelled, setLabelled] = useState(false);
|
|
632
|
+
|
|
633
|
+
const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
|
|
634
|
+
|
|
635
|
+
return (
|
|
636
|
+
<ComboboxGroupContext.Provider value={group}>
|
|
637
|
+
<div {...rest} aria-labelledby={labelled ? group.labelId : undefined} role="group">
|
|
638
|
+
{children}
|
|
639
|
+
</div>
|
|
640
|
+
</ComboboxGroupContext.Provider>
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* The heading of a `Combobox.Group`.
|
|
646
|
+
*
|
|
647
|
+
* `role="presentation"` because the group already carries the name: left as
|
|
648
|
+
* ordinary content a reader would hear the heading once as the group's name and
|
|
649
|
+
* again as a stray line of text among the options.
|
|
650
|
+
*
|
|
651
|
+
* This is not `Combobox.Label`. That one names the field; this one names a
|
|
652
|
+
* group of options, and a combobox with groups has both.
|
|
653
|
+
*/
|
|
654
|
+
export component ComboboxGroupLabel(children: React.Node, ...rest: Rest) {
|
|
655
|
+
const group = useContext(ComboboxGroupContext);
|
|
656
|
+
const register = group?.registerLabel;
|
|
657
|
+
|
|
658
|
+
useEffect(() => {
|
|
659
|
+
if (register == null) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
register(true);
|
|
663
|
+
return () => register(false);
|
|
664
|
+
}, [register]);
|
|
665
|
+
|
|
666
|
+
return (
|
|
667
|
+
<div {...rest} id={group?.labelId} role="presentation">
|
|
668
|
+
{children}
|
|
669
|
+
</div>
|
|
499
670
|
);
|
|
500
671
|
}
|
|
501
672
|
|