@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.41

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.
Files changed (52) hide show
  1. package/accordion.js +362 -0
  2. package/alert-dialog.js +284 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +280 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +547 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +216 -31
  9. package/collapsible.js +171 -0
  10. package/combobox.js +235 -47
  11. package/context-menu.js +215 -0
  12. package/date-picker.js +357 -0
  13. package/dialog.js +235 -197
  14. package/drawer.js +504 -0
  15. package/field.js +257 -42
  16. package/hover-card.js +334 -0
  17. package/index.js +1548 -24
  18. package/input-otp.js +218 -0
  19. package/interactions.js +2327 -0
  20. package/internal/anchor.js +565 -0
  21. package/internal/date-grid.js +260 -0
  22. package/internal/disclosure.js +298 -0
  23. package/internal/focus.js +64 -0
  24. package/internal/form-value.js +83 -0
  25. package/internal/hover-intent.js +259 -0
  26. package/internal/menu-tree.js +228 -0
  27. package/internal/merge-props.js +206 -7
  28. package/internal/range.js +147 -0
  29. package/internal/roving-focus.js +207 -11
  30. package/menu.js +558 -340
  31. package/menubar.js +295 -0
  32. package/navigation-menu.js +251 -0
  33. package/package.json +8 -12
  34. package/pagination.js +209 -0
  35. package/popover.js +367 -0
  36. package/progress.js +91 -0
  37. package/radio-group.js +304 -0
  38. package/resizable.js +453 -0
  39. package/scroll-area.js +283 -0
  40. package/select.js +901 -0
  41. package/separator.js +97 -0
  42. package/sheet.js +189 -0
  43. package/sidebar.js +320 -0
  44. package/skeleton.js +163 -0
  45. package/slider.js +411 -0
  46. package/switch.js +43 -34
  47. package/table.js +520 -0
  48. package/tabs.js +99 -96
  49. package/toast.js +594 -0
  50. package/toggle-group.js +284 -0
  51. package/toggle.js +105 -0
  52. package/tooltip.js +404 -0
