@uniflowed/ui 0.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/accordion.js +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
package/select.js
ADDED
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// A select: a button that opens a list of options and takes one of them.
|
|
4
|
+
//
|
|
5
|
+
// This is the *select-only* combobox of ARIA 1.2, and `combobox.js` is the
|
|
6
|
+
// editable one. They are the two halves of the same pattern and they are two
|
|
7
|
+
// modules, because every key means something different in each:
|
|
8
|
+
//
|
|
9
|
+
// | | `Combobox` | `Select` |
|
|
10
|
+
// | ------------------- | ------------------------------ | --------------------------- |
|
|
11
|
+
// | Trigger | `<input type="text">` | `<button>` |
|
|
12
|
+
// | Printable keys | edit the text; caller filters | typeahead onto an option |
|
|
13
|
+
// | `Home` / `End` | left to the text cursor | first and last option |
|
|
14
|
+
// | `Enter`, nothing active | left to the form | opens, or takes the cursor |
|
|
15
|
+
// | Value | the text, which is not a value | the option, always |
|
|
16
|
+
//
|
|
17
|
+
// One module with a flag would have to guess which of those a keystroke meant,
|
|
18
|
+
// and a component that guesses gets both wrong — the same reason `switch.js`
|
|
19
|
+
// and `checkbox.js` are apart.
|
|
20
|
+
//
|
|
21
|
+
// What the two do share is the focus model, and it is the part hand-written
|
|
22
|
+
// selects get wrong. Focus never leaves the trigger. The arrow keys move
|
|
23
|
+
// `aria-activedescendant`, a second cursor naming which option is current while
|
|
24
|
+
// the real focus stays on the button, so the reader is told the option changed.
|
|
25
|
+
// A highlight drawn in CSS moves the same pixels and says nothing.
|
|
26
|
+
//
|
|
27
|
+
// # Why a listbox and not a native `<select>`
|
|
28
|
+
//
|
|
29
|
+
// A `<select>` is better than this component in every way a `<select>` can be:
|
|
30
|
+
// it is the platform's, it is announced correctly by software this package has
|
|
31
|
+
// never been tested against, on a phone it is a wheel the thumb already knows,
|
|
32
|
+
// it autofills, and it validates. **If a native `<select>` will do, use one** —
|
|
33
|
+
// that is not a disclaimer, it is the recommendation, and it is why this module
|
|
34
|
+
// exists rather than a `Select` that renders `<select>` and calls it headless.
|
|
35
|
+
// Wrapping the native control would add nothing a caller cannot write in one
|
|
36
|
+
// line, and this package's premise is that it only ships the part that is hard.
|
|
37
|
+
//
|
|
38
|
+
// The part that is hard is what a `<select>` cannot do: its popup is drawn by
|
|
39
|
+
// the operating system, so an option cannot hold an icon, a second line, a
|
|
40
|
+
// keyboard shortcut or a checkmark, and nothing about it can be styled. A
|
|
41
|
+
// design that needs any of that has exactly two options — this pattern, or a
|
|
42
|
+
// `div` with a `click` handler that no keyboard reaches. This module is the
|
|
43
|
+
// first one, done properly:
|
|
44
|
+
//
|
|
45
|
+
// * `role="combobox"` on the trigger with `aria-haspopup="listbox"`, so a
|
|
46
|
+
// reader is told what the button will do before pressing it.
|
|
47
|
+
// * The whole keyboard map below, including typeahead, which is the one every
|
|
48
|
+
// hand-written select omits and the one that makes a list of two hundred
|
|
49
|
+
// countries usable.
|
|
50
|
+
// * A hidden control carrying the value, so a form submits `GB` and not
|
|
51
|
+
// "United Kingdom" — `internal/form-value.js` says why it is an `<input>`.
|
|
52
|
+
//
|
|
53
|
+
// # The keyboard
|
|
54
|
+
//
|
|
55
|
+
// Closed, on the trigger:
|
|
56
|
+
//
|
|
57
|
+
// * `Enter`, `Space`, `ArrowDown`, `ArrowUp` open the list. The cursor lands
|
|
58
|
+
// on the *selected* option when there is one, not on the first: a list of
|
|
59
|
+
// two hundred countries opened onto "Afghanistan" when the reader had
|
|
60
|
+
// already chosen Zimbabwe is a list they have to arrow through twice.
|
|
61
|
+
// * `Home` and `End` open onto the first and last option, and deliberately
|
|
62
|
+
// ignore the selection — those two keys name a position, and answering a
|
|
63
|
+
// question about position with the current value is not an answer.
|
|
64
|
+
// * `Alt+ArrowDown` opens with no cursor at all, which is how a reader looks
|
|
65
|
+
// at the options without committing to moving among them.
|
|
66
|
+
// * A printable character opens the list and runs typeahead on it, so `f`
|
|
67
|
+
// from a closed select reaches France in one keystroke, the way every
|
|
68
|
+
// native select on every platform has always done.
|
|
69
|
+
//
|
|
70
|
+
// Open:
|
|
71
|
+
//
|
|
72
|
+
// * `ArrowDown` / `ArrowUp` move the cursor and **do not wrap**, which is
|
|
73
|
+
// where this differs from `menu.js` on purpose. A native menu cycles and a
|
|
74
|
+
// native select stops, and a reader's expectation comes from the platform
|
|
75
|
+
// control the widget imitates, not from the package it was shipped in.
|
|
76
|
+
// * `Home` / `End` go to the ends. This is the exact inverse of
|
|
77
|
+
// `combobox.js`, which leaves both keys to the text cursor, and the pair is
|
|
78
|
+
// the clearest single statement of why there are two components.
|
|
79
|
+
// * `Enter`, `Space` and `Alt+ArrowUp` take the option under the cursor and
|
|
80
|
+
// close.
|
|
81
|
+
// * `Escape` closes and changes nothing, and is stopped from travelling
|
|
82
|
+
// further so a select inside a dialog does not close the dialog too.
|
|
83
|
+
// * `Tab` takes the option under the cursor and moves on. This is APG's rule
|
|
84
|
+
// for the select-only combobox and it is the opposite of what `Combobox`
|
|
85
|
+
// does with the same key, which is worth a sentence because it looks like
|
|
86
|
+
// an inconsistency and is not. In an editable combobox the reader has typed
|
|
87
|
+
// something, the highlight is a suggestion about text they still own, and
|
|
88
|
+
// committing it on the way out turns "leave this field" into an edit. Here
|
|
89
|
+
// there is nothing typed and nothing to lose: moving the cursor *is* the
|
|
90
|
+
// act of choosing, and a select that discarded it on Tab would be the only
|
|
91
|
+
// select on the machine that did.
|
|
92
|
+
//
|
|
93
|
+
// # Moving the cursor does not change the value
|
|
94
|
+
//
|
|
95
|
+
// Arrowing sets `aria-activedescendant` and nothing else; the value changes on
|
|
96
|
+
// `Enter`, `Space`, `Alt+ArrowUp`, `Tab` and a click, and `Escape` leaves it
|
|
97
|
+
// alone. A native `<select>` on Windows does the opposite — the value follows
|
|
98
|
+
// the arrow keys — and copying it here would be a mistake with a cost the
|
|
99
|
+
// pattern does not have to pay. `onValueChange` is wired to a form store, a
|
|
100
|
+
// validation run or a server mutation, and selection-follows-focus fires all
|
|
101
|
+
// three once per arrow press: arrowing from the top of a country list to the
|
|
102
|
+
// bottom would be two hundred submissions.
|
|
103
|
+
//
|
|
104
|
+
// # Groups
|
|
105
|
+
//
|
|
106
|
+
// A `listbox` may own `option` and `group` elements, and nothing else. That
|
|
107
|
+
// one sentence decides three things here:
|
|
108
|
+
//
|
|
109
|
+
// * The parts are `div`s rather than the `ul`/`li` `combobox.js` uses.
|
|
110
|
+
// Nesting a group's options inside a listbox as a list means a second
|
|
111
|
+
// `list` role between the group and its options, which is a child ARIA does
|
|
112
|
+
// not allow the group to own.
|
|
113
|
+
// * `Select.GroupLabel` is `role="presentation"` and names its group through
|
|
114
|
+
// `aria-labelledby`, exactly as `Menu.Group` and `Menu.Label` do — and only
|
|
115
|
+
// while a label is actually rendered, because an `aria-labelledby` naming
|
|
116
|
+
// an id that is not in the document makes a reader hear nothing at all.
|
|
117
|
+
// * `Select.Separator` is `aria-hidden`, which is the one place it differs
|
|
118
|
+
// from `Menu.Separator`. A `separator` is a legal child of a `menu` and is
|
|
119
|
+
// announced there as "the group changed"; inside a `listbox` it is not a
|
|
120
|
+
// legal child, so the rule between two groups is decoration and is kept out
|
|
121
|
+
// of the tree. A reader who greps this package for `separator` finds both,
|
|
122
|
+
// and they are not the same thing.
|
|
123
|
+
//
|
|
124
|
+
// # What this module does not ship
|
|
125
|
+
//
|
|
126
|
+
// shadcn's Select has `ScrollUpButton` and `ScrollDownButton`. They are not
|
|
127
|
+
// here, and their absence is a decision rather than an omission: both exist to
|
|
128
|
+
// scroll a popup that Radix positions and sizes, and this package positions
|
|
129
|
+
// nothing and ships no styles, so a scroll button here would be a `button` with
|
|
130
|
+
// no idea what to scroll. The behaviour they are really for — the cursor
|
|
131
|
+
// staying visible as the arrow keys move it — is in this module already, as the
|
|
132
|
+
// `scrollIntoView({ block: "nearest" })` every move performs, and it works for
|
|
133
|
+
// a caller's own scroll container without either button.
|
|
134
|
+
|
|
135
|
+
"use client";
|
|
136
|
+
|
|
137
|
+
import * as React from "@uniflowed/react";
|
|
138
|
+
import {
|
|
139
|
+
createContext,
|
|
140
|
+
useContext,
|
|
141
|
+
useEffect,
|
|
142
|
+
useId,
|
|
143
|
+
useMemo,
|
|
144
|
+
useRef,
|
|
145
|
+
useState,
|
|
146
|
+
} from "@uniflowed/react";
|
|
147
|
+
import { useStableCallback } from "@uniflowed/hooks/lifecycle";
|
|
148
|
+
|
|
149
|
+
import type { Rest } from "./internal/merge-props.js";
|
|
150
|
+
import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
|
|
151
|
+
import type { Movement } from "./internal/roving-focus.js";
|
|
152
|
+
import { isTypeaheadKey, itemsOf, moveTo, useTypeahead } from "./internal/roving-focus.js";
|
|
153
|
+
import { useControlled } from "./internal/controlled-state.js";
|
|
154
|
+
import { FormValue } from "./internal/form-value.js";
|
|
155
|
+
|
|
156
|
+
const OPTION_SELECTOR = '[role="option"]';
|
|
157
|
+
const LISTBOX_SELECTOR = '[role="listbox"]';
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Where the cursor should go once the list is in the document.
|
|
161
|
+
*
|
|
162
|
+
* Everything that opens the list has an opinion about where the cursor lands,
|
|
163
|
+
* and none of it can be acted on yet: the options do not exist to be measured
|
|
164
|
+
* until the commit that renders the listbox. So the opinion is left here for
|
|
165
|
+
* `Select.List`'s effect to carry out — a ref rather than state, because
|
|
166
|
+
* nothing renders it and a re-render whose only purpose is to carry a message
|
|
167
|
+
* to an effect is a render nobody asked for.
|
|
168
|
+
*
|
|
169
|
+
* `preferSelected` is the difference between `ArrowDown`, which means "start
|
|
170
|
+
* from where I am", and `End`, which means "the last one" and must not be
|
|
171
|
+
* quietly answered with the current selection instead.
|
|
172
|
+
*/
|
|
173
|
+
type Landing =
|
|
174
|
+
| {| readonly kind: "end", readonly end: Movement, readonly preferSelected: boolean |}
|
|
175
|
+
| {| readonly kind: "typed", readonly key: string |};
|
|
176
|
+
|
|
177
|
+
type SelectState = {|
|
|
178
|
+
readonly base: string,
|
|
179
|
+
readonly open: boolean,
|
|
180
|
+
readonly setOpen: (open: boolean) => void,
|
|
181
|
+
readonly disabled: boolean,
|
|
182
|
+
/** The chosen option's value, or null when nothing is chosen. */
|
|
183
|
+
readonly value: string | null,
|
|
184
|
+
/** Take an option: sets the value, closes, and leaves focus on the trigger. */
|
|
185
|
+
readonly choose: (value: string) => void,
|
|
186
|
+
/** The id of the option `aria-activedescendant` names, if any. */
|
|
187
|
+
readonly activeId: string | null,
|
|
188
|
+
readonly setActiveId: (id: string | null) => void,
|
|
189
|
+
readonly pendingLanding: { current: Landing | null },
|
|
190
|
+
readonly triggerRef: { current: HTMLElement | null },
|
|
191
|
+
readonly listRef: { current: HTMLElement | null },
|
|
192
|
+
/**
|
|
193
|
+
* What `Select.Value` should display for a value, learned from the options.
|
|
194
|
+
*
|
|
195
|
+
* See `registerLabel` for why this only ever grows.
|
|
196
|
+
*/
|
|
197
|
+
readonly labels: { readonly [string]: string },
|
|
198
|
+
readonly registerLabel: (value: string, label: string) => void,
|
|
199
|
+
readonly labelled: boolean,
|
|
200
|
+
readonly registerFieldLabel: (present: boolean) => void,
|
|
201
|
+
/**
|
|
202
|
+
* Matching by the characters a reader types.
|
|
203
|
+
*
|
|
204
|
+
* Held here rather than made where it is used, because there is one buffer
|
|
205
|
+
* and two callers: the trigger runs it while the list is open, and
|
|
206
|
+
* `Select.List`'s effect runs it for the keystroke that *opened* the list.
|
|
207
|
+
* Two `useTypeahead()` calls would be two buffers, and typing "sa" fast
|
|
208
|
+
* enough to be one word would be read as "s" and then "a".
|
|
209
|
+
*/
|
|
210
|
+
readonly typeahead: (
|
|
211
|
+
items: $ReadOnlyArray<HTMLElement>,
|
|
212
|
+
from: number,
|
|
213
|
+
key: string,
|
|
214
|
+
) => HTMLElement | null,
|
|
215
|
+
|};
|
|
216
|
+
|
|
217
|
+
const SelectContext: React.Context<SelectState | null> = createContext(null);
|
|
218
|
+
|
|
219
|
+
hook useSelect(part: string): SelectState {
|
|
220
|
+
const state = useContext(SelectContext);
|
|
221
|
+
if (state == null) {
|
|
222
|
+
throw new Error(`${part} must be rendered inside a Select.Root`);
|
|
223
|
+
}
|
|
224
|
+
return state;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The id of a group's label, so `Select.Group` only claims one that exists. */
|
|
228
|
+
type SelectGroupState = {|
|
|
229
|
+
readonly labelId: string,
|
|
230
|
+
readonly registerLabel: (present: boolean) => void,
|
|
231
|
+
|};
|
|
232
|
+
|
|
233
|
+
const SelectGroupContext: React.Context<SelectGroupState | null> = createContext(null);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The select.
|
|
237
|
+
*
|
|
238
|
+
* `name` is the only thing here a form sees. Given one, the root renders a
|
|
239
|
+
* hidden control carrying the *value* — `internal/form-value.js` explains what
|
|
240
|
+
* it is and why it is not a concealed `<select>`. Without one, nothing is
|
|
241
|
+
* submitted, which is correct for a select that filters a table.
|
|
242
|
+
*
|
|
243
|
+
* A select bound to `@uniflowed/form` needs no `name`: that library keeps its
|
|
244
|
+
* values in its own store and prevents the native submission, so the binding is
|
|
245
|
+
* `useController` — `field.value` into `value` and `field.onChange` into
|
|
246
|
+
* `onValueChange`, which is exactly the pair of props below. Neither package
|
|
247
|
+
* imports the other and neither needs to.
|
|
248
|
+
*/
|
|
249
|
+
export component SelectRoot(
|
|
250
|
+
children: React.Node,
|
|
251
|
+
value?: string | null,
|
|
252
|
+
defaultValue?: string | null = null,
|
|
253
|
+
onValueChange?: (value: string | null) => void,
|
|
254
|
+
open?: boolean,
|
|
255
|
+
defaultOpen?: boolean = false,
|
|
256
|
+
onOpenChange?: (open: boolean) => void,
|
|
257
|
+
name?: string,
|
|
258
|
+
disabled?: boolean = false,
|
|
259
|
+
...rest: Rest
|
|
260
|
+
) {
|
|
261
|
+
const base = useId();
|
|
262
|
+
const [chosen, setChosen] = useControlled(value, defaultValue, onValueChange);
|
|
263
|
+
const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
|
|
264
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
265
|
+
const [labels, setLabels] = useState<{ readonly [string]: string }>({});
|
|
266
|
+
const [labelled, setLabelled] = useState(false);
|
|
267
|
+
const pendingLanding = useRef<Landing | null>(null);
|
|
268
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
269
|
+
const listRef = useRef<HTMLElement | null>(null);
|
|
270
|
+
const typeahead = useTypeahead();
|
|
271
|
+
|
|
272
|
+
const choose = useStableCallback((next: string) => {
|
|
273
|
+
setChosen(next);
|
|
274
|
+
setOpen(false);
|
|
275
|
+
setActiveId(null);
|
|
276
|
+
// Focus never left the trigger for a keyboard selection, and an option's
|
|
277
|
+
// `pointerdown` handler stops a click taking it either — but the trigger is
|
|
278
|
+
// where the next keystroke has to arrive, and asserting that here costs
|
|
279
|
+
// nothing and survives a caller who renders an option as something
|
|
280
|
+
// focusable.
|
|
281
|
+
triggerRef.current?.focus();
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Remember what an option's value is called.
|
|
286
|
+
*
|
|
287
|
+
* Only ever added to, and that is the whole design. The options are in the
|
|
288
|
+
* document while the list is open and gone when it is closed, which is
|
|
289
|
+
* exactly when `Select.Value` needs a label to show — so forgetting on
|
|
290
|
+
* unmount would blank the trigger the instant the reader chose something.
|
|
291
|
+
* The map is bounded by the number of distinct values the caller has
|
|
292
|
+
* rendered, which is the size of their own option list.
|
|
293
|
+
*/
|
|
294
|
+
const registerLabel = useStableCallback((optionValue: string, label: string) => {
|
|
295
|
+
setLabels((current) =>
|
|
296
|
+
current[optionValue] === label ? current : { ...current, [optionValue]: label },
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const state = useMemo(
|
|
301
|
+
() => ({
|
|
302
|
+
base,
|
|
303
|
+
open: isOpen,
|
|
304
|
+
setOpen,
|
|
305
|
+
disabled,
|
|
306
|
+
value: chosen,
|
|
307
|
+
choose,
|
|
308
|
+
activeId,
|
|
309
|
+
setActiveId,
|
|
310
|
+
pendingLanding,
|
|
311
|
+
triggerRef,
|
|
312
|
+
listRef,
|
|
313
|
+
labels,
|
|
314
|
+
registerLabel,
|
|
315
|
+
labelled,
|
|
316
|
+
registerFieldLabel: setLabelled,
|
|
317
|
+
typeahead,
|
|
318
|
+
}),
|
|
319
|
+
[
|
|
320
|
+
base,
|
|
321
|
+
isOpen,
|
|
322
|
+
setOpen,
|
|
323
|
+
disabled,
|
|
324
|
+
chosen,
|
|
325
|
+
choose,
|
|
326
|
+
activeId,
|
|
327
|
+
labels,
|
|
328
|
+
registerLabel,
|
|
329
|
+
labelled,
|
|
330
|
+
typeahead,
|
|
331
|
+
],
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<SelectContext.Provider value={state}>
|
|
336
|
+
<div {...rest}>
|
|
337
|
+
{children}
|
|
338
|
+
{name == null ? null : <FormValue disabled={disabled} name={name} value={chosen} />}
|
|
339
|
+
</div>
|
|
340
|
+
</SelectContext.Provider>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The field's label.
|
|
346
|
+
*
|
|
347
|
+
* A `<label htmlFor>` *and* an `aria-labelledby` on the trigger, and the second
|
|
348
|
+
* one is not redundant. `role="combobox"` is not a role that takes its name
|
|
349
|
+
* from its own content, and a `<label for>` pointing at a `<button>` does not
|
|
350
|
+
* name it either — HTML-AAM gives a button its name from its subtree, which the
|
|
351
|
+
* role has just ruled out. A select with only a `<label for>` was therefore a
|
|
352
|
+
* combobox with no accessible name at all, announced as "combobox" and nothing
|
|
353
|
+
* else, while looking correct in the markup and reading correctly to anyone
|
|
354
|
+
* who could see it. The `htmlFor` is kept for the behaviour it does carry: a
|
|
355
|
+
* click on the label focuses the trigger.
|
|
356
|
+
*
|
|
357
|
+
* This is `Select.Label` and it names the field. `Select.GroupLabel` names a
|
|
358
|
+
* group of options — the two are separate parts because a select has both, and
|
|
359
|
+
* shadcn's single `SelectLabel`, which is the group's, has no name for the
|
|
360
|
+
* field's.
|
|
361
|
+
*/
|
|
362
|
+
export component SelectLabel(children: React.Node, ...rest: Rest) {
|
|
363
|
+
const select = useSelect("Select.Label");
|
|
364
|
+
const register = select.registerFieldLabel;
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
register(true);
|
|
367
|
+
return () => register(false);
|
|
368
|
+
}, [register]);
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
<label {...rest} htmlFor={`${select.base}-trigger`} id={`${select.base}-label`}>
|
|
372
|
+
{children}
|
|
373
|
+
</label>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The button that opens the list, and every key the pattern defines.
|
|
379
|
+
*
|
|
380
|
+
* A `<button>` rather than the `div` with `tabindex="0"` the APG example uses,
|
|
381
|
+
* for the reason `menu.js` gives about its items: focusability, `disabled` and
|
|
382
|
+
* the focus ring are then the browser's rather than this component's, and a
|
|
383
|
+
* component that reimplements `disabled` gets one of its four behaviours wrong.
|
|
384
|
+
*
|
|
385
|
+
* `type="button"` because the whole point of this module is that it lives in
|
|
386
|
+
* forms, and a `<button>` inside a `<form>` submits it by default. A select
|
|
387
|
+
* that posted the form every time it was opened would be a memorable bug.
|
|
388
|
+
*/
|
|
389
|
+
export component SelectTrigger(children: React.Node, ...rest: Rest) {
|
|
390
|
+
const select = useSelect("Select.Trigger");
|
|
391
|
+
const passed = withoutComposed(rest, ["onClick", "onKeyDown", "ref"]);
|
|
392
|
+
|
|
393
|
+
/** The options in the document right now, in document order. */
|
|
394
|
+
const options = (): Array<HTMLElement> => {
|
|
395
|
+
const list = select.listRef.current;
|
|
396
|
+
return list == null ? [] : itemsOf(list, OPTION_SELECTOR, LISTBOX_SELECTOR);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const put = (option: HTMLElement | null) => {
|
|
400
|
+
if (option == null) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
select.setActiveId(option.id);
|
|
404
|
+
// `nearest`, so a list already showing the option does not jump under a
|
|
405
|
+
// reader who can see it.
|
|
406
|
+
(option as $FlowFixMe).scrollIntoView?.({ block: "nearest" });
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/** Move the cursor within an open list, or open with an instruction. */
|
|
410
|
+
const move = (end: Movement, preferSelected: boolean) => {
|
|
411
|
+
if (!select.open) {
|
|
412
|
+
select.pendingLanding.current = { kind: "end", end, preferSelected };
|
|
413
|
+
select.setOpen(true);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const items = options();
|
|
417
|
+
const at = items.findIndex((item) => item.id === select.activeId);
|
|
418
|
+
// `false`: the ends are closed. A native select stops at the last option
|
|
419
|
+
// and a native menu cycles, and this widget is the first kind.
|
|
420
|
+
put(moveTo(items, at, end, false));
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/** Take the option under the cursor, if there is one. */
|
|
424
|
+
const commit = (): boolean => {
|
|
425
|
+
const option = options().find((item) => item.id === select.activeId);
|
|
426
|
+
if (option == null) {
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
select.choose(option.getAttribute("data-value") ?? "");
|
|
430
|
+
return true;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
return (
|
|
434
|
+
<button
|
|
435
|
+
{...passed}
|
|
436
|
+
// Only while the list is in the document. Either attribute naming an
|
|
437
|
+
// element that is not there makes a screen reader announce nothing where
|
|
438
|
+
// it used to announce the current option.
|
|
439
|
+
aria-activedescendant={select.open ? (select.activeId ?? undefined) : undefined}
|
|
440
|
+
aria-controls={select.open ? `${select.base}-list` : undefined}
|
|
441
|
+
aria-expanded={select.open ? "true" : "false"}
|
|
442
|
+
aria-haspopup="listbox"
|
|
443
|
+
// Named only while a `Select.Label` is rendered: an `aria-labelledby`
|
|
444
|
+
// pointing at an id nothing has is worse than no name, because a reader
|
|
445
|
+
// is told nothing rather than told the button's own content.
|
|
446
|
+
aria-labelledby={select.labelled ? `${select.base}-label` : undefined}
|
|
447
|
+
disabled={select.disabled}
|
|
448
|
+
id={`${select.base}-trigger`}
|
|
449
|
+
onClick={composeHandlers(rest.onClick, () => {
|
|
450
|
+
if (select.open) {
|
|
451
|
+
select.setOpen(false);
|
|
452
|
+
select.setActiveId(null);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
select.pendingLanding.current = { kind: "end", end: "first", preferSelected: true };
|
|
456
|
+
select.setOpen(true);
|
|
457
|
+
})}
|
|
458
|
+
onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
|
|
459
|
+
if (select.disabled) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
464
|
+
event.preventDefault();
|
|
465
|
+
if (event.altKey) {
|
|
466
|
+
if (event.key === "ArrowDown") {
|
|
467
|
+
// Look without moving: the list opens with no cursor at all.
|
|
468
|
+
select.setOpen(true);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// `Alt+ArrowUp` is the collapse-and-take of a native select.
|
|
472
|
+
if (!commit()) {
|
|
473
|
+
select.setOpen(false);
|
|
474
|
+
select.setActiveId(null);
|
|
475
|
+
}
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
move(event.key === "ArrowDown" ? "next" : "previous", true);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (event.key === "Home" || event.key === "End") {
|
|
483
|
+
event.preventDefault();
|
|
484
|
+
// `preferSelected` false: these two keys name a position, and
|
|
485
|
+
// answering "the last one" with "the one you already chose" is not an
|
|
486
|
+
// answer to the question that was asked.
|
|
487
|
+
move(event.key === "Home" ? "first" : "last", false);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
492
|
+
// Prevented in both branches: `Enter` would submit the form this
|
|
493
|
+
// select is in, and `Space` would scroll the page and then arrive
|
|
494
|
+
// again as a click.
|
|
495
|
+
event.preventDefault();
|
|
496
|
+
if (!select.open) {
|
|
497
|
+
select.pendingLanding.current = { kind: "end", end: "first", preferSelected: true };
|
|
498
|
+
select.setOpen(true);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (!commit()) {
|
|
502
|
+
// Open with nothing under the cursor: close rather than sit there,
|
|
503
|
+
// which is what a reader who pressed Enter asked for.
|
|
504
|
+
select.setOpen(false);
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (event.key === "Escape") {
|
|
510
|
+
if (!select.open) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
event.preventDefault();
|
|
514
|
+
// A dialog around this select must not also close: one Escape is one
|
|
515
|
+
// dismissal, and the innermost thing wins.
|
|
516
|
+
event.stopPropagation();
|
|
517
|
+
select.setOpen(false);
|
|
518
|
+
select.setActiveId(null);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (event.key === "Tab") {
|
|
523
|
+
// Not prevented: Tab still moves on. It takes the cursor's option on
|
|
524
|
+
// the way out, which is APG's rule for this pattern and the opposite
|
|
525
|
+
// of `Combobox`'s — the module header says why the two differ.
|
|
526
|
+
if (select.open) {
|
|
527
|
+
commit();
|
|
528
|
+
select.setOpen(false);
|
|
529
|
+
select.setActiveId(null);
|
|
530
|
+
}
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (!isTypeaheadKey(event)) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (!select.open) {
|
|
538
|
+
event.preventDefault();
|
|
539
|
+
// The options are not in the document yet, so the keystroke travels
|
|
540
|
+
// to the commit that renders them.
|
|
541
|
+
select.pendingLanding.current = { kind: "typed", key: event.key };
|
|
542
|
+
select.setOpen(true);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const items = options();
|
|
546
|
+
const at = items.findIndex((item) => item.id === select.activeId);
|
|
547
|
+
const found = select.typeahead(items, at, event.key);
|
|
548
|
+
if (found != null) {
|
|
549
|
+
// Prevented only when something was found, the way `menu.js` does it:
|
|
550
|
+
// a letter that matches nothing here is a letter the browser's own
|
|
551
|
+
// find-as-you-type may still want.
|
|
552
|
+
event.preventDefault();
|
|
553
|
+
put(found);
|
|
554
|
+
}
|
|
555
|
+
})}
|
|
556
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
557
|
+
select.triggerRef.current = element;
|
|
558
|
+
})}
|
|
559
|
+
role="combobox"
|
|
560
|
+
type="button"
|
|
561
|
+
>
|
|
562
|
+
{children}
|
|
563
|
+
</button>
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* What the trigger shows for the current value.
|
|
569
|
+
*
|
|
570
|
+
* The content of a `role="combobox"` element is its *value*, not its name —
|
|
571
|
+
* which is why `Select.Label` exists and why this part may be plain text with
|
|
572
|
+
* no ARIA of its own.
|
|
573
|
+
*
|
|
574
|
+
* Four rules, in order, and the third is the one worth knowing about:
|
|
575
|
+
*
|
|
576
|
+
* 1. `children`, when the caller passed any. A caller who holds the option
|
|
577
|
+
* list as data already knows what `value` is called and this is how they
|
|
578
|
+
* say so.
|
|
579
|
+
* 2. The label of the option with that value, learned from the options
|
|
580
|
+
* themselves the first time the list was rendered and remembered after it
|
|
581
|
+
* closes.
|
|
582
|
+
* 3. The value itself, when it has never been seen as an option — a select
|
|
583
|
+
* whose `defaultValue` came from a saved form and whose list has not been
|
|
584
|
+
* opened yet. A reader hears "GB" rather than "United Kingdom", which is
|
|
585
|
+
* wrong and true; showing the placeholder there would be wrong and
|
|
586
|
+
* confident, telling a reader that nothing is chosen when something is.
|
|
587
|
+
* 4. The placeholder, only when nothing is chosen at all.
|
|
588
|
+
*
|
|
589
|
+
* Case 3 is a real edge and the way out of it is case 1.
|
|
590
|
+
*/
|
|
591
|
+
export component SelectValue(children?: React.Node, placeholder?: React.Node, ...rest: Rest) {
|
|
592
|
+
const select = useSelect("Select.Value");
|
|
593
|
+
const chosen = select.value;
|
|
594
|
+
|
|
595
|
+
if (children != null) {
|
|
596
|
+
return <span {...rest}>{children}</span>;
|
|
597
|
+
}
|
|
598
|
+
if (chosen == null) {
|
|
599
|
+
return <span {...rest}>{placeholder}</span>;
|
|
600
|
+
}
|
|
601
|
+
return <span {...rest}>{select.labels[chosen] ?? chosen}</span>;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The list of options, in the document only while it is open.
|
|
606
|
+
*
|
|
607
|
+
* `div`s rather than `combobox.js`'s `ul`/`li`, because a listbox with groups
|
|
608
|
+
* cannot be a list without putting a second `list` role between a group and the
|
|
609
|
+
* options it owns. The module header has the ARIA rule this follows from.
|
|
610
|
+
*
|
|
611
|
+
* The effect below keeps the one invariant this pattern rests on:
|
|
612
|
+
* `aria-activedescendant` never names an option that is not in the document.
|
|
613
|
+
*/
|
|
614
|
+
export component SelectList(
|
|
615
|
+
children: renders* (SelectOption | SelectGroup | SelectSeparator),
|
|
616
|
+
...rest: Rest
|
|
617
|
+
) {
|
|
618
|
+
const select = useSelect("Select.List");
|
|
619
|
+
const { activeId, listRef, pendingLanding, setActiveId, triggerRef, typeahead, value } = select;
|
|
620
|
+
const close = useStableCallback(() => {
|
|
621
|
+
select.setOpen(false);
|
|
622
|
+
select.setActiveId(null);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// No dependency list, for the reason `combobox.js` gives: what this reads is
|
|
626
|
+
// the *rendered* options, and a caller may render different ones on any
|
|
627
|
+
// render — a change to `children` that no dependency list can describe. Every
|
|
628
|
+
// write is guarded by a comparison, so it settles after one extra pass rather
|
|
629
|
+
// than looping.
|
|
630
|
+
useEffect(() => {
|
|
631
|
+
const list = listRef.current;
|
|
632
|
+
if (list == null) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const items = itemsOf(list, OPTION_SELECTOR, LISTBOX_SELECTOR);
|
|
636
|
+
|
|
637
|
+
const wanted = pendingLanding.current;
|
|
638
|
+
if (wanted != null) {
|
|
639
|
+
pendingLanding.current = null;
|
|
640
|
+
// An `if` rather than a `match` on `wanted.kind`, because matching on a
|
|
641
|
+
// property does not refine the object that property came from: inside
|
|
642
|
+
// `match (wanted.kind)` both arms still see the whole union, and `uf
|
|
643
|
+
// check` says so twice.
|
|
644
|
+
const landing =
|
|
645
|
+
wanted.kind === "typed"
|
|
646
|
+
? typeahead(items, -1, wanted.key)
|
|
647
|
+
: // The selected option when the key that opened the list meant
|
|
648
|
+
// "start from where I am", and when there is a selection to start
|
|
649
|
+
// from; the end that key named otherwise.
|
|
650
|
+
((wanted.preferSelected
|
|
651
|
+
? items.find((item) => item.getAttribute("data-value") === value)
|
|
652
|
+
: null) ?? moveTo(items, -1, wanted.end, false));
|
|
653
|
+
if (landing != null) {
|
|
654
|
+
setActiveId(landing.id);
|
|
655
|
+
(landing as $FlowFixMe).scrollIntoView?.({ block: "nearest" });
|
|
656
|
+
}
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (activeId != null && !items.some((item) => item.id === activeId)) {
|
|
661
|
+
// The option the cursor named has left the list. Clearing it is what
|
|
662
|
+
// keeps `aria-activedescendant` pointing only at ids that exist.
|
|
663
|
+
setActiveId(null);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Keyed on `select.open`, which is load-bearing: this component is mounted
|
|
668
|
+
// the whole time and only *renders* while the list is open, so keyed on the
|
|
669
|
+
// stable callbacks alone the effect would run once, on the commit where
|
|
670
|
+
// `listRef.current` was still null, and never attach the listener at all.
|
|
671
|
+
useEffect(() => {
|
|
672
|
+
const list = listRef.current;
|
|
673
|
+
if (list == null) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const document = list.ownerDocument;
|
|
677
|
+
const onOutsidePress = (event: Event) => {
|
|
678
|
+
const target: $FlowFixMe = event.target;
|
|
679
|
+
if (target == null || list.contains(target)) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// The trigger is not "outside": closing here and letting its own click
|
|
683
|
+
// reopen the list makes a press on the trigger a no-op that flickers.
|
|
684
|
+
const trigger = triggerRef.current;
|
|
685
|
+
if (trigger != null && trigger.contains(target)) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
close();
|
|
689
|
+
};
|
|
690
|
+
document.addEventListener("pointerdown", onOutsidePress, true);
|
|
691
|
+
return () => document.removeEventListener("pointerdown", onOutsidePress, true);
|
|
692
|
+
}, [select.open, close, listRef, triggerRef]);
|
|
693
|
+
|
|
694
|
+
if (!select.open) {
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const passed = withoutComposed(rest, ["ref"]);
|
|
699
|
+
|
|
700
|
+
return (
|
|
701
|
+
<div
|
|
702
|
+
{...passed}
|
|
703
|
+
aria-labelledby={select.labelled ? `${select.base}-label` : undefined}
|
|
704
|
+
id={`${select.base}-list`}
|
|
705
|
+
ref={composeRefs(rest.ref, (element) => {
|
|
706
|
+
listRef.current = element;
|
|
707
|
+
})}
|
|
708
|
+
role="listbox"
|
|
709
|
+
>
|
|
710
|
+
{children}
|
|
711
|
+
</div>
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* One option.
|
|
717
|
+
*
|
|
718
|
+
* Never focusable, and that is the invariant the whole pattern rests on: focus
|
|
719
|
+
* belongs to the trigger, and an option that could take it would leave the
|
|
720
|
+
* reader's keystrokes arriving somewhere with no key handler.
|
|
721
|
+
*
|
|
722
|
+
* `data-value` is how the trigger reads back what the cursor is on, because it
|
|
723
|
+
* finds the option in the document rather than in a registry that could
|
|
724
|
+
* disagree with the page — the reason `internal/roving-focus.js` gives.
|
|
725
|
+
*/
|
|
726
|
+
export component SelectOption(
|
|
727
|
+
value: string,
|
|
728
|
+
children: React.Node,
|
|
729
|
+
label?: string,
|
|
730
|
+
disabled?: boolean = false,
|
|
731
|
+
...rest: Rest
|
|
732
|
+
) {
|
|
733
|
+
const select = useSelect("Select.Option");
|
|
734
|
+
const id = useId();
|
|
735
|
+
const active = select.activeId === id;
|
|
736
|
+
const selected = select.value === value;
|
|
737
|
+
const passed = withoutComposed(rest, ["onClick", "onPointerDown", "onPointerMove", "ref"]);
|
|
738
|
+
const register = select.registerLabel;
|
|
739
|
+
const element = useRef<HTMLElement | null>(null);
|
|
740
|
+
|
|
741
|
+
// What `Select.Value` will show once this option has been unmounted with the
|
|
742
|
+
// list. Read from the DOM rather than from `children`, because `children` is
|
|
743
|
+
// a `React.Node` — an icon beside a word, a fragment, a caller's own
|
|
744
|
+
// component — and the only thing that reliably knows what it came out as is
|
|
745
|
+
// the element it came out in. `label` overrides it for the case where the
|
|
746
|
+
// rendered content is not what the trigger should say.
|
|
747
|
+
useEffect(() => {
|
|
748
|
+
register(value, label ?? textOf(element.current));
|
|
749
|
+
}, [register, value, label]);
|
|
750
|
+
|
|
751
|
+
return (
|
|
752
|
+
<div
|
|
753
|
+
{...passed}
|
|
754
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
755
|
+
aria-selected={selected ? "true" : "false"}
|
|
756
|
+
// For styling the cursor. `data-` rather than a class because this
|
|
757
|
+
// package ships no styles and the caller owns the class list.
|
|
758
|
+
data-active={active ? "true" : undefined}
|
|
759
|
+
data-value={value}
|
|
760
|
+
id={id}
|
|
761
|
+
onClick={composeHandlers(rest.onClick, () => {
|
|
762
|
+
if (!disabled) {
|
|
763
|
+
select.choose(value);
|
|
764
|
+
}
|
|
765
|
+
})}
|
|
766
|
+
// A press must not take focus off the trigger. Without this the trigger
|
|
767
|
+
// blurs on `pointerdown`, and every key the reader presses next arrives
|
|
768
|
+
// at the document instead of at this widget.
|
|
769
|
+
onPointerDown={composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
|
|
770
|
+
event.preventDefault();
|
|
771
|
+
})}
|
|
772
|
+
// The pointer moves the cursor so the keyboard and the mouse agree about
|
|
773
|
+
// which option `Enter` would take.
|
|
774
|
+
onPointerMove={composeHandlers(rest.onPointerMove, () => {
|
|
775
|
+
if (!disabled && !active) {
|
|
776
|
+
select.setActiveId(id);
|
|
777
|
+
}
|
|
778
|
+
})}
|
|
779
|
+
ref={composeRefs(rest.ref, (node) => {
|
|
780
|
+
element.current = node;
|
|
781
|
+
})}
|
|
782
|
+
role="option"
|
|
783
|
+
>
|
|
784
|
+
{children}
|
|
785
|
+
</div>
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* A named group of options.
|
|
791
|
+
*
|
|
792
|
+
* The name reaches the group through `aria-labelledby`, and only while a
|
|
793
|
+
* `Select.GroupLabel` is rendered — the same rule, and the same reason, as
|
|
794
|
+
* `Menu.Group`. The arrow keys pass over the label without stopping on it,
|
|
795
|
+
* because they only ever look for `role="option"`.
|
|
796
|
+
*/
|
|
797
|
+
export component SelectGroup(children: React.Node, ...rest: Rest) {
|
|
798
|
+
const base = useId();
|
|
799
|
+
const [labelled, setLabelled] = useState(false);
|
|
800
|
+
|
|
801
|
+
const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
|
|
802
|
+
|
|
803
|
+
return (
|
|
804
|
+
<SelectGroupContext.Provider value={group}>
|
|
805
|
+
<div {...rest} aria-labelledby={labelled ? group.labelId : undefined} role="group">
|
|
806
|
+
{children}
|
|
807
|
+
</div>
|
|
808
|
+
</SelectGroupContext.Provider>
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* The heading of a `Select.Group`.
|
|
814
|
+
*
|
|
815
|
+
* `role="presentation"` because the group already carries the name: left as
|
|
816
|
+
* ordinary content a reader would hear the heading once as the group's name and
|
|
817
|
+
* again as a stray line of text among the options.
|
|
818
|
+
*/
|
|
819
|
+
export component SelectGroupLabel(children: React.Node, ...rest: Rest) {
|
|
820
|
+
const group = useContext(SelectGroupContext);
|
|
821
|
+
const register = group?.registerLabel;
|
|
822
|
+
|
|
823
|
+
useEffect(() => {
|
|
824
|
+
if (register == null) {
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
register(true);
|
|
828
|
+
return () => register(false);
|
|
829
|
+
}, [register]);
|
|
830
|
+
|
|
831
|
+
return (
|
|
832
|
+
<div {...rest} id={group?.labelId} role="presentation">
|
|
833
|
+
{children}
|
|
834
|
+
</div>
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* A rule between groups of options.
|
|
840
|
+
*
|
|
841
|
+
* `aria-hidden`, and this is the one part that differs from `Menu.Separator`. A
|
|
842
|
+
* `separator` is a legal child of a `menu` and is announced there; a `listbox`
|
|
843
|
+
* may only own `option` and `group`, so a separator inside one is a child ARIA
|
|
844
|
+
* does not allow, and what a reader is told about an invalid listbox is up to
|
|
845
|
+
* the software rather than the specification. The rule between two groups is
|
|
846
|
+
* decoration, so it says so and stays out of the tree.
|
|
847
|
+
*/
|
|
848
|
+
export component SelectSeparator(...rest: Rest) {
|
|
849
|
+
return <div {...rest} aria-hidden="true" />;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** What an option came out as, for the trigger to show later. */
|
|
853
|
+
function textOf(element: HTMLElement | null): string {
|
|
854
|
+
return (element?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
855
|
+
}
|