@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/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
+ }