@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.40
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 +360 -0
- package/alert-dialog.js +282 -0
- package/alert.js +142 -0
- package/avatar.js +276 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +547 -0
- package/carousel.js +410 -0
- package/checkbox.js +216 -31
- package/collapsible.js +169 -0
- package/combobox.js +209 -40
- package/context-menu.js +206 -0
- package/date-picker.js +346 -0
- package/dialog.js +229 -197
- package/drawer.js +490 -0
- package/field.js +257 -42
- package/hover-card.js +330 -0
- package/index.js +1548 -24
- package/input-otp.js +218 -0
- package/interactions.js +2323 -0
- package/internal/anchor.js +565 -0
- package/internal/date-grid.js +260 -0
- package/internal/disclosure.js +298 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +206 -7
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +205 -11
- package/menu.js +521 -336
- package/menubar.js +288 -0
- package/navigation-menu.js +251 -0
- package/package.json +8 -12
- package/pagination.js +209 -0
- package/popover.js +344 -0
- package/progress.js +91 -0
- package/radio-group.js +302 -0
- package/resizable.js +447 -0
- package/scroll-area.js +283 -0
- package/select.js +888 -0
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +313 -0
- package/skeleton.js +159 -0
- package/slider.js +405 -0
- package/switch.js +43 -34
- package/table.js +520 -0
- package/tabs.js +99 -96
- package/toast.js +592 -0
- package/toggle-group.js +282 -0
- package/toggle.js +105 -0
- package/tooltip.js +400 -0
package/collapsible.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
// # The height, for a stylesheet that animates it
|
|
27
|
+
//
|
|
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.
|
|
49
|
+
|
|
50
|
+
"use client";
|
|
51
|
+
|
|
52
|
+
import * as React from "@uniflowed/react";
|
|
53
|
+
import { createContext, useContext, useId, useMemo, useRef, useState } from "@uniflowed/react";
|
|
54
|
+
|
|
55
|
+
import type { RenderProp, Rest } from "./internal/merge-props.js";
|
|
56
|
+
import {
|
|
57
|
+
composeHandlers,
|
|
58
|
+
composeRefs,
|
|
59
|
+
withProps,
|
|
60
|
+
withoutComposed,
|
|
61
|
+
} from "./internal/merge-props.js";
|
|
62
|
+
import { useMeasuredHeight, usePresence, useUntilFound } from "./internal/disclosure.js";
|
|
63
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
64
|
+
|
|
65
|
+
type CollapsibleState = {|
|
|
66
|
+
readonly contentId: string,
|
|
67
|
+
readonly open: boolean,
|
|
68
|
+
readonly setOpen: (open: boolean) => void,
|
|
69
|
+
/** Whether a `Collapsible.Content` is rendered, so the trigger names one that exists. */
|
|
70
|
+
readonly present: boolean,
|
|
71
|
+
readonly registerContent: (present: boolean) => void,
|
|
72
|
+
/** Whether the content carries its measured height; see the module header. */
|
|
73
|
+
readonly measure: boolean,
|
|
74
|
+
|};
|
|
75
|
+
|
|
76
|
+
const CollapsibleContext: React.Context<CollapsibleState | null> = createContext(null);
|
|
77
|
+
|
|
78
|
+
hook useCollapsible(part: string): CollapsibleState {
|
|
79
|
+
const state = useContext(CollapsibleContext);
|
|
80
|
+
if (state == null) {
|
|
81
|
+
throw new Error(`${part} must be rendered inside a Collapsible.Root`);
|
|
82
|
+
}
|
|
83
|
+
return state;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The pair, and the state they agree about.
|
|
88
|
+
*
|
|
89
|
+
* Renders no element of its own: a trigger and its content are siblings in
|
|
90
|
+
* whatever layout the caller wrote, and a wrapper would put a `<div>` between
|
|
91
|
+
* them that the caller then has to style around. `Menu.Root` makes the same
|
|
92
|
+
* choice for the same reason.
|
|
93
|
+
*/
|
|
94
|
+
export component CollapsibleRoot(
|
|
95
|
+
children: React.Node,
|
|
96
|
+
defaultOpen?: boolean = false,
|
|
97
|
+
open?: boolean,
|
|
98
|
+
onOpenChange?: (open: boolean) => void,
|
|
99
|
+
measure?: boolean = false,
|
|
100
|
+
) {
|
|
101
|
+
const contentId = `${useId()}-content`;
|
|
102
|
+
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
103
|
+
const [present, setPresent] = useState(false);
|
|
104
|
+
|
|
105
|
+
const state = useMemo(
|
|
106
|
+
() => ({ contentId, open: isOpen, setOpen, present, registerContent: setPresent, measure }),
|
|
107
|
+
[contentId, isOpen, setOpen, present, measure],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return <CollapsibleContext.Provider value={state}>{children}</CollapsibleContext.Provider>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The control that shows and hides the content. */
|
|
114
|
+
export component CollapsibleTrigger(
|
|
115
|
+
children: React.Node,
|
|
116
|
+
disabled?: boolean = false,
|
|
117
|
+
render?: RenderProp,
|
|
118
|
+
...rest: Rest
|
|
119
|
+
) {
|
|
120
|
+
const collapsible = useCollapsible("Collapsible.Trigger");
|
|
121
|
+
const props = withProps(withoutComposed(rest, ["onClick"]), {
|
|
122
|
+
// Named only while the content is in the document. A caller who renders
|
|
123
|
+
// the content conditionally — or not at all until data arrives — would
|
|
124
|
+
// otherwise have this trigger pointing at nothing.
|
|
125
|
+
"aria-controls": collapsible.present ? collapsible.contentId : undefined,
|
|
126
|
+
"aria-expanded": collapsible.open ? "true" : "false",
|
|
127
|
+
children,
|
|
128
|
+
disabled,
|
|
129
|
+
onClick: composeHandlers(rest.onClick, () => {
|
|
130
|
+
if (!disabled) {
|
|
131
|
+
collapsible.setOpen(!collapsible.open);
|
|
132
|
+
}
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (render != null) {
|
|
137
|
+
return render(props);
|
|
138
|
+
}
|
|
139
|
+
return <button {...props} type="button" />;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The region the trigger shows.
|
|
144
|
+
*
|
|
145
|
+
* It is always rendered and `hidden` while closed, rather than removed — see
|
|
146
|
+
* the module header, and `internal/disclosure.js` for what `hidden` is upgraded
|
|
147
|
+
* to and why that takes an effect.
|
|
148
|
+
*/
|
|
149
|
+
export component CollapsibleContent(children: React.Node, render?: RenderProp, ...rest: Rest) {
|
|
150
|
+
const collapsible = useCollapsible("Collapsible.Content");
|
|
151
|
+
const contentRef = useRef<HTMLElement | null>(null);
|
|
152
|
+
usePresence(collapsible.registerContent);
|
|
153
|
+
useUntilFound(contentRef, collapsible.open);
|
|
154
|
+
useMeasuredHeight(contentRef, collapsible.measure);
|
|
155
|
+
|
|
156
|
+
const props = withProps(withoutComposed(rest, ["ref"]), {
|
|
157
|
+
children,
|
|
158
|
+
hidden: !collapsible.open,
|
|
159
|
+
id: collapsible.contentId,
|
|
160
|
+
ref: composeRefs(rest.ref, (element: HTMLElement | null) => {
|
|
161
|
+
contentRef.current = element;
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (render != null) {
|
|
166
|
+
return render(props);
|
|
167
|
+
}
|
|
168
|
+
return <div {...props} />;
|
|
169
|
+
}
|
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,9 +127,17 @@ import {
|
|
|
64
127
|
} from "@uniflowed/react";
|
|
65
128
|
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
66
129
|
|
|
130
|
+
import { useInteractOutside } from "./interactions.js";
|
|
131
|
+
|
|
132
|
+
import type { Align, LogicalSide } from "./internal/anchor.js";
|
|
133
|
+
import { useAnchor } from "./internal/anchor.js";
|
|
134
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
67
135
|
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
68
136
|
import { itemsOf, moveTo } from "./internal/roving-focus.js";
|
|
69
137
|
import { useControlled } from "./internal/controlled-state.js";
|
|
138
|
+
import { FormValue } from "./internal/form-value.js";
|
|
139
|
+
|
|
140
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
70
141
|
|
|
71
142
|
const OPTION_SELECTOR = '[role="option"]';
|
|
72
143
|
const LISTBOX_SELECTOR = '[role="listbox"]';
|
|
@@ -114,6 +185,14 @@ hook useCombobox(part: string): ComboboxState {
|
|
|
114
185
|
return state;
|
|
115
186
|
}
|
|
116
187
|
|
|
188
|
+
/** The id of a group's label, so `Combobox.Group` only claims one that exists. */
|
|
189
|
+
type ComboboxGroupState = {|
|
|
190
|
+
readonly labelId: string,
|
|
191
|
+
readonly registerLabel: (present: boolean) => void,
|
|
192
|
+
|};
|
|
193
|
+
|
|
194
|
+
const ComboboxGroupContext: React.Context<ComboboxGroupState | null> = createContext(null);
|
|
195
|
+
|
|
117
196
|
/**
|
|
118
197
|
* The combobox.
|
|
119
198
|
*
|
|
@@ -122,6 +201,14 @@ hook useCombobox(part: string): ComboboxState {
|
|
|
122
201
|
* `open` is whether the list is showing. A search box owns the text and nothing
|
|
123
202
|
* else; a form field owns the value; a page with a "browse all" button owns
|
|
124
203
|
* `open`. Tying them together would make two of those three impossible.
|
|
204
|
+
*
|
|
205
|
+
* `name` is what a form submits, and it exists because that same distinction
|
|
206
|
+
* had a hole in it. `Combobox.Input` renders the *text* — the label the reader
|
|
207
|
+
* sees — so a combobox named `country` inside a `<form>` submitted "United
|
|
208
|
+
* Kingdom" where the application meant `GB`, silently and only in production.
|
|
209
|
+
* Given a `name`, the root renders a hidden control carrying `value` instead;
|
|
210
|
+
* `internal/form-value.js` says why it is an `<input>` and why
|
|
211
|
+
* `@uniflowed/form` does not need it.
|
|
125
212
|
*/
|
|
126
213
|
export component ComboboxRoot(
|
|
127
214
|
children: React.Node,
|
|
@@ -134,7 +221,8 @@ export component ComboboxRoot(
|
|
|
134
221
|
open?: boolean,
|
|
135
222
|
defaultOpen?: boolean = false,
|
|
136
223
|
onOpenChange?: (open: boolean) => void,
|
|
137
|
-
|
|
224
|
+
name?: string,
|
|
225
|
+
...rest: Rest
|
|
138
226
|
) {
|
|
139
227
|
const base = useId();
|
|
140
228
|
const [chosen, setChosen] = useControlled(value, defaultValue, onValueChange);
|
|
@@ -190,7 +278,10 @@ export component ComboboxRoot(
|
|
|
190
278
|
|
|
191
279
|
return (
|
|
192
280
|
<ComboboxContext.Provider value={state}>
|
|
193
|
-
<div {...rest}>
|
|
281
|
+
<div {...rest}>
|
|
282
|
+
{children}
|
|
283
|
+
{name == null ? null : <FormValue name={name} value={chosen} />}
|
|
284
|
+
</div>
|
|
194
285
|
</ComboboxContext.Provider>
|
|
195
286
|
);
|
|
196
287
|
}
|
|
@@ -203,7 +294,7 @@ export component ComboboxRoot(
|
|
|
203
294
|
* because the list names it, and naming a label that is not rendered is worse
|
|
204
295
|
* than leaving the list unnamed.
|
|
205
296
|
*/
|
|
206
|
-
export component ComboboxLabel(children: React.Node, ...rest:
|
|
297
|
+
export component ComboboxLabel(children: React.Node, ...rest: Rest) {
|
|
207
298
|
const combobox = useCombobox("Combobox.Label");
|
|
208
299
|
const register = combobox.registerLabel;
|
|
209
300
|
useEffect(() => {
|
|
@@ -219,7 +310,7 @@ export component ComboboxLabel(children: React.Node, ...rest: { readonly [string
|
|
|
219
310
|
}
|
|
220
311
|
|
|
221
312
|
/** The text field, and every key the pattern defines. */
|
|
222
|
-
export component ComboboxInput(...rest:
|
|
313
|
+
export component ComboboxInput(...rest: Rest) {
|
|
223
314
|
const combobox = useCombobox("Combobox.Input");
|
|
224
315
|
const passed = withoutComposed(rest, ["onChange", "onKeyDown", "ref"]);
|
|
225
316
|
|
|
@@ -333,13 +424,23 @@ export component ComboboxInput(...rest: { readonly [string]: mixed }) {
|
|
|
333
424
|
/**
|
|
334
425
|
* The list of options, in the document only while it is open.
|
|
335
426
|
*
|
|
427
|
+
* A `div` rather than the `ul` this was, because a listbox that owns groups
|
|
428
|
+
* cannot be a list without a second `list` role between a group and the options
|
|
429
|
+
* it holds. The module header has the argument and what it costs a caller.
|
|
430
|
+
*
|
|
336
431
|
* It also keeps the two things that have to stay true as the caller filters:
|
|
337
432
|
* the count the live region announces, and the invariant that
|
|
338
433
|
* `aria-activedescendant` never names an option that has left the list.
|
|
339
434
|
*/
|
|
340
435
|
export component ComboboxList(
|
|
341
|
-
children: renders* ComboboxOption,
|
|
342
|
-
|
|
436
|
+
children: renders* (ComboboxOption | ComboboxGroup),
|
|
437
|
+
align?: Align = "start",
|
|
438
|
+
alignOffset?: number = 0,
|
|
439
|
+
avoidCollisions?: boolean = true,
|
|
440
|
+
collisionPadding?: number = 0,
|
|
441
|
+
side?: LogicalSide = "bottom",
|
|
442
|
+
sideOffset?: number = 0,
|
|
443
|
+
...rest: Rest
|
|
343
444
|
) {
|
|
344
445
|
const combobox = useCombobox("Combobox.List");
|
|
345
446
|
const { activeId, count, listRef, inputRef, pendingActive, setActiveId, setCount } = combobox;
|
|
@@ -348,6 +449,22 @@ export component ComboboxList(
|
|
|
348
449
|
combobox.setActiveId(null);
|
|
349
450
|
});
|
|
350
451
|
|
|
452
|
+
// Anchored to the *field*, not to a wrapper the caller may not have written.
|
|
453
|
+
// `align="start"` because a list of options belongs under the edge the text
|
|
454
|
+
// starts at, and `--uf-anchor-trigger-width` is what a stylesheet reads to
|
|
455
|
+
// make it exactly as wide as the field.
|
|
456
|
+
const anchored = useAnchor({
|
|
457
|
+
align,
|
|
458
|
+
alignOffset,
|
|
459
|
+
anchorRef: inputRef,
|
|
460
|
+
avoidCollisions,
|
|
461
|
+
collisionPadding,
|
|
462
|
+
open: combobox.open,
|
|
463
|
+
overlayRef: listRef,
|
|
464
|
+
side,
|
|
465
|
+
sideOffset,
|
|
466
|
+
});
|
|
467
|
+
|
|
351
468
|
// No dependency list on purpose: what this reads is the *rendered* options,
|
|
352
469
|
// and they change whenever the caller re-filters — which is a change to
|
|
353
470
|
// `children` that no dependency list can describe. Every write below is
|
|
@@ -381,33 +498,18 @@ export component ComboboxList(
|
|
|
381
498
|
}
|
|
382
499
|
});
|
|
383
500
|
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const target: $FlowFixMe = event.target;
|
|
397
|
-
if (target == null || list.contains(target)) {
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
// The field is not "outside": pressing it is how a reader gets back to
|
|
401
|
-
// typing, and closing on it would fight the input's own handlers.
|
|
402
|
-
const input = inputRef.current;
|
|
403
|
-
if (input != null && input.contains(target)) {
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
close();
|
|
407
|
-
};
|
|
408
|
-
document.addEventListener("pointerdown", onOutsidePress, true);
|
|
409
|
-
return () => document.removeEventListener("pointerdown", onOutsidePress, true);
|
|
410
|
-
}, [combobox.open, close, listRef, inputRef]);
|
|
501
|
+
// The field is not "outside": pressing it is how a reader gets back to
|
|
502
|
+
// typing, and closing on it would fight the input's own handlers.
|
|
503
|
+
//
|
|
504
|
+
// The refs are read when a press arrives rather than when the listener is
|
|
505
|
+
// attached. This component is mounted the whole time and only *renders*
|
|
506
|
+
// while the list is open, and the listener that was attached on the first
|
|
507
|
+
// commit — when `listRef.current` was still null — closed nothing at all.
|
|
508
|
+
useInteractOutside({
|
|
509
|
+
isDisabled: !combobox.open,
|
|
510
|
+
onInteractOutside: () => close(),
|
|
511
|
+
refs: [listRef, inputRef],
|
|
512
|
+
});
|
|
411
513
|
|
|
412
514
|
if (!combobox.open) {
|
|
413
515
|
return null;
|
|
@@ -416,9 +518,11 @@ export component ComboboxList(
|
|
|
416
518
|
const passed = withoutComposed(rest, ["ref"]);
|
|
417
519
|
|
|
418
520
|
return (
|
|
419
|
-
<
|
|
521
|
+
<div
|
|
420
522
|
{...passed}
|
|
421
523
|
aria-labelledby={combobox.labelled ? `${combobox.base}-label` : undefined}
|
|
524
|
+
data-align={anchored.align}
|
|
525
|
+
data-side={anchored.side}
|
|
422
526
|
id={`${combobox.base}-list`}
|
|
423
527
|
ref={composeRefs(rest.ref, (element) => {
|
|
424
528
|
listRef.current = element;
|
|
@@ -426,7 +530,7 @@ export component ComboboxList(
|
|
|
426
530
|
role="listbox"
|
|
427
531
|
>
|
|
428
532
|
{children}
|
|
429
|
-
</
|
|
533
|
+
</div>
|
|
430
534
|
);
|
|
431
535
|
}
|
|
432
536
|
|
|
@@ -444,7 +548,7 @@ export component ComboboxOption(
|
|
|
444
548
|
children: React.Node,
|
|
445
549
|
label?: string,
|
|
446
550
|
disabled?: boolean = false,
|
|
447
|
-
...rest:
|
|
551
|
+
...rest: Rest
|
|
448
552
|
) {
|
|
449
553
|
const combobox = useCombobox("Combobox.Option");
|
|
450
554
|
const id = useId();
|
|
@@ -452,7 +556,7 @@ export component ComboboxOption(
|
|
|
452
556
|
const passed = withoutComposed(rest, ["onClick", "onPointerDown", "onPointerMove"]);
|
|
453
557
|
|
|
454
558
|
return (
|
|
455
|
-
<
|
|
559
|
+
<div
|
|
456
560
|
{...passed}
|
|
457
561
|
aria-disabled={disabled ? "true" : undefined}
|
|
458
562
|
aria-selected={combobox.value === value ? "true" : "false"}
|
|
@@ -484,7 +588,72 @@ export component ComboboxOption(
|
|
|
484
588
|
role="option"
|
|
485
589
|
>
|
|
486
590
|
{children}
|
|
487
|
-
</
|
|
591
|
+
</div>
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* A named group of options.
|
|
597
|
+
*
|
|
598
|
+
* The name reaches the group through `aria-labelledby`, and only while a
|
|
599
|
+
* `Combobox.GroupLabel` is rendered — the same rule, and the same reason, as
|
|
600
|
+
* `Select.Group` and `Menu.Group` before it.
|
|
601
|
+
*
|
|
602
|
+
* Nothing about `Combobox.Input` had to learn that groups exist. It asks for
|
|
603
|
+
* `[role="option"]` elements whose nearest `[role="listbox"]` is this list, and
|
|
604
|
+
* a group is not a listbox — so the arrow keys walk an option at a time across
|
|
605
|
+
* a boundary they cannot see, and the heading is never a place the cursor can
|
|
606
|
+
* land, because it is not an option.
|
|
607
|
+
*
|
|
608
|
+
* `children` is the true statement rather than a `React.Node` that would take
|
|
609
|
+
* anything: a `group` inside a `listbox` may own options and its own heading,
|
|
610
|
+
* and nothing else. `Select.Group` says the same since ubugeeei-prod/uf#562 —
|
|
611
|
+
* it is the same listbox, and it took a second breaking change to get there.
|
|
612
|
+
*/
|
|
613
|
+
export component ComboboxGroup(
|
|
614
|
+
children: renders* (ComboboxOption | ComboboxGroupLabel),
|
|
615
|
+
...rest: Rest
|
|
616
|
+
) {
|
|
617
|
+
const base = useId();
|
|
618
|
+
const [labelled, setLabelled] = useState(false);
|
|
619
|
+
|
|
620
|
+
const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
|
|
621
|
+
|
|
622
|
+
return (
|
|
623
|
+
<ComboboxGroupContext.Provider value={group}>
|
|
624
|
+
<div {...rest} aria-labelledby={labelled ? group.labelId : undefined} role="group">
|
|
625
|
+
{children}
|
|
626
|
+
</div>
|
|
627
|
+
</ComboboxGroupContext.Provider>
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The heading of a `Combobox.Group`.
|
|
633
|
+
*
|
|
634
|
+
* `role="presentation"` because the group already carries the name: left as
|
|
635
|
+
* ordinary content a reader would hear the heading once as the group's name and
|
|
636
|
+
* again as a stray line of text among the options.
|
|
637
|
+
*
|
|
638
|
+
* This is not `Combobox.Label`. That one names the field; this one names a
|
|
639
|
+
* group of options, and a combobox with groups has both.
|
|
640
|
+
*/
|
|
641
|
+
export component ComboboxGroupLabel(children: React.Node, ...rest: Rest) {
|
|
642
|
+
const group = useContext(ComboboxGroupContext);
|
|
643
|
+
const register = group?.registerLabel;
|
|
644
|
+
|
|
645
|
+
useEffect(() => {
|
|
646
|
+
if (register == null) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
register(true);
|
|
650
|
+
return () => register(false);
|
|
651
|
+
}, [register]);
|
|
652
|
+
|
|
653
|
+
return (
|
|
654
|
+
<div {...rest} id={group?.labelId} role="presentation">
|
|
655
|
+
{children}
|
|
656
|
+
</div>
|
|
488
657
|
);
|
|
489
658
|
}
|
|
490
659
|
|
|
@@ -495,7 +664,7 @@ export component ComboboxOption(
|
|
|
495
664
|
* contain options: an "no matches" row inside one is announced as an option a
|
|
496
665
|
* reader can choose, and choosing it does nothing.
|
|
497
666
|
*/
|
|
498
|
-
export component ComboboxEmpty(children: React.Node, ...rest:
|
|
667
|
+
export component ComboboxEmpty(children: React.Node, ...rest: Rest) {
|
|
499
668
|
const combobox = useCombobox("Combobox.Empty");
|
|
500
669
|
if (!combobox.open || combobox.count > 0) {
|
|
501
670
|
return null;
|
|
@@ -514,7 +683,7 @@ export component ComboboxEmpty(children: React.Node, ...rest: { readonly [string
|
|
|
514
683
|
* `children` overrides the wording — the default is English and a real
|
|
515
684
|
* application has a translation table.
|
|
516
685
|
*/
|
|
517
|
-
export component ComboboxStatus(children?: React.Node, ...rest:
|
|
686
|
+
export component ComboboxStatus(children?: React.Node, ...rest: Rest) {
|
|
518
687
|
const combobox = useCombobox("Combobox.Status");
|
|
519
688
|
const message = children ?? defaultAnnouncement(combobox.open, combobox.count);
|
|
520
689
|
|