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

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/index.js CHANGED
@@ -11,44 +11,128 @@
11
11
  // That part is behaviour, and specifically keyboard and screen-reader
12
12
  // behaviour: a roving `tabindex` so a twelve-tab list does not take twelve Tab
13
13
  // presses to get past, a focus trap that actually cannot be escaped, focus
14
- // restored to whatever opened a dialog, `aria-describedby` pointing only at
15
- // elements that are in the document. None of it is visible in a screenshot and
16
- // all of it is what separates a component from a div that looks like one.
14
+ // restored to whatever opened a dialog, typeahead in a menu, an
15
+ // `aria-activedescendant` that names an option still in the document. None of
16
+ // it is visible in a screenshot and all of it is what separates a component
17
+ // from a `div` that looks like one.
18
+ //
19
+ // Each primitive implements the WAI-ARIA authoring practices pattern for it —
20
+ // the roles, the `aria-*` wiring, the focus management and the whole keyboard
21
+ // map — and each module's header says which interaction it exists to get right
22
+ // and what a naive version breaks.
17
23
  //
18
24
  // # Composition is type-checked
19
25
  //
20
26
  // This is where Flow says something no other type system can. `Tabs.List`
21
27
  // declares `renders* Tabs.Tab`, so a `<button>` in a tab list is a *type
22
28
  // error* — not a review comment, not a runtime warning, not a screen reader
23
- // announcing "button" where the reader expected "tab, 2 of 5". A library
24
- // written in TypeScript can document that constraint; it cannot state it.
29
+ // announcing "button" where the reader expected "tab, 2 of 5". `Menu.Body` and
30
+ // `Combobox.List` state the same constraint about what may appear inside a
31
+ // menu and a listbox, which ARIA also requires and which nothing else checks.
32
+ // A library written in TypeScript can document those constraints; it cannot
33
+ // state them.
34
+ //
35
+ // # Styling is a default, not a dependency
36
+ //
37
+ // Nothing here imports StyleX, and nothing here has a StyleX-shaped type. A
38
+ // consumer styling with plain CSS, CSS Modules or anything else gets exactly
39
+ // the same components with exactly the same behaviour; the design-system layer
40
+ // that adds uf's default styles is built *on* these, not into them.
41
+ //
42
+ // # What these components promise React
43
+ //
44
+ // Nothing here mutates during a render, reads a ref during a render, or depends
45
+ // on a render happening exactly once — so React Compiler's memoization and
46
+ // ordinary `memo` are both safe, and none of it needs an escape hatch. The
47
+ // refs that exist (`triggerRef`, `pendingFocus`, the typeahead buffer) are
48
+ // written only from event handlers and effects, and nothing renders them.
49
+ //
50
+ // Where a component has to learn something the DOM knows — how many options a
51
+ // caller filtered down to, which item the arrow key should move to — it reads
52
+ // the document in an effect or an event handler and, if a render depends on
53
+ // the answer, puts it in state. It is deliberately *not* `useSyncExternalStore`:
54
+ // that is for a store whose value a render reads, and reading layout during a
55
+ // render is the thing it exists to prevent.
56
+ //
57
+ // # Server and client
58
+ //
59
+ // Every module that manages focus, listens to the document or holds state
60
+ // declares `"use client"`, because each of those needs a browser. That is a
61
+ // property of the components, not of the application: an RSC page may import
62
+ // this package from a Server Component, and only the parts that need the client
63
+ // join the client bundle.
64
+ //
65
+ // # How the package is laid out
66
+ //
67
+ // One root module per primitive, each with its own `exports` subpath, each
68
+ // named after the thing it implements:
69
+ //
70
+ // - `dialog.js` — the focus trap, focus restore, scroll lock and inert page.
71
+ // - `menu.js` — the arrow keys, typeahead, submenus and `Escape` stacking.
72
+ // - `combobox.js` — `aria-activedescendant` over a filtered list, and the
73
+ // count a screen reader is told.
74
+ // - `tabs.js` — the roving `tabindex`, and automatic versus manual activation.
75
+ // - `field.js` — the label, description, error and `aria-invalid` wiring.
76
+ // - `switch.js` and `checkbox.js` — the two two-state controls, apart because
77
+ // the third state and the `Enter` key genuinely differ between them.
78
+ //
79
+ // Every name below is exported from one of those, so a consumer may import
80
+ // `@uniflowed/ui` or `@uniflowed/ui/dialog` and get the same thing. The split
81
+ // is by primitive because that is the unit a reader looks for, the unit a
82
+ // bundler drops, and the unit the WAI-ARIA practices are written in.
83
+ //
84
+ // `internal/` holds three modules and nothing else, each a rule the primitives
85
+ // must apply identically and a consumer must not be able to apply differently:
86
+ // `merge-props.js` (the caller's props go on first, the component's semantics
87
+ // last), `controlled-state.js` (what "controlled" means here), and
88
+ // `roving-focus.js` (how a set of items is found and moved between). Each says
89
+ // in its own header why it is unreachable rather than exported. There is no
90
+ // `internal/props.js`-shaped bag of helpers: a module that cannot say what it
91
+ // is about does not belong in this package.
25
92
 
