@uniflowed/ui 0.0.0-alpha.2 → 0.0.0-alpha.37
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 +550 -0
- package/carousel.js +410 -0
- package/checkbox.js +264 -0
- package/collapsible.js +169 -0
- package/combobox.js +728 -0
- package/context-menu.js +206 -0
- package/date-picker.js +346 -0
- package/dialog.js +523 -0
- package/drawer.js +490 -0
- package/field.js +387 -0
- package/hover-card.js +330 -0
- package/index.js +1699 -22
- package/input-otp.js +218 -0
- package/interactions.js +2163 -0
- package/internal/anchor.js +565 -0
- package/internal/controlled-state.js +65 -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 +285 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +823 -0
- package/menubar.js +287 -0
- package/navigation-menu.js +251 -0
- package/package.json +9 -9
- package/pagination.js +209 -0
- package/popover.js +343 -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 +902 -0
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +300 -0
- package/skeleton.js +159 -0
- package/slider.js +405 -0
- package/switch.js +81 -0
- package/table.js +502 -0
- package/tabs.js +289 -0
- package/toast.js +592 -0
- package/toggle-group.js +283 -0
- package/toggle.js +105 -0
- package/tooltip.js +400 -0
- package/internal/dialog.js +0 -236
- package/internal/field.js +0 -161
- package/internal/props.js +0 -78
- package/internal/switch.js +0 -122
- package/internal/tabs.js +0 -270
package/combobox.js
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A combobox: a text field with a list of options attached to it.
|
|
4
|
+
//
|
|
5
|
+
// It is the one widget in this package where focus does *not* move onto the
|
|
6
|
+
// items, and everything else about it follows from that. Focus has to stay in
|
|
7
|
+
// the text field — the reader is still typing — so the list is navigated with
|
|
8
|
+
// `aria-activedescendant`, a second, "virtual" cursor that names which option is
|
|
9
|
+
// current while the real one stays put. Getting that wrong is the classic
|
|
10
|
+
// broken autocomplete: the arrow keys move a highlight the sighted reader can
|
|
11
|
+
// see and the screen reader says nothing, because nothing it watches changed.
|
|
12
|
+
//
|
|
13
|
+
// The keyboard map, and what each key is protecting:
|
|
14
|
+
//
|
|
15
|
+
// * `ArrowDown` / `ArrowUp` open the list and move the active option, wrapping
|
|
16
|
+
// at the ends.
|
|
17
|
+
// * `Alt+ArrowDown` opens the list *without* moving, and `Alt+ArrowUp` closes
|
|
18
|
+
// it. This is how a reader looks at the options without committing to one.
|
|
19
|
+
// * `Enter` takes the active option. With no active option it does nothing —
|
|
20
|
+
// which is deliberate, because that is what lets a combobox inside a form
|
|
21
|
+
// still submit it.
|
|
22
|
+
// * `Escape` closes the list; pressed again, with the list already closed, it
|
|
23
|
+
// clears the field. It is also stopped from travelling any further, so a
|
|
24
|
+
// combobox inside a dialog does not close the dialog on the way past.
|
|
25
|
+
// * `Tab` closes the list and moves on *without* selecting. A list that
|
|
26
|
+
// commits whatever happened to be highlighted turns a keystroke meant to
|
|
27
|
+
// leave the field into an edit.
|
|
28
|
+
// * `Home` and `End` are deliberately left alone. They belong to the text
|
|
29
|
+
// cursor, and a combobox that steals them to jump to the first and last
|
|
30
|
+
// option has made its own text field harder to edit than a plain `<input>`.
|
|
31
|
+
//
|
|
32
|
+
// # The announcement
|
|
33
|
+
//
|
|
34
|
+
// A screen reader reader who types "ma" needs to be told that four options
|
|
35
|
+
// matched, and nothing about the list appearing says so: the options are not in
|
|
36
|
+
// the reading order, and `aria-activedescendant` only speaks when one becomes
|
|
37
|
+
// current. `Combobox.Status` is a polite live region carrying that count. It is
|
|
38
|
+
// rendered whether the list is open or not, on purpose — a live region inserted
|
|
39
|
+
// into the document at the same moment as its content is usually not announced
|
|
40
|
+
// at all, because the region has to be there to be watched before the thing it
|
|
41
|
+
// is watching changes.
|
|
42
|
+
//
|
|
43
|
+
// # Filtering belongs to the caller
|
|
44
|
+
//
|
|
45
|
+
// This component never filters. The options are whatever the caller rendered,
|
|
46
|
+
// and matching against `Combobox.Root`'s `inputValue` is application logic —
|
|
47
|
+
// fuzzy or prefix, accent-folding or not, local or from a server. What the
|
|
48
|
+
// component owns is everything that has to stay true *while* the list changes:
|
|
49
|
+
// the active option is cleared when the option it named is filtered away, the
|
|
50
|
+
// count is remeasured, and `aria-activedescendant` never names an id that has
|
|
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.
|
|
115
|
+
|
|
116
|
+
"use client";
|
|
117
|
+
|
|
118
|
+
import * as React from "@uniflowed/react";
|
|
119
|
+
import {
|
|
120
|
+
createContext,
|
|
121
|
+
useContext,
|
|
122
|
+
useEffect,
|
|
123
|
+
useId,
|
|
124
|
+
useMemo,
|
|
125
|
+
useRef,
|
|
126
|
+
useState,
|
|
127
|
+
} from "@uniflowed/react";
|
|
128
|
+
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
129
|
+
|
|
130
|
+
import type { Align, LogicalSide } from "./internal/anchor.js";
|
|
131
|
+
import { useAnchor } from "./internal/anchor.js";
|
|
132
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
133
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
134
|
+
import { itemsOf, moveTo } from "./internal/roving-focus.js";
|
|
135
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
136
|
+
import { FormValue } from "./internal/form-value.js";
|
|
137
|
+
|
|
138
|
+
export type { Align, LogicalSide, Side } from "./internal/anchor.js";
|
|
139
|
+
|
|
140
|
+
const OPTION_SELECTOR = '[role="option"]';
|
|
141
|
+
const LISTBOX_SELECTOR = '[role="listbox"]';
|
|
142
|
+
|
|
143
|
+
type ComboboxState = {|
|
|
144
|
+
readonly base: string,
|
|
145
|
+
readonly open: boolean,
|
|
146
|
+
readonly setOpen: (open: boolean) => void,
|
|
147
|
+
/** The chosen option's value, or null when nothing is chosen. */
|
|
148
|
+
readonly value: string | null,
|
|
149
|
+
/** The text in the field, which is not the value until something is chosen. */
|
|
150
|
+
readonly text: string,
|
|
151
|
+
readonly setText: (text: string) => void,
|
|
152
|
+
/** Take an option: sets the value, puts its label in the field, closes. */
|
|
153
|
+
readonly select: (value: string, label: string) => void,
|
|
154
|
+
/** Empty the field and the selection, which is what a second Escape does. */
|
|
155
|
+
readonly clear: () => void,
|
|
156
|
+
/** The id of the option `aria-activedescendant` names, if any. */
|
|
157
|
+
readonly activeId: string | null,
|
|
158
|
+
readonly setActiveId: (id: string | null) => void,
|
|
159
|
+
/**
|
|
160
|
+
* Which end to activate once the list is in the document.
|
|
161
|
+
*
|
|
162
|
+
* `ArrowDown` on a closed combobox opens it *and* lands on the first option,
|
|
163
|
+
* and the list does not exist to be measured until the next commit. A ref
|
|
164
|
+
* rather than state because nothing renders it.
|
|
165
|
+
*/
|
|
166
|
+
readonly pendingActive: { current: "first" | "last" | null },
|
|
167
|
+
readonly inputRef: { current: HTMLElement | null },
|
|
168
|
+
readonly listRef: { current: HTMLElement | null },
|
|
169
|
+
/** How many options are in the list, for the live region. */
|
|
170
|
+
readonly count: number,
|
|
171
|
+
readonly setCount: (count: number) => void,
|
|
172
|
+
readonly labelled: boolean,
|
|
173
|
+
readonly registerLabel: (present: boolean) => void,
|
|
174
|
+
|};
|
|
175
|
+
|
|
176
|
+
const ComboboxContext: React.Context<ComboboxState | null> = createContext(null);
|
|
177
|
+
|
|
178
|
+
hook useCombobox(part: string): ComboboxState {
|
|
179
|
+
const state = useContext(ComboboxContext);
|
|
180
|
+
if (state == null) {
|
|
181
|
+
throw new Error(`${part} must be rendered inside a Combobox.Root`);
|
|
182
|
+
}
|
|
183
|
+
return state;
|
|
184
|
+
}
|
|
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
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The combobox.
|
|
196
|
+
*
|
|
197
|
+
* Three separate things a caller may own, because applications own different
|
|
198
|
+
* ones: `value` is what has been chosen, `inputValue` is what is typed, and
|
|
199
|
+
* `open` is whether the list is showing. A search box owns the text and nothing
|
|
200
|
+
* else; a form field owns the value; a page with a "browse all" button owns
|
|
201
|
+
* `open`. Tying them together would make two of those three impossible.
|
|
202
|
+
*
|
|
203
|
+
* `name` is what a form submits, and it exists because that same distinction
|
|
204
|
+
* had a hole in it. `Combobox.Input` renders the *text* — the label the reader
|
|
205
|
+
* sees — so a combobox named `country` inside a `<form>` submitted "United
|
|
206
|
+
* Kingdom" where the application meant `GB`, silently and only in production.
|
|
207
|
+
* Given a `name`, the root renders a hidden control carrying `value` instead;
|
|
208
|
+
* `internal/form-value.js` says why it is an `<input>` and why
|
|
209
|
+
* `@uniflowed/form` does not need it.
|
|
210
|
+
*/
|
|
211
|
+
export component ComboboxRoot(
|
|
212
|
+
children: React.Node,
|
|
213
|
+
value?: string | null,
|
|
214
|
+
defaultValue?: string | null = null,
|
|
215
|
+
onValueChange?: (value: string | null) => void,
|
|
216
|
+
inputValue?: string,
|
|
217
|
+
defaultInputValue?: string = "",
|
|
218
|
+
onInputValueChange?: (text: string) => void,
|
|
219
|
+
open?: boolean,
|
|
220
|
+
defaultOpen?: boolean = false,
|
|
221
|
+
onOpenChange?: (open: boolean) => void,
|
|
222
|
+
name?: string,
|
|
223
|
+
...rest: Rest
|
|
224
|
+
) {
|
|
225
|
+
const base = useId();
|
|
226
|
+
const [chosen, setChosen] = useControlled(value, defaultValue, onValueChange);
|
|
227
|
+
const [text, setText] = useControlled(inputValue, defaultInputValue, onInputValueChange);
|
|
228
|
+
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
229
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
230
|
+
const [count, setCount] = useState(0);
|
|
231
|
+
const [labelled, setLabelled] = useState(false);
|
|
232
|
+
const pendingActive = useRef<"first" | "last" | null>(null);
|
|
233
|
+
const inputRef = useRef<HTMLElement | null>(null);
|
|
234
|
+
const listRef = useRef<HTMLElement | null>(null);
|
|
235
|
+
|
|
236
|
+
// Stable, so the parts below can hold on to them without re-subscribing every
|
|
237
|
+
// time the caller re-renders with a fresh `onValueChange`.
|
|
238
|
+
const select = useStableCallback((next: string, label: string) => {
|
|
239
|
+
setChosen(next);
|
|
240
|
+
setText(label);
|
|
241
|
+
setOpen(false);
|
|
242
|
+
setActiveId(null);
|
|
243
|
+
// Focus never left the field for a keyboard selection; it did for a click
|
|
244
|
+
// on an option, and it has to come back or the next keystroke goes nowhere.
|
|
245
|
+
inputRef.current?.focus();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const clear = useStableCallback(() => {
|
|
249
|
+
setChosen(null);
|
|
250
|
+
setText("");
|
|
251
|
+
setActiveId(null);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const state = useMemo(
|
|
255
|
+
() => ({
|
|
256
|
+
base,
|
|
257
|
+
open: isOpen,
|
|
258
|
+
setOpen,
|
|
259
|
+
value: chosen,
|
|
260
|
+
text,
|
|
261
|
+
setText,
|
|
262
|
+
select,
|
|
263
|
+
clear,
|
|
264
|
+
activeId,
|
|
265
|
+
setActiveId,
|
|
266
|
+
pendingActive,
|
|
267
|
+
inputRef,
|
|
268
|
+
listRef,
|
|
269
|
+
count,
|
|
270
|
+
setCount,
|
|
271
|
+
labelled,
|
|
272
|
+
registerLabel: setLabelled,
|
|
273
|
+
}),
|
|
274
|
+
[base, isOpen, setOpen, chosen, text, setText, select, clear, activeId, count, labelled],
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<ComboboxContext.Provider value={state}>
|
|
279
|
+
<div {...rest}>
|
|
280
|
+
{children}
|
|
281
|
+
{name == null ? null : <FormValue name={name} value={chosen} />}
|
|
282
|
+
</div>
|
|
283
|
+
</ComboboxContext.Provider>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The field's label.
|
|
289
|
+
*
|
|
290
|
+
* A real `<label for>`, so clicking it focuses the field and so the name comes
|
|
291
|
+
* from the same place for the field and for the list. It registers itself
|
|
292
|
+
* because the list names it, and naming a label that is not rendered is worse
|
|
293
|
+
* than leaving the list unnamed.
|
|
294
|
+
*/
|
|
295
|
+
export component ComboboxLabel(children: React.Node, ...rest: Rest) {
|
|
296
|
+
const combobox = useCombobox("Combobox.Label");
|
|
297
|
+
const register = combobox.registerLabel;
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
register(true);
|
|
300
|
+
return () => register(false);
|
|
301
|
+
}, [register]);
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<label {...rest} htmlFor={`${combobox.base}-input`} id={`${combobox.base}-label`}>
|
|
305
|
+
{children}
|
|
306
|
+
</label>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The text field, and every key the pattern defines. */
|
|
311
|
+
export component ComboboxInput(...rest: Rest) {
|
|
312
|
+
const combobox = useCombobox("Combobox.Input");
|
|
313
|
+
const passed = withoutComposed(rest, ["onChange", "onKeyDown", "ref"]);
|
|
314
|
+
|
|
315
|
+
/** The options in the document right now, in document order. */
|
|
316
|
+
const options = (): Array<HTMLElement> => {
|
|
317
|
+
const list = combobox.listRef.current;
|
|
318
|
+
return list == null ? [] : itemsOf(list, OPTION_SELECTOR, LISTBOX_SELECTOR);
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const move = (movement: "previous" | "next") => {
|
|
322
|
+
const items = options();
|
|
323
|
+
if (items.length === 0) {
|
|
324
|
+
// The list is not in the document yet, so leave an instruction for the
|
|
325
|
+
// commit that puts it there.
|
|
326
|
+
combobox.pendingActive.current = movement === "next" ? "first" : "last";
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const at = items.findIndex((item) => item.id === combobox.activeId);
|
|
330
|
+
const next = moveTo(items, at, movement, true);
|
|
331
|
+
if (next == null) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
combobox.setActiveId(next.id);
|
|
335
|
+
// `nearest`, so a list that is already showing the option does not jump.
|
|
336
|
+
(next as $FlowFixMe).scrollIntoView?.({ block: "nearest" });
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const take = (element: HTMLElement) => {
|
|
340
|
+
combobox.select(element.getAttribute("data-value") ?? "", labelOf(element));
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<input
|
|
345
|
+
{...passed}
|
|
346
|
+
// Only while the list is in the document. `aria-activedescendant` naming
|
|
347
|
+
// an option that has been filtered away, or `aria-controls` naming a
|
|
348
|
+
// listbox that is not rendered, both make a screen reader announce
|
|
349
|
+
// nothing rather than announce something slightly wrong.
|
|
350
|
+
aria-activedescendant={combobox.open ? (combobox.activeId ?? undefined) : undefined}
|
|
351
|
+
// "list": the field's own text is never rewritten by the component, so
|
|
352
|
+
// this is not `both` (inline completion) and not `none`.
|
|
353
|
+
aria-autocomplete="list"
|
|
354
|
+
aria-controls={combobox.open ? `${combobox.base}-list` : undefined}
|
|
355
|
+
aria-expanded={combobox.open ? "true" : "false"}
|
|
356
|
+
// The browser's own dropdown would sit on top of this one.
|
|
357
|
+
autoComplete="off"
|
|
358
|
+
id={`${combobox.base}-input`}
|
|
359
|
+
onChange={composeHandlers(rest.onChange, (event: $FlowFixMe) => {
|
|
360
|
+
combobox.setText(event.target.value);
|
|
361
|
+
combobox.setOpen(true);
|
|
362
|
+
// Typing invalidates the highlight: the option that was current may not
|
|
363
|
+
// even be in the filtered list any more, and carrying it over means
|
|
364
|
+
// Enter takes something the reader can no longer see.
|
|
365
|
+
combobox.setActiveId(null);
|
|
366
|
+
})}
|
|
367
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
|
|
368
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
369
|
+
event.preventDefault();
|
|
370
|
+
if (event.altKey) {
|
|
371
|
+
// Look without moving, and close without choosing.
|
|
372
|
+
combobox.setOpen(event.key === "ArrowDown");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
combobox.setOpen(true);
|
|
376
|
+
move(event.key === "ArrowDown" ? "next" : "previous");
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (event.key === "Enter") {
|
|
381
|
+
const chosen = options().find((item) => item.id === combobox.activeId);
|
|
382
|
+
if (!combobox.open || chosen == null) {
|
|
383
|
+
// Nothing is highlighted, so this keystroke is the form's.
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
event.preventDefault();
|
|
387
|
+
take(chosen);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (event.key === "Escape") {
|
|
392
|
+
event.preventDefault();
|
|
393
|
+
// A dialog around this combobox must not also close: one Escape is
|
|
394
|
+
// one dismissal, and the innermost thing wins.
|
|
395
|
+
event.stopPropagation();
|
|
396
|
+
if (combobox.open) {
|
|
397
|
+
combobox.setOpen(false);
|
|
398
|
+
combobox.setActiveId(null);
|
|
399
|
+
} else {
|
|
400
|
+
combobox.clear();
|
|
401
|
+
}
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (event.key === "Tab" && combobox.open) {
|
|
406
|
+
// Not prevented, and nothing is taken: Tab is how a reader leaves a
|
|
407
|
+
// field, not how they commit to a highlight they were only passing.
|
|
408
|
+
combobox.setOpen(false);
|
|
409
|
+
combobox.setActiveId(null);
|
|
410
|
+
}
|
|
411
|
+
})}
|
|
412
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
413
|
+
combobox.inputRef.current = element;
|
|
414
|
+
})}
|
|
415
|
+
role="combobox"
|
|
416
|
+
type="text"
|
|
417
|
+
value={combobox.text}
|
|
418
|
+
/>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The list of options, in the document only while it is open.
|
|
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
|
+
*
|
|
429
|
+
* It also keeps the two things that have to stay true as the caller filters:
|
|
430
|
+
* the count the live region announces, and the invariant that
|
|
431
|
+
* `aria-activedescendant` never names an option that has left the list.
|
|
432
|
+
*/
|
|
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
|
+
) {
|
|
443
|
+
const combobox = useCombobox("Combobox.List");
|
|
444
|
+
const { activeId, count, listRef, inputRef, pendingActive, setActiveId, setCount } = combobox;
|
|
445
|
+
const close = useStableCallback(() => {
|
|
446
|
+
combobox.setOpen(false);
|
|
447
|
+
combobox.setActiveId(null);
|
|
448
|
+
});
|
|
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
|
+
|
|
466
|
+
// No dependency list on purpose: what this reads is the *rendered* options,
|
|
467
|
+
// and they change whenever the caller re-filters — which is a change to
|
|
468
|
+
// `children` that no dependency list can describe. Every write below is
|
|
469
|
+
// guarded by a comparison, so the effect settles after one extra pass rather
|
|
470
|
+
// than looping.
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
const list = listRef.current;
|
|
473
|
+
if (list == null) {
|
|
474
|
+
// Closed. The live region must not keep announcing options that are no
|
|
475
|
+
// longer in the document.
|
|
476
|
+
if (count !== 0) {
|
|
477
|
+
setCount(0);
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const items = itemsOf(list, OPTION_SELECTOR, LISTBOX_SELECTOR);
|
|
482
|
+
if (items.length !== count) {
|
|
483
|
+
setCount(items.length);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const wanted = pendingActive.current;
|
|
487
|
+
if (wanted != null) {
|
|
488
|
+
pendingActive.current = null;
|
|
489
|
+
setActiveId(moveTo(items, -1, wanted, false)?.id ?? null);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (activeId != null && !items.some((item) => item.id === activeId)) {
|
|
493
|
+
// The active option was filtered away. Clearing it is what keeps
|
|
494
|
+
// `aria-activedescendant` pointing only at ids that exist.
|
|
495
|
+
setActiveId(null);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Keyed on `combobox.open`, and that is load-bearing. This component is
|
|
500
|
+
// mounted the whole time and only *renders* while the list is open, so keyed
|
|
501
|
+
// on the stable callbacks alone the effect ran once — on the first commit,
|
|
502
|
+
// when `listRef.current` was still null — and never again. The listener was
|
|
503
|
+
// never attached, and a press outside the combobox closed nothing.
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
const list = listRef.current;
|
|
506
|
+
if (list == null) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
const document = list.ownerDocument;
|
|
510
|
+
const onOutsidePress = (event: Event) => {
|
|
511
|
+
const target: $FlowFixMe = event.target;
|
|
512
|
+
if (target == null || list.contains(target)) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// The field is not "outside": pressing it is how a reader gets back to
|
|
516
|
+
// typing, and closing on it would fight the input's own handlers.
|
|
517
|
+
const input = inputRef.current;
|
|
518
|
+
if (input != null && input.contains(target)) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
close();
|
|
522
|
+
};
|
|
523
|
+
document.addEventListener("pointerdown", onOutsidePress, true);
|
|
524
|
+
return () => document.removeEventListener("pointerdown", onOutsidePress, true);
|
|
525
|
+
}, [combobox.open, close, listRef, inputRef]);
|
|
526
|
+
|
|
527
|
+
if (!combobox.open) {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const passed = withoutComposed(rest, ["ref"]);
|
|
532
|
+
|
|
533
|
+
return (
|
|
534
|
+
<div
|
|
535
|
+
{...passed}
|
|
536
|
+
aria-labelledby={combobox.labelled ? `${combobox.base}-label` : undefined}
|
|
537
|
+
data-align={anchored.align}
|
|
538
|
+
data-side={anchored.side}
|
|
539
|
+
id={`${combobox.base}-list`}
|
|
540
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
541
|
+
listRef.current = element;
|
|
542
|
+
})}
|
|
543
|
+
role="listbox"
|
|
544
|
+
>
|
|
545
|
+
{children}
|
|
546
|
+
</div>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* One option.
|
|
552
|
+
*
|
|
553
|
+
* Never focusable: focus belongs to the text field, and an option that can take
|
|
554
|
+
* it would break the one invariant this pattern rests on. `data-value` and
|
|
555
|
+
* `data-label` are how the field reads back what was chosen, because the field
|
|
556
|
+
* finds the active option in the document rather than in a registry that could
|
|
557
|
+
* disagree with it.
|
|
558
|
+
*/
|
|
559
|
+
export component ComboboxOption(
|
|
560
|
+
value: string,
|
|
561
|
+
children: React.Node,
|
|
562
|
+
label?: string,
|
|
563
|
+
disabled?: boolean = false,
|
|
564
|
+
...rest: Rest
|
|
565
|
+
) {
|
|
566
|
+
const combobox = useCombobox("Combobox.Option");
|
|
567
|
+
const id = useId();
|
|
568
|
+
const active = combobox.activeId === id;
|
|
569
|
+
const passed = withoutComposed(rest, ["onClick", "onPointerDown", "onPointerMove"]);
|
|
570
|
+
|
|
571
|
+
return (
|
|
572
|
+
<div
|
|
573
|
+
{...passed}
|
|
574
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
575
|
+
aria-selected={combobox.value === value ? "true" : "false"}
|
|
576
|
+
// For styling the highlight. It is `data-` rather than a class because
|
|
577
|
+
// this package ships no styles and the caller owns the class list.
|
|
578
|
+
data-active={active ? "true" : undefined}
|
|
579
|
+
data-label={label}
|
|
580
|
+
data-value={value}
|
|
581
|
+
id={id}
|
|
582
|
+
onClick={composeHandlers(rest.onClick, (event: $FlowFixMe) => {
|
|
583
|
+
if (disabled) {
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
combobox.select(value, label ?? textOf(event.currentTarget));
|
|
587
|
+
})}
|
|
588
|
+
// A press must not take focus off the field. Without this the field blurs
|
|
589
|
+
// on `mousedown`, the list closes, and the `click` that follows lands on
|
|
590
|
+
// nothing — which is why so many autocompletes cannot be clicked at all.
|
|
591
|
+
onPointerDown={composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
|
|
592
|
+
event.preventDefault();
|
|
593
|
+
})}
|
|
594
|
+
// The pointer moves the highlight so the keyboard and the mouse agree on
|
|
595
|
+
// which option `Enter` would take.
|
|
596
|
+
onPointerMove={composeHandlers(rest.onPointerMove, () => {
|
|
597
|
+
if (!disabled && !active) {
|
|
598
|
+
combobox.setActiveId(id);
|
|
599
|
+
}
|
|
600
|
+
})}
|
|
601
|
+
role="option"
|
|
602
|
+
>
|
|
603
|
+
{children}
|
|
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 the true statement rather than a `React.Node` that would take
|
|
622
|
+
* anything: a `group` inside a `listbox` may own options and its own heading,
|
|
623
|
+
* and nothing else. `Select.Group` says the same since ubugeeei-prod/uf#562 —
|
|
624
|
+
* it is the same listbox, and it took a second breaking change to get there.
|
|
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>
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* What to show when the caller filtered everything away.
|
|
675
|
+
*
|
|
676
|
+
* Rendered beside the list rather than inside it, because a listbox may only
|
|
677
|
+
* contain options: an "no matches" row inside one is announced as an option a
|
|
678
|
+
* reader can choose, and choosing it does nothing.
|
|
679
|
+
*/
|
|
680
|
+
export component ComboboxEmpty(children: React.Node, ...rest: Rest) {
|
|
681
|
+
const combobox = useCombobox("Combobox.Empty");
|
|
682
|
+
if (!combobox.open || combobox.count > 0) {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
return <div {...rest}>{children}</div>;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* The live region that tells a screen reader how many options matched.
|
|
690
|
+
*
|
|
691
|
+
* Always in the document, even when the list is closed. A live region added to
|
|
692
|
+
* the page in the same commit as the text it holds is usually not announced,
|
|
693
|
+
* because the technology watching it had nothing to watch until it was already
|
|
694
|
+
* too late; leaving it mounted and empty is what makes the *next* change speak.
|
|
695
|
+
*
|
|
696
|
+
* `children` overrides the wording — the default is English and a real
|
|
697
|
+
* application has a translation table.
|
|
698
|
+
*/
|
|
699
|
+
export component ComboboxStatus(children?: React.Node, ...rest: Rest) {
|
|
700
|
+
const combobox = useCombobox("Combobox.Status");
|
|
701
|
+
const message = children ?? defaultAnnouncement(combobox.open, combobox.count);
|
|
702
|
+
|
|
703
|
+
return (
|
|
704
|
+
<div {...rest} aria-atomic="true" aria-live="polite" role="status">
|
|
705
|
+
{message}
|
|
706
|
+
</div>
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** The wording `Combobox.Status` uses when the caller supplies none. */
|
|
711
|
+
function defaultAnnouncement(open: boolean, count: number): string {
|
|
712
|
+
if (!open) {
|
|
713
|
+
return "";
|
|
714
|
+
}
|
|
715
|
+
if (count === 0) {
|
|
716
|
+
return "No results available.";
|
|
717
|
+
}
|
|
718
|
+
return count === 1 ? "1 result available." : `${count} results available.`;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** What a reader hears for an option: its explicit label, or its own text. */
|
|
722
|
+
function labelOf(element: HTMLElement): string {
|
|
723
|
+
return element.getAttribute("data-label") ?? textOf(element);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function textOf(element: HTMLElement): string {
|
|
727
|
+
return (element.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
728
|
+
}
|