package/select.js ADDED
@@ -0,0 +1,901 @@
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 { useInteractOutside } from "./interactions.js";
150
+
151
+ import type { Align, LogicalSide } from "./internal/anchor.js";
152
+ import { useAnchor } from "./internal/anchor.js";
153
+ import type { Rest } from "./internal/merge-props.js";
154
+ import { composeHandlers, composeRefs, withoutComposed } from "./internal/merge-props.js";
155
+ import type { Movement } from "./internal/roving-focus.js";
156
+ import { isTypeaheadKey, itemsOf, moveTo, useTypeahead } from "./internal/roving-focus.js";
157
+ import { useControlled } from "./internal/controlled-state.js";
158
+ import { FormValue } from "./internal/form-value.js";
159
+
160
+ export type { Align, LogicalSide, Side } from "./internal/anchor.js";
161
+
162
+ const OPTION_SELECTOR = '[role="option"]';
163
+ const LISTBOX_SELECTOR = '[role="listbox"]';
164
+
165
+ /**
166
+ * Where the cursor should go once the list is in the document.
167
+ *
168
+ * Everything that opens the list has an opinion about where the cursor lands,
169
+ * and none of it can be acted on yet: the options do not exist to be measured
170
+ * until the commit that renders the listbox. So the opinion is left here for
171
+ * `Select.List`'s effect to carry out — a ref rather than state, because
172
+ * nothing renders it and a re-render whose only purpose is to carry a message
173
+ * to an effect is a render nobody asked for.
174
+ *
175
+ * `preferSelected` is the difference between `ArrowDown`, which means "start
176
+ * from where I am", and `End`, which means "the last one" and must not be
177
+ * quietly answered with the current selection instead.
178
+ */
179
+ type Landing =
180
+ | {| readonly kind: "end", readonly end: Movement, readonly preferSelected: boolean |}
181
+ | {| readonly kind: "typed", readonly key: string |};
182
+
183
+ type SelectState = {|
184
+ readonly base: string,
185
+ readonly open: boolean,
186
+ readonly setOpen: (open: boolean) => void,
187
+ readonly disabled: boolean,
188
+ /** The chosen option's value, or null when nothing is chosen. */
189
+ readonly value: string | null,
190
+ /** Take an option: sets the value, closes, and leaves focus on the trigger. */
191
+ readonly choose: (value: string) => void,
192
+ /** The id of the option `aria-activedescendant` names, if any. */
193
+ readonly activeId: string | null,
194
+ readonly setActiveId: (id: string | null) => void,
195
+ readonly pendingLandingRef: { current: Landing | null },
196
+ readonly triggerRef: { current: HTMLElement | null },
197
+ readonly listRef: { current: HTMLElement | null },
198
+ /**
199
+ * What `Select.Value` should display for a value, learned from the options.
200
+ *
201
+ * See `registerLabel` for why this only ever grows.
202
+ */
203
+ readonly labels: { readonly [string]: string },
204
+ readonly registerLabel: (value: string, label: string) => void,
205
+ readonly labelled: boolean,
206
+ readonly registerFieldLabel: (present: boolean) => void,
207
+ /**
208
+ * Matching by the characters a reader types.
209
+ *
210
+ * Held here rather than made where it is used, because there is one buffer
211
+ * and two callers: the trigger runs it while the list is open, and
212
+ * `Select.List`'s effect runs it for the keystroke that *opened* the list.
213
+ * Two `useTypeahead()` calls would be two buffers, and typing "sa" fast
214
+ * enough to be one word would be read as "s" and then "a".
215
+ */
216
+ readonly typeahead: (
217
+ items: $ReadOnlyArray<HTMLElement>,
218
+ from: number,
219
+ key: string,
220
+ ) => HTMLElement | null,
221
+ |};
222
+
223
+ const SelectContext: React.Context<SelectState | null> = createContext(null);
224
+
225
+ hook useSelect(part: string): SelectState {
226
+ const state = useContext(SelectContext);
227
+ if (state == null) {
228
+ throw new Error(`${part} must be rendered inside a Select.Root`);
229
+ }
230
+ return state;
231
+ }
232
+
233
+ /** The id of a group's label, so `Select.Group` only claims one that exists. */
234
+ type SelectGroupState = {|
235
+ readonly labelId: string,
236
+ readonly registerLabel: (present: boolean) => void,
237
+ |};
238
+
239
+ const SelectGroupContext: React.Context<SelectGroupState | null> = createContext(null);
240
+
241
+ /**
242
+ * The select.
243
+ *
244
+ * `name` is the only thing here a form sees. Given one, the root renders a
245
+ * hidden control carrying the *value* — `internal/form-value.js` explains what
246
+ * it is and why it is not a concealed `<select>`. Without one, nothing is
247
+ * submitted, which is correct for a select that filters a table.
248
+ *
249
+ * A select bound to `@uniflowed/form` needs no `name`: that library keeps its
250
+ * values in its own store and prevents the native submission, so the binding is
251
+ * `useController` — `field.value` into `value` and `field.onChange` into
252
+ * `onValueChange`, which is exactly the pair of props below. Neither package
253
+ * imports the other and neither needs to.
254
+ */
255
+ export component SelectRoot(
256
+ children: React.Node,
257
+ value?: string | null,
258
+ defaultValue?: string | null = null,
259
+ onValueChange?: (value: string | null) => void,
260
+ open?: boolean,
261
+ defaultOpen?: boolean = false,
262
+ onOpenChange?: (open: boolean) => void,
263
+ name?: string,
264
+ disabled?: boolean = false,
265
+ ...rest: Rest
266
+ ) {
267
+ const base = useId();
268
+ const [chosen, setChosen] = useControlled(value, defaultValue, onValueChange);
269
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
270
+ const [activeId, setActiveId] = useState<string | null>(null);
271
+ const [labels, setLabels] = useState<{ readonly [string]: string }>({});
272
+ const [labelled, setLabelled] = useState(false);
273
+ const pendingLandingRef = useRef<Landing | null>(null);
274
+ const triggerRef = useRef<HTMLElement | null>(null);
275
+ const listRef = useRef<HTMLElement | null>(null);
276
+ const typeahead = useTypeahead();
277
+
278
+ const choose = useStableCallback((next: string) => {
279
+ setChosen(next);
280
+ setOpen(false);
281
+ setActiveId(null);
282
+ // Focus never left the trigger for a keyboard selection, and an option's
283
+ // `pointerdown` handler stops a click taking it either — but the trigger is
284
+ // where the next keystroke has to arrive, and asserting that here costs
285
+ // nothing and survives a caller who renders an option as something
286
+ // focusable.
287
+ triggerRef.current?.focus();
288
+ });
289
+
290
+ /**
291
+ * Remember what an option's value is called.
292
+ *
293
+ * Only ever added to, and that is the whole design. The options are in the
294
+ * document while the list is open and gone when it is closed, which is
295
+ * exactly when `Select.Value` needs a label to show — so forgetting on
296
+ * unmount would blank the trigger the instant the reader chose something.
297
+ * The map is bounded by the number of distinct values the caller has
298
+ * rendered, which is the size of their own option list.
299
+ */
300
+ const registerLabel = useStableCallback((optionValue: string, label: string) => {
301
+ setLabels((current) =>
302
+ current[optionValue] === label ? current : { ...current, [optionValue]: label },
303
+ );
304
+ });
305
+
306
+ const state = useMemo(
307
+ () => ({
308
+ base,
309
+ open: isOpen,
310
+ setOpen,
311
+ disabled,
312
+ value: chosen,
313
+ choose,
314
+ activeId,
315
+ setActiveId,
316
+ pendingLandingRef,
317
+ triggerRef,
318
+ listRef,
319
+ labels,
320
+ registerLabel,
321
+ labelled,
322
+ registerFieldLabel: setLabelled,
323
+ typeahead,
324
+ }),
325
+ [
326
+ base,
327
+ isOpen,
328
+ setOpen,
329
+ disabled,
330
+ chosen,
331
+ choose,
332
+ activeId,
333
+ labels,
334
+ registerLabel,
335
+ labelled,
336
+ typeahead,
337
+ ],
338
+ );
339
+
340
+ return (
341
+ <SelectContext.Provider value={state}>
342
+ <div {...rest}>
343
+ {children}
344
+ {name == null ? null : <FormValue disabled={disabled} name={name} value={chosen} />}
345
+ </div>
346
+ </SelectContext.Provider>
347
+ );
348
+ }
349
+
350
+ /**
351
+ * The field's label.
352
+ *
353
+ * A `<label htmlFor>` *and* an `aria-labelledby` on the trigger, and the second
354
+ * one is not redundant. `role="combobox"` is not a role that takes its name
355
+ * from its own content, and a `<label for>` pointing at a `<button>` does not
356
+ * name it either — HTML-AAM gives a button its name from its subtree, which the
357
+ * role has just ruled out. A select with only a `<label for>` was therefore a
358
+ * combobox with no accessible name at all, announced as "combobox" and nothing
359
+ * else, while looking correct in the markup and reading correctly to anyone
360
+ * who could see it. The `htmlFor` is kept for the behaviour it does carry: a
361
+ * click on the label focuses the trigger.
362
+ *
363
+ * This is `Select.Label` and it names the field. `Select.GroupLabel` names a
364
+ * group of options — the two are separate parts because a select has both, and
365
+ * shadcn's single `SelectLabel`, which is the group's, has no name for the
366
+ * field's.
367
+ */
368
+ export component SelectLabel(children: React.Node, ...rest: Rest) {
369
+ const select = useSelect("Select.Label");
370
+ const register = select.registerFieldLabel;
371
+ useEffect(() => {
372
+ register(true);
373
+ return () => register(false);
374
+ }, [register]);
375
+
376
+ return (
377
+ <label {...rest} htmlFor={`${select.base}-trigger`} id={`${select.base}-label`}>
378
+ {children}
379
+ </label>
380
+ );
381
+ }
382
+
383
+ /**
384
+ * The button that opens the list, and every key the pattern defines.
385
+ *
386
+ * A `<button>` rather than the `div` with `tabindex="0"` the APG example uses,
387
+ * for the reason `menu.js` gives about its items: focusability, `disabled` and
388
+ * the focus ring are then the browser's rather than this component's, and a
389
+ * component that reimplements `disabled` gets one of its four behaviours wrong.
390
+ *
391
+ * `type="button"` because the whole point of this module is that it lives in
392
+ * forms, and a `<button>` inside a `<form>` submits it by default. A select
393
+ * that posted the form every time it was opened would be a memorable bug.
394
+ */
395
+ export component SelectTrigger(children: React.Node, ...rest: Rest) {
396
+ const select = useSelect("Select.Trigger");
397
+ const passed = withoutComposed(rest, ["onClick", "onKeyDown", "ref"]);
398
+
399
+ /** The options in the document right now, in document order. */
400
+ const options = (): Array<HTMLElement> => {
401
+ const list = select.listRef.current;
402
+ return list == null ? [] : itemsOf(list, OPTION_SELECTOR, LISTBOX_SELECTOR);
403
+ };
404
+
405
+ const put = (option: HTMLElement | null) => {
406
+ if (option == null) {
407
+ return;
408
+ }
409
+ select.setActiveId(option.id);
410
+ // `nearest`, so a list already showing the option does not jump under a
411
+ // reader who can see it.
412
+ (option as $FlowFixMe).scrollIntoView?.({ block: "nearest" });
413
+ };
414
+
415
+ /** Move the cursor within an open list, or open with an instruction. */
416
+ const move = (end: Movement, preferSelected: boolean) => {
417
+ if (!select.open) {
418
+ // This is an instruction for the list after the opening commit.
419
+ // uf-lint-disable-next-line react-compiler/immutability
420
+ select.pendingLandingRef.current = { kind: "end", end, preferSelected };
421
+ select.setOpen(true);
422
+ return;
423
+ }
424
+ const items = options();
425
+ const at = items.findIndex((item) => item.id === select.activeId);
426
+ // `false`: the ends are closed. A native select stops at the last option
427
+ // and a native menu cycles, and this widget is the first kind.
428
+ put(moveTo(items, at, end, false));
429
+ };
430
+
431
+ /** Take the option under the cursor, if there is one. */
432
+ const commit = (): boolean => {
433
+ const option = options().find((item) => item.id === select.activeId);
434
+ if (option == null) {
435
+ return false;
436
+ }
437
+ select.choose(option.getAttribute("data-value") ?? "");
438
+ return true;
439
+ };
440
+
441
+ return (
442
+ <button
443
+ {...passed}
444
+ // Only while the list is in the document. Either attribute naming an
445
+ // element that is not there makes a screen reader announce nothing where
446
+ // it used to announce the current option.
447
+ aria-activedescendant={select.open ? (select.activeId ?? undefined) : undefined}
448
+ aria-controls={select.open ? `${select.base}-list` : undefined}
449
+ aria-expanded={select.open ? "true" : "false"}
450
+ aria-haspopup="listbox"
451
+ // Named only while a `Select.Label` is rendered: an `aria-labelledby`
452
+ // pointing at an id nothing has is worse than no name, because a reader
453
+ // is told nothing rather than told the button's own content.
454
+ aria-labelledby={select.labelled ? `${select.base}-label` : undefined}
455
+ disabled={select.disabled}
456
+ id={`${select.base}-trigger`}
457
+ onClick={composeHandlers(rest.onClick, () => {
458
+ if (select.open) {
459
+ select.setOpen(false);
460
+ select.setActiveId(null);
461
+ return;
462
+ }
463
+ // This is an instruction for the list after the opening commit.
464
+ // uf-lint-disable-next-line react-compiler/immutability
465
+ select.pendingLandingRef.current = { kind: "end", end: "first", preferSelected: true };
466
+ select.setOpen(true);
467
+ })}
468
+ onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
469
+ if (select.disabled) {
470
+ return;
471
+ }
472
+
473
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
474
+ event.preventDefault();
475
+ if (event.altKey) {
476
+ if (event.key === "ArrowDown") {
477
+ // Look without moving: the list opens with no cursor at all.
478
+ select.setOpen(true);
479
+ return;
480
+ }
481
+ // `Alt+ArrowUp` is the collapse-and-take of a native select.
482
+ if (!commit()) {
483
+ select.setOpen(false);
484
+ select.setActiveId(null);
485
+ }
486
+ return;
487
+ }
488
+ move(event.key === "ArrowDown" ? "next" : "previous", true);
489
+ return;
490
+ }
491
+
492
+ if (event.key === "Home" || event.key === "End") {
493
+ event.preventDefault();
494
+ // `preferSelected` false: these two keys name a position, and
495
+ // answering "the last one" with "the one you already chose" is not an
496
+ // answer to the question that was asked.
497
+ move(event.key === "Home" ? "first" : "last", false);
498
+ return;
499
+ }
500
+
501
+ if (event.key === "Enter" || event.key === " ") {
502
+ // Prevented in both branches: `Enter` would submit the form this
503
+ // select is in, and `Space` would scroll the page and then arrive
504
+ // again as a click.
505
+ event.preventDefault();
506
+ if (!select.open) {
507
+ // This is an instruction for the list after the opening commit.
508
+ // uf-lint-disable-next-line react-compiler/immutability
509
+ select.pendingLandingRef.current = { kind: "end", end: "first", preferSelected: true };
510
+ select.setOpen(true);
511
+ return;
512
+ }
513
+ if (!commit()) {
514
+ // Open with nothing under the cursor: close rather than sit there,
515
+ // which is what a reader who pressed Enter asked for.
516
+ select.setOpen(false);
517
+ }
518
+ return;
519
+ }
520
+
521
+ if (event.key === "Escape") {
522
+ if (!select.open) {
523
+ return;
524
+ }
525
+ event.preventDefault();
526
+ // A dialog around this select must not also close: one Escape is one
527
+ // dismissal, and the innermost thing wins.
528
+ event.stopPropagation();
529
+ select.setOpen(false);
530
+ select.setActiveId(null);
531
+ return;
532
+ }
533
+
534
+ if (event.key === "Tab") {
535
+ // Not prevented: Tab still moves on. It takes the cursor's option on
536
+ // the way out, which is APG's rule for this pattern and the opposite
537
+ // of `Combobox`'s — the module header says why the two differ.
538
+ if (select.open) {
539
+ commit();
540
+ select.setOpen(false);
541
+ select.setActiveId(null);
542
+ }
543
+ return;
544
+ }
545
+
546
+ if (!isTypeaheadKey(event)) {
547
+ return;
548
+ }
549
+ if (!select.open) {
550
+ event.preventDefault();
551
+ // The options are not in the document yet, so the keystroke travels
552
+ // to the commit that renders them.
553
+ // uf-lint-disable-next-line react-compiler/immutability
554
+ select.pendingLandingRef.current = { kind: "typed", key: event.key };
555
+ select.setOpen(true);
556
+ return;
557
+ }
558
+ const items = options();
559
+ const at = items.findIndex((item) => item.id === select.activeId);
560
+ const found = select.typeahead(items, at, event.key);
561
+ if (found != null) {
562
+ // Prevented only when something was found, the way `menu.js` does it:
563
+ // a letter that matches nothing here is a letter the browser's own
564
+ // find-as-you-type may still want.
565
+ event.preventDefault();
566
+ put(found);
567
+ }
568
+ })}
569
+ ref={composeRefs(rest.ref, (element) => {
570
+ // React calls callback refs during commit; the list reads the trigger later.
571
+ // uf-lint-disable-next-line react-compiler/immutability
572
+ select.triggerRef.current = element;
573
+ })}
574
+ role="combobox"
575
+ type="button"
576
+ >
577
+ {children}
578
+ </button>
579
+ );
580
+ }
581
+
582
+ /**
583
+ * What the trigger shows for the current value.
584
+ *
585
+ * The content of a `role="combobox"` element is its *value*, not its name —
586
+ * which is why `Select.Label` exists and why this part may be plain text with
587
+ * no ARIA of its own.
588
+ *
589
+ * Four rules, in order, and the third is the one worth knowing about:
590
+ *
591
+ * 1. `children`, when the caller passed any. A caller who holds the option
592
+ * list as data already knows what `value` is called and this is how they
593
+ * say so.
594
+ * 2. The label of the option with that value, learned from the options
595
+ * themselves the first time the list was rendered and remembered after it
596
+ * closes.
597
+ * 3. The value itself, when it has never been seen as an option — a select
598
+ * whose `defaultValue` came from a saved form and whose list has not been
599
+ * opened yet. A reader hears "GB" rather than "United Kingdom", which is
600
+ * wrong and true; showing the placeholder there would be wrong and
601
+ * confident, telling a reader that nothing is chosen when something is.
602
+ * 4. The placeholder, only when nothing is chosen at all.
603
+ *
604
+ * Case 3 is a real edge and the way out of it is case 1.
605
+ */
606
+ export component SelectValue(children?: React.Node, placeholder?: React.Node, ...rest: Rest) {
607
+ const select = useSelect("Select.Value");
608
+ const chosen = select.value;
609
+
610
+ if (children != null) {
611
+ return <span {...rest}>{children}</span>;
612
+ }
613
+ if (chosen == null) {
614
+ return <span {...rest}>{placeholder}</span>;
615
+ }
616
+ return <span {...rest}>{select.labels[chosen] ?? chosen}</span>;
617
+ }
618
+
619
+ /**
620
+ * The list of options, in the document only while it is open.
621
+ *
622
+ * `div`s rather than `combobox.js`'s `ul`/`li`, because a listbox with groups
623
+ * cannot be a list without putting a second `list` role between a group and the
624
+ * options it owns. The module header has the ARIA rule this follows from.
625
+ *
626
+ * The effect below keeps the one invariant this pattern rests on:
627
+ * `aria-activedescendant` never names an option that is not in the document.
628
+ */
629
+ export component SelectList(
630
+ children: renders* (SelectOption | SelectGroup | SelectSeparator),
631
+ align?: Align = "start",
632
+ alignOffset?: number = 0,
633
+ avoidCollisions?: boolean = true,
634
+ collisionPadding?: number = 0,
635
+ side?: LogicalSide = "bottom",
636
+ sideOffset?: number = 0,
637
+ ...rest: Rest
638
+ ) {
639
+ const select = useSelect("Select.List");
640
+ const { activeId, listRef, pendingLandingRef, setActiveId, triggerRef, typeahead, value } =
641
+ select;
642
+ const close = useStableCallback(() => {
643
+ select.setOpen(false);
644
+ select.setActiveId(null);
645
+ });
646
+
647
+ // The popup a select opens is the one case where the trigger's *width* is
648
+ // part of the design rather than a detail: a list narrower than the button it
649
+ // came out of reads as a different control. `--uf-anchor-trigger-width` is
650
+ // written on this element for a stylesheet to use, which is why the
651
+ // measurement is here and not in the caller.
652
+ const anchored = useAnchor({
653
+ align,
654
+ alignOffset,
655
+ anchorRef: triggerRef,
656
+ avoidCollisions,
657
+ collisionPadding,
658
+ open: select.open,
659
+ overlayRef: listRef,
660
+ side,
661
+ sideOffset,
662
+ });
663
+
664
+ // No dependency list, for the reason `combobox.js` gives: what this reads is
665
+ // the *rendered* options, and a caller may render different ones on any
666
+ // render — a change to `children` that no dependency list can describe. Every
667
+ // write is guarded by a comparison, so it settles after one extra pass rather
668
+ // than looping.
669
+ // This effect measures caller-rendered options after commit.
670
+ // uf-lint-disable-next-line react-compiler/immutability
671
+ useEffect(() => {
672
+ const list = listRef.current;
673
+ if (list == null) {
674
+ return;
675
+ }
676
+ const items = itemsOf(list, OPTION_SELECTOR, LISTBOX_SELECTOR);
677
+
678
+ const wanted = pendingLandingRef.current;
679
+ if (wanted != null) {
680
+ // uf-lint-disable-next-line react-compiler/immutability
681
+ pendingLandingRef.current = null;
682
+ // An `if` rather than a `match` on `wanted.kind`, because matching on a
683
+ // property does not refine the object that property came from: inside
684
+ // `match (wanted.kind)` both arms still see the whole union, and `uf
685
+ // check` says so twice.
686
+ const landing =
687
+ wanted.kind === "typed"
688
+ ? typeahead(items, -1, wanted.key)
689
+ : // The selected option when the key that opened the list meant
690
+ // "start from where I am", and when there is a selection to start
691
+ // from; the end that key named otherwise.
692
+ ((wanted.preferSelected
693
+ ? items.find((item) => item.getAttribute("data-value") === value)
694
+ : null) ?? moveTo(items, -1, wanted.end, false));
695
+ if (landing != null) {
696
+ setActiveId(landing.id);
697
+ (landing as $FlowFixMe).scrollIntoView?.({ block: "nearest" });
698
+ }
699
+ return;
700
+ }
701
+
702
+ if (activeId != null && !items.some((item) => item.id === activeId)) {
703
+ // The option the cursor named has left the list. Clearing it is what
704
+ // keeps `aria-activedescendant` pointing only at ids that exist.
705
+ setActiveId(null);
706
+ }
707
+ });
708
+
709
+ // The refs are read when a press arrives rather than when the listener is
710
+ // attached, which is what makes `select.open` the only thing this depends on:
711
+ // this component is mounted the whole time and only *renders* while the list
712
+ // is open, and a listener attached on the commit where `listRef.current` was
713
+ // still null used to be one that never worked.
714
+ useInteractOutside({
715
+ isDisabled: !select.open,
716
+ onInteractOutside: () => close(),
717
+ refs: [listRef, triggerRef],
718
+ });
719
+
720
+ if (!select.open) {
721
+ return null;
722
+ }
723
+
724
+ const passed = withoutComposed(rest, ["ref"]);
725
+
726
+ return (
727
+ <div
728
+ {...passed}
729
+ aria-labelledby={select.labelled ? `${select.base}-label` : undefined}
730
+ data-align={anchored.align}
731
+ data-side={anchored.side}
732
+ id={`${select.base}-list`}
733
+ ref={composeRefs(rest.ref, (element) => {
734
+ listRef.current = element;
735
+ })}
736
+ role="listbox"
737
+ >
738
+ {children}
739
+ </div>
740
+ );
741
+ }
742
+
743
+ /**
744
+ * One option.
745
+ *
746
+ * Never focusable, and that is the invariant the whole pattern rests on: focus
747
+ * belongs to the trigger, and an option that could take it would leave the
748
+ * reader's keystrokes arriving somewhere with no key handler.
749
+ *
750
+ * `data-value` is how the trigger reads back what the cursor is on, because it
751
+ * finds the option in the document rather than in a registry that could
752
+ * disagree with the page — the reason `internal/roving-focus.js` gives.
753
+ */
754
+ export component SelectOption(
755
+ value: string,
756
+ children: React.Node,
757
+ label?: string,
758
+ disabled?: boolean = false,
759
+ ...rest: Rest
760
+ ) {
761
+ const select = useSelect("Select.Option");
762
+ const id = useId();
763
+ const active = select.activeId === id;
764
+ const selected = select.value === value;
765
+ const passed = withoutComposed(rest, ["onClick", "onPointerDown", "onPointerMove", "ref"]);
766
+ const register = select.registerLabel;
767
+ const element = useRef<HTMLElement | null>(null);
768
+
769
+ // What `Select.Value` will show once this option has been unmounted with the
770
+ // list. Read from the DOM rather than from `children`, because `children` is
771
+ // a `React.Node` — an icon beside a word, a fragment, a caller's own
772
+ // component — and the only thing that reliably knows what it came out as is
773
+ // the element it came out in. `label` overrides it for the case where the
774
+ // rendered content is not what the trigger should say.
775
+ useEffect(() => {
776
+ register(value, label ?? textOf(element.current));
777
+ }, [register, value, label]);
778
+
779
+ return (
780
+ <div
781
+ {...passed}
782
+ aria-disabled={disabled ? "true" : undefined}
783
+ aria-selected={selected ? "true" : "false"}
784
+ // For styling the cursor. `data-` rather than a class because this
785
+ // package ships no styles and the caller owns the class list.
786
+ data-active={active ? "true" : undefined}
787
+ data-value={value}
788
+ id={id}
789
+ onClick={composeHandlers(rest.onClick, () => {
790
+ if (!disabled) {
791
+ select.choose(value);
792
+ }
793
+ })}
794
+ // A press must not take focus off the trigger. Without this the trigger
795
+ // blurs on `pointerdown`, and every key the reader presses next arrives
796
+ // at the document instead of at this widget.
797
+ onPointerDown={composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
798
+ event.preventDefault();
799
+ })}
800
+ // The pointer moves the cursor so the keyboard and the mouse agree about
801
+ // which option `Enter` would take.
802
+ onPointerMove={composeHandlers(rest.onPointerMove, () => {
803
+ if (!disabled && !active) {
804
+ select.setActiveId(id);
805
+ }
806
+ })}
807
+ ref={composeRefs(rest.ref, (node) => {
808
+ element.current = node;
809
+ })}
810
+ role="option"
811
+ >
812
+ {children}
813
+ </div>
814
+ );
815
+ }
816
+
817
+ /**
818
+ * A named group of options.
819
+ *
820
+ * The name reaches the group through `aria-labelledby`, and only while a
821
+ * `Select.GroupLabel` is rendered — the same rule, and the same reason, as
822
+ * `Menu.Group`. The arrow keys pass over the label without stopping on it,
823
+ * because they only ever look for `role="option"`.
824
+ *
825
+ * `children` is `renders* (SelectOption | SelectGroupLabel)`, which is what a
826
+ * `group` inside a `listbox` may hold: options, and the heading that names
827
+ * them. It took `React.Node` until ubugeeei-prod/uf#562, so a `<div>` in a
828
+ * group was a runtime surprise — an element with no role between two options,
829
+ * which the arrow keys walk straight past and a screen reader reads as a stray
830
+ * line — rather than a type error. `Combobox.Group` has stated the constraint
831
+ * since #558 and this is the same listbox.
832
+ *
833
+ * No `Select.Separator`, and that is deliberate rather than an omission: a rule
834
+ * separates *groups*, so it belongs between them in `Select.List` — which does
835
+ * admit one. A separator inside a group is a rule with nothing on one side of
836
+ * it.
837
+ *
838
+ * **Breaking.** A caller passing anything else — a `<div>` wrapper, a fragment
839
+ * of their own, a component that returns options — now fails `uf check`. The
840
+ * fix is to hand the options to the group directly; a wrapper had no effect on
841
+ * what this renders, because the group's element is the one below.
842
+ */
843
+ export component SelectGroup(children: renders* (SelectOption | SelectGroupLabel), ...rest: Rest) {
844
+ const base = useId();
845
+ const [labelled, setLabelled] = useState(false);
846
+
847
+ const group = useMemo(() => ({ labelId: `${base}-label`, registerLabel: setLabelled }), [base]);
848
+
849
+ return (
850
+ <SelectGroupContext.Provider value={group}>
851
+ <div {...rest} aria-labelledby={labelled ? group.labelId : undefined} role="group">
852
+ {children}
853
+ </div>
854
+ </SelectGroupContext.Provider>
855
+ );
856
+ }
857
+
858
+ /**
859
+ * The heading of a `Select.Group`.
860
+ *
861
+ * `role="presentation"` because the group already carries the name: left as
862
+ * ordinary content a reader would hear the heading once as the group's name and
863
+ * again as a stray line of text among the options.
864
+ */
865
+ export component SelectGroupLabel(children: React.Node, ...rest: Rest) {
866
+ const group = useContext(SelectGroupContext);
867
+ const register = group?.registerLabel;
868
+
869
+ useEffect(() => {
870
+ if (register == null) {
871
+ return;
872
+ }
873
+ register(true);
874
+ return () => register(false);
875
+ }, [register]);
876
+
877
+ return (
878
+ <div {...rest} id={group?.labelId} role="presentation">
879
+ {children}
880
+ </div>
881
+ );
882
+ }
883
+
884
+ /**
885
+ * A rule between groups of options.
886
+ *
887
+ * `aria-hidden`, and this is the one part that differs from `Menu.Separator`. A
888
+ * `separator` is a legal child of a `menu` and is announced there; a `listbox`
889
+ * may only own `option` and `group`, so a separator inside one is a child ARIA
890
+ * does not allow, and what a reader is told about an invalid listbox is up to
891
+ * the software rather than the specification. The rule between two groups is
892
+ * decoration, so it says so and stays out of the tree.
893
+ */
894
+ export component SelectSeparator(...rest: Rest) {
895
+ return <div {...rest} aria-hidden="true" />;
896
+ }
897
+
898
+ /** What an option came out as, for the trigger to show later. */
899
+ function textOf(element: HTMLElement | null): string {
900
+ return (element?.textContent ?? "").replace(/\s+/g, " ").trim();
901
+ }