93
+ import { Checkbox } from "./checkbox.js";
26
94
  import {
27
- FieldControl,
28
- FieldDescription,
29
- FieldError,
30
- FieldLabel,
31
- FieldRoot,
32
- } from "./internal/field.js";
95
+ ComboboxEmpty,
96
+ ComboboxInput,
97
+ ComboboxLabel,
98
+ ComboboxList,
99
+ ComboboxOption,
100
+ ComboboxRoot,
101
+ ComboboxStatus,
102
+ } from "./combobox.js";
33
103
  import {
104
+ DialogBody,
34
105
  DialogClose,
35
- DialogContent,
106
+ DialogDescription,
107
+ DialogFooter,
108
+ DialogHeader,
109
+ DialogOverlay,
36
110
  DialogRoot,
37
111
  DialogTitle,
38
112
  DialogTrigger,
39
- } from "./internal/dialog.js";
40
- import { TabsList, TabsPanel, TabsRoot, TabsTab } from "./internal/tabs.js";
41
- import { Checkbox, Switch } from "./internal/switch.js";
113
+ } from "./dialog.js";
114
+ import { FieldControl, FieldDescription, FieldError, FieldLabel, FieldRoot } from "./field.js";
115
+ import {
116
+ MenuBody,
117
+ MenuGroup,
118
+ MenuItem,
119
+ MenuLabel,
120
+ MenuRoot,
121
+ MenuSeparator,
122
+ MenuSub,
123
+ MenuSubTrigger,
124
+ MenuTrigger,
125
+ } from "./menu.js";
126
+ import { Switch } from "./switch.js";
127
+ import { TabsList, TabsPanel, TabsRoot, TabsTab } from "./tabs.js";
128
+
129
+ export type { ActivationMode } from "./tabs.js";
42
130
 
43
131
  export { Checkbox, Switch };
44
132
 
45
133
  /**
46
134
  * An accessible form field.
47
135
  *
48
- * `Field.Control` takes a render function rather than rendering an `<input>`,
49
- * because a field wraps a select, a textarea or somebody else's component just
50
- * as often, and each needs the same attributes.
51
- *
52
136
  * <Field.Root invalid={error != null}>
53
137
  * <Field.Label>Email</Field.Label>
54
138
  * <Field.Control render={(props) => <input type="email" {...props} />} />
@@ -67,8 +151,11 @@ export const Field = {
67
151
  /**
68
152
  * Tabs, with the arrow-key behaviour the pattern requires.
69
153
  *
154
+ * `activationMode="manual"` moves focus without selecting, for panels that cost
155
+ * something to show.
156
+ *
70
157
  * <Tabs.Root defaultValue="one">
71
- * <Tabs.List>
158
+ * <Tabs.List aria-label="Sections">
72
159
  * <Tabs.Tab value="one">One</Tabs.Tab>
73
160
  * <Tabs.Tab value="two">Two</Tabs.Tab>
74
161
  * </Tabs.List>
@@ -87,17 +174,86 @@ export const Tabs = {
87
174
  * A modal dialog: focus moved in, kept in, and given back.
88
175
  *
89
176
  * <Dialog.Root>
90
- * <Dialog.Trigger>Open</Dialog.Trigger>
91
- * <Dialog.Content>
92
- * <Dialog.Title>Are you sure?</Dialog.Title>
93
- * <Dialog.Close>Cancel</Dialog.Close>
94
- * </Dialog.Content>
177
+ * <Dialog.Trigger>Delete</Dialog.Trigger>
178
+ * <Dialog.Overlay />
179
+ * <Dialog.Body>
180
+ * <Dialog.Header>
181
+ * <Dialog.Title>Delete this project?</Dialog.Title>
182
+ * <Dialog.Description>This cannot be undone.</Dialog.Description>
183
+ * </Dialog.Header>
184
+ * <Dialog.Footer>
185
+ * <Dialog.Close>Cancel</Dialog.Close>
186
+ * </Dialog.Footer>
187
+ * </Dialog.Body>
95
188
  * </Dialog.Root>
96
189
  */
97
190
  export const Dialog = {
98
191
  Root: DialogRoot,
99
192
  Trigger: DialogTrigger,
100
- Content: DialogContent,
193
+ Overlay: DialogOverlay,
194
+ Body: DialogBody,
195
+ Header: DialogHeader,
196
+ Footer: DialogFooter,
101
197
  Title: DialogTitle,
198
+ Description: DialogDescription,
102
199
  Close: DialogClose,
103
200
  };
201
+
202
+ /**
203
+ * A menu, with the keyboard map every native menu has had for thirty years.
204
+ *
205
+ * <Menu.Root>
206
+ * <Menu.Trigger>File</Menu.Trigger>
207
+ * <Menu.Body>
208
+ * <Menu.Group>
209
+ * <Menu.Label>Recent</Menu.Label>
210
+ * <Menu.Item onSelect={open}>Open…</Menu.Item>
211
+ * </Menu.Group>
212
+ * <Menu.Separator />
213
+ * <Menu.Sub>
214
+ * <Menu.SubTrigger>Export</Menu.SubTrigger>
215
+ * <Menu.Body>
216
+ * <Menu.Item onSelect={png}>PNG</Menu.Item>
217
+ * </Menu.Body>
218
+ * </Menu.Sub>
219
+ * </Menu.Body>
220
+ * </Menu.Root>
221
+ */
222
+ export const Menu = {
223
+ Root: MenuRoot,
224
+ Trigger: MenuTrigger,
225
+ Body: MenuBody,
226
+ Item: MenuItem,
227
+ Separator: MenuSeparator,
228
+ Group: MenuGroup,
229
+ Label: MenuLabel,
230
+ Sub: MenuSub,
231
+ SubTrigger: MenuSubTrigger,
232
+ };
233
+
234
+ /**
235
+ * A text field with a list of options, navigated without leaving the field.
236
+ *
237
+ * The caller filters; the component keeps the ARIA wiring true while they do.
238
+ *
239
+ * <Combobox.Root inputValue={query} onInputValueChange={setQuery}>
240
+ * <Combobox.Label>Country</Combobox.Label>
241
+ * <Combobox.Input />
242
+ * <Combobox.List>
243
+ * {matches.map((each) => (
244
+ * <Combobox.Option key={each} value={each}>{each}</Combobox.Option>
245
+ * ))}
246
+ * </Combobox.List>
247
+ * <Combobox.Empty>No matches.</Combobox.Empty>
248
+ * <Combobox.Status />
249
+ * </Combobox.Root>
250
+ */
251
+ export const Combobox = {
252
+ Root: ComboboxRoot,
253
+ Label: ComboboxLabel,
254
+ Input: ComboboxInput,
255
+ List: ComboboxList,
256
+ Option: ComboboxOption,
257
+ Empty: ComboboxEmpty,
258
+ Status: ComboboxStatus,
259
+ };
@@ -0,0 +1,65 @@
1
+ // @flow
2
+ //
3
+ // The one contract every part of this package makes about state.
4
+ //
5
+ // Each primitive here has a value somebody may want to own: a dialog's open,
6
+ // a tab set's selection, a switch's checked, a combobox's text. A library that
7
+ // only supports one of the two arrangements is unusable in the other half of
8
+ // applications — a form library owns the value, and a page that just wants tabs
9
+ // does not — so every one of them is uncontrolled by default and controlled the
10
+ // moment the corresponding prop is passed.
11
+ //
12
+ // Written once because the failure mode of writing it six times is that five of
13
+ // them agree and one does not, and the one that does not is a component that
14
+ // silently ignores the parent's value on the second render. The rules it fixes:
15
+ //
16
+ // * `undefined` means "not controlled", and `null` does not. A combobox with
17
+ // no selection is `value={null}` and is still controlled.
18
+ // * A controlled component never writes its internal state, so a parent that
19
+ // rejects a change actually rejects it, rather than the component moving
20
+ // and then being moved back on the next render.
21
+ // * `onChange` is called for both arrangements, so a caller can observe
22
+ // without taking ownership.
23
+ //
24
+ // # Why this is `internal/` and not a subpath
25
+ //
26
+ // A public `useControlled` would be a general-purpose hook, and general-purpose
27
+ // React hooks are `@uniflowed/hooks`' subject, not this package's. What lives
28
+ // here is narrower than that: the specific contract this package's components
29
+ // promise about their props. Exporting it would publish a second, weaker copy
30
+ // of somebody else's API.
31
+
32
+ import { useCallback, useState } from "@uniflowed/react";
33
+
34
+ /**
35
+ * A value the caller may own, and the setter that respects the answer.
36
+ *
37
+ * `controlled` is the prop; `fallback` is the `defaultValue`-shaped initial
38
+ * state used only while the caller owns nothing.
39
+ */
40
+ export hook useControlled<T>(
41
+ controlled: T | void,
42
+ fallback: T,
43
+ onChange: ((next: T) => mixed) | void,
44
+ ): [T, (next: T) => void] {
45
+ const [internal, setInternal] = useState<T>(fallback);
46
+ // `=== undefined` rather than `== null`: `null` is a legitimate controlled
47
+ // value — a combobox with nothing selected — and treating it as "give me the
48
+ // uncontrolled behaviour" made a cleared selection reappear on the next
49
+ // render.
50
+ const owned = controlled === undefined;
51
+
52
+ const set = useCallback(
53
+ (next: T) => {
54
+ if (owned) {
55
+ setInternal(next);
56
+ }
57
+ // Both arrangements report, so a caller can watch a value it does not
58
+ // own without having to take it over to do so.
59
+ onChange?.(next);
60
+ },
61
+ [owned, onChange],
62
+ );
63
+
64
+ return [owned ? internal : (controlled as $FlowFixMe), set];
65
+ }
@@ -1,24 +1,32 @@
1
1
  // @flow
2
2
  //
3
- // Merging a caller's props with the ones a component owns.
3
+ // One rule about prop order, stated once.
4
4
  //
5
5
  // `<div {...rest} role="dialog">` and `<div role="dialog" {...rest}>` are
6
6
  // different components. The second lets a caller pass `role="button"` and get
7
- // it; the first does not. That sounds like a preference until you notice what
8
- // else is in `rest`:
7
+ // it; the first does not. That sounds like a matter of taste until you notice
8
+ // what else arrives in `rest`:
9
9
  //
10
10
  // * A caller `ref` replaced the ref the dialog uses to find its focus stops,
11
- // so `contentRef.current` stayed null, the Tab handler returned early, and
12
- // the focus trap was *silently off* while the dialog still announced
11
+ // so `bodyRef.current` stayed null, the Tab handler returned early, and the
12
+ // focus trap was *silently off* while the dialog still announced
13
13
  // `aria-modal="true"`.
14
14
  // * A caller `onClick` replaced a tab's selection handler, so clicking a tab
15
15
  // did nothing.
16
16
  // * A caller `onKeyDown` replaced the dialog's, so Escape stopped closing it.
17
17
  //
18
- // None of those fail loudly. So the rule here is: the caller's props go on
19
- // first and the component's own semantics go on last, and for the two kinds of
20
- // prop where a caller legitimately wants *both* — event handlers and refs —
21
- // they are composed rather than one replacing the other.
18
+ // None of those fail loudly. So the rule is: the caller's props go on first and
19
+ // the component's own semantics go on last, and for the two kinds of prop where
20
+ // a caller legitimately wants *both* — event handlers and refs — they are
21
+ // composed rather than one replacing the other.
22
+ //
23
+ // # Why this is `internal/` and not a subpath
24
+ //
25
+ // It is not a "props utils" module and there is nothing else in it. It is the
26
+ // one policy every part of this package applies, extracted so that a new
27
+ // primitive cannot quietly apply a different one. Exporting it would invite a
28
+ // consumer to build a part that spreads `rest` last, which is the failure this
29
+ // exists to prevent — so it stays unreachable from outside the package.
22
30
 
23
31
  /** Anything a caller can spread onto an element. */
24
32
  export type Rest = { readonly [string]: mixed };
@@ -26,8 +34,8 @@ export type Rest = { readonly [string]: mixed };
26
34
  /**
27
35
  * Call the caller's handler and then the component's.
28
36
  *
29
- * The caller's runs first so it can inspect the event before the component
30
- * acts on it, and the component's runs unless the caller stopped the event —
37
+ * The caller's runs first so it can inspect the event before the component acts
38
+ * on it, and the component's runs unless the caller stopped the event —
31
39
  * `defaultPrevented` is the caller's way of saying "I handled this", which is
32
40
  * the same contract the DOM uses.
33
41
  */
@@ -0,0 +1,236 @@
1
+ // @flow
2
+ //
3
+ // The keyboard pattern shared by every list of things in this package.
4
+ //
5
+ // A tab list, a menu and a listbox look nothing alike and behave identically at
6
+ // the keyboard, because WAI-ARIA says they must: the *set* takes one stop in the
7
+ // page's tab order, and the arrow keys move within it. That is what makes a
8
+ // twelve-item menu something a keyboard user passes in one Tab press instead of
9
+ // twelve, and it is the part hand-written components leave out.
10
+ //
11
+ // Four rules make it up, and each one has a way of being got wrong that no
12
+ // screenshot shows:
13
+ //
14
+ // * **Document order, read from the document.** Items are found by querying
15
+ // the container at the moment a key is pressed, not from a registry the
16
+ // items push themselves into as they mount. Mount order is not document
17
+ // order the moment a list is filtered, reordered, or has a conditional item
18
+ // in the middle of it — and a registry that disagrees with the page sends
19
+ // the arrow keys somewhere the reader is not.
20
+ // * **Nesting.** A submenu's items are inside its parent menu's element, so
21
+ // "the items of this menu" cannot be `querySelectorAll` alone. An item
22
+ // belongs to the nearest container of its own kind.
23
+ // * **Disabled is skipped, not landed on.** And the direction to keep
24
+ // searching in cannot be inferred from the target index: `End` aims at the
25
+ // last item and, if that one is disabled, has to walk *backwards*. Guessing
26
+ // "forwards, because the target is ahead of us" wrapped `End` around to the
27
+ // first item.
28
+ // * **Typeahead.** Pressing `r` in a menu goes to Refresh. Without it a menu
29
+ // of thirty items is thirty arrow presses, and every native menu on every
30
+ // platform has had this since before the web.
31
+ //
32
+ // # Why this is `internal/` and not a subpath
33
+ //
34
+ // It is a description of DOM structure this package owns — that a tab lives
35
+ // under `[role="tablist"]`, that a menu item's owner is `[role="menu"]` — and
36
+ // those relationships are only guaranteed because the components in this
37
+ // package build them. Handed to a consumer it would be a set of selectors that
38
+ // happen to work today, which is a different and much weaker promise than the
39
+ // one the components make.
40
+
41
+ import { useCallback, useRef } from "@uniflowed/react";
42
+
43
+ /** Which way a key asks the focus to move within a set. */
44
+ export type Movement = "previous" | "next" | "first" | "last";
45
+
46
+ /** The axis a set's arrow keys run along. */
47
+ export type Orientation = "horizontal" | "vertical";
48
+
49
+ /** How long a typeahead buffer survives without another key, in milliseconds. */
50
+ const TYPEAHEAD_WINDOW = 500;
51
+
52
+ /**
53
+ * The items directly belonging to `container`, in document order.
54
+ *
55
+ * `owner` names the container's own kind — `[role="menu"]` for a menu — so an
56
+ * item inside a *nested* container of that kind is left to the nested one. A
57
+ * plain `querySelectorAll` returns a submenu's items as if they were the parent
58
+ * menu's, which makes `ArrowDown` in the parent step into a menu the reader
59
+ * cannot see.
60
+ */
61
+ export function itemsOf(container: HTMLElement, item: string, owner: string): Array<HTMLElement> {
62
+ return Array.from(container.querySelectorAll(item)).filter(
63
+ (element: $FlowFixMe) => element.closest(owner) === container,
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Whether the keyboard may land on this item.
69
+ *
70
+ * Both spellings, because the two mean different things and this package uses
71
+ * both: a native `disabled` takes an element out of the accessibility tree's
72
+ * reach, while `aria-disabled` leaves it announced — which is what a menu item
73
+ * or a tab wants, so a reader can tell the option exists and is unavailable
74
+ * rather than finding a gap where it used to be.
75
+ */
76
+ export function isEnabled(element: HTMLElement): boolean {
77
+ return (
78
+ (element as $FlowFixMe).disabled !== true && element.getAttribute("aria-disabled") !== "true"
79
+ );
80
+ }
81
+
82
+ /**
83
+ * The movement a key asks for along `orientation`, or nothing if it is not ours.
84
+ *
85
+ * The unhandled keys matter as much as the handled ones. `ArrowDown` inside a
86
+ * *horizontal* tab list belongs to the page — it scrolls — and a component that
87
+ * swallows it has taken a key away from every reader who uses it to read.
88
+ */
89
+ export function movementFor(key: string, orientation: Orientation): Movement | null {
90
+ return match (key) {
91
+ "Home" => "first",
92
+ "End" => "last",
93
+ "ArrowUp" => orientation === "vertical" ? "previous" : null,
94
+ "ArrowDown" => orientation === "vertical" ? "next" : null,
95
+ "ArrowLeft" => orientation === "horizontal" ? "previous" : null,
96
+ "ArrowRight" => orientation === "horizontal" ? "next" : null,
97
+ _ => null,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * The item `movement` reaches from `from`, skipping disabled ones.
103
+ *
104
+ * `from` may be `-1` for "nothing is focused yet", which is what makes
105
+ * `ArrowDown` on a freshly opened menu land on the first item. `wrap` is false
106
+ * for a set where running off the end should stop rather than cycle.
107
+ *
108
+ * Returns null when every item is disabled, or when the ends are closed and
109
+ * there is nothing further in that direction — in both cases the caller should
110
+ * leave focus where it is rather than move it somewhere arbitrary.
111
+ */
112
+ export function moveTo(
113
+ items: $ReadOnlyArray<HTMLElement>,
114
+ from: number,
115
+ movement: Movement,
116
+ wrap: boolean,
117
+ ): HTMLElement | null {
118
+ const count = items.length;
119
+ if (count === 0) {
120
+ return null;
121
+ }
122
+ // Two things this expression is careful about, each of which was a bug.
123
+ //
124
+ // The direction is part of the answer rather than derived from it: `last`
125
+ // aims at the end and searches *backwards* from there, and deriving
126
+ // "forwards" from the target being ahead of `from` sent `End` past the end
127
+ // and around to the first item whenever the last one was disabled.
128
+ //
129
+ // And `from` is -1 when nothing is focused yet, which the two directions read
130
+ // differently: "next" from nowhere is the first item, and "previous" from
131
+ // nowhere is the *last* one. Letting -1 fall through the arithmetic aimed
132
+ // `previous` at -2, which wraps to `count - 2` — so `ArrowUp` on a freshly
133
+ // opened list landed one short of the end, and on a two-item list landed on
134
+ // the first item.
135
+ const aim = match (movement) {
136
+ "previous" => [from < 0 ? count - 1 : from - 1, -1],
137
+ "next" => [from + 1, 1],
138
+ "first" => [0, 1],
139
+ "last" => [count - 1, -1],
140
+ };
141
+ const [target, direction] = aim;
142
+
143
+ for (let tried = 0; tried < count; tried += 1) {
144
+ const at = target + tried * direction;
145
+ if (!wrap && (at < 0 || at >= count)) {
146
+ return null;
147
+ }
148
+ const candidate = items[((at % count) + count) % count];
149
+ if (isEnabled(candidate)) {
150
+ return candidate;
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+
156
+ /** The index of the focused item, or `-1` when focus is elsewhere. */
157
+ export function indexOfActive(items: $ReadOnlyArray<HTMLElement>, active: mixed): number {
158
+ return items.findIndex((item) => item === active);
159
+ }
160
+
161
+ /**
162
+ * Match items by the characters a reader types, the way every native menu does.
163
+ *
164
+ * The returned function is stable, so a component may pass it straight to a key
165
+ * handler without re-subscribing anything. The buffer lives in a ref and is only
166
+ * ever touched from an event handler — never during a render, where a value that
167
+ * depends on how many times React chose to render is a bug waiting for
168
+ * Strict Mode to find it.
169
+ *
170
+ * Two behaviours people notice when they are missing:
171
+ *
172
+ * * Typing `s`, `a`, `v` within half a second looks for "sav", not for three
173
+ * separate items starting with `s`, `a` and `v`.
174
+ * * Pressing the *same* letter repeatedly cycles through the items starting
175
+ * with it, which is how a reader reaches the second "Save as…".
176
+ */
177
+ export hook useTypeahead(): (
178
+ items: $ReadOnlyArray<HTMLElement>,
179
+ from: number,
180
+ key: string,
181
+ ) => HTMLElement | null {
182
+ const buffer = useRef<{| text: string, at: number |}>({ text: "", at: 0 });
183
+
184
+ return useCallback(
185
+ (items: $ReadOnlyArray<HTMLElement>, from: number, key: string): HTMLElement | null => {
186
+ const now = Date.now();
187
+ const text = now - buffer.current.at > TYPEAHEAD_WINDOW ? key : buffer.current.text + key;
188
+ buffer.current = { text, at: now };
189
+
190
+ const repeated = text.length > 1 && text.split("").every((each) => each === text[0]);
191
+ const needle = (repeated ? text[0] : text).toLowerCase();
192
+ // A single character — or the same one again — moves on from where we
193
+ // are. A longer buffer starts *at* the current item, so typing "sa" after
194
+ // "s" can keep the item "s" already found.
195
+ const start = repeated || text.length === 1 ? from + 1 : Math.max(from, 0);
196
+
197
+ for (let tried = 0; tried < items.length; tried += 1) {
198
+ const candidate = items[(((start + tried) % items.length) + items.length) % items.length];
199
+ if (isEnabled(candidate) && labelOf(candidate).startsWith(needle)) {
200
+ return candidate;
201
+ }
202
+ }
203
+ return null;
204
+ },
205
+ [],
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Whether a key press is a character a reader meant to type.
211
+ *
212
+ * Modifier combinations are excluded because `Ctrl+P` is the browser's, and a
213
+ * component that treats it as "the letter p" both steals the shortcut and jumps
214
+ * the selection somewhere the reader did not ask for.
215
+ */
216
+ export function isTypeaheadKey(event: {
217
+ readonly key: string,
218
+ readonly altKey?: boolean,
219
+ readonly ctrlKey?: boolean,
220
+ readonly metaKey?: boolean,
221
+ ...
222
+ }): boolean {
223
+ return (
224
+ event.key.length === 1 &&
225
+ event.key !== " " &&
226
+ event.altKey !== true &&
227
+ event.ctrlKey !== true &&
228
+ event.metaKey !== true
229
+ );
230
+ }
231
+
232
+ /** What a reader hears for this item, lower-cased for matching. */
233
+ function labelOf(element: HTMLElement): string {
234
+ const spoken = element.getAttribute("aria-label") ?? element.textContent ?? "";
235
+ return spoken.replace(/\s+/g, " ").trim().toLowerCase();
236
+ }