@spaced-out/ui-design-system 0.6.35-beta.0 → 0.6.35

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 (35) hide show
  1. package/.cspell/custom-words.txt +0 -9
  2. package/CHANGELOG.md +9 -0
  3. package/lib/components/Menu/Menu.d.ts +1 -71
  4. package/lib/components/Menu/Menu.d.ts.map +1 -1
  5. package/lib/components/Menu/Menu.js +221 -1035
  6. package/lib/components/Menu/Menu.module.css +27 -148
  7. package/lib/components/Menu/MenuOptionButton.d.ts +2 -23
  8. package/lib/components/Menu/MenuOptionButton.d.ts.map +1 -1
  9. package/lib/components/Menu/MenuOptionButton.js +38 -87
  10. package/lib/components/Menu/index.d.ts +0 -2
  11. package/lib/components/Menu/index.d.ts.map +1 -1
  12. package/lib/components/Menu/index.js +0 -22
  13. package/lib/hooks/index.d.ts +0 -1
  14. package/lib/hooks/index.d.ts.map +1 -1
  15. package/lib/hooks/index.js +0 -11
  16. package/lib/utils/click-away/click-away.d.ts +0 -3
  17. package/lib/utils/click-away/click-away.d.ts.map +1 -1
  18. package/lib/utils/click-away/click-away.js +0 -49
  19. package/mcp/package.json +1 -1
  20. package/package.json +1 -1
  21. package/lib/components/Menu/menuOptionId.d.ts +0 -12
  22. package/lib/components/Menu/menuOptionId.d.ts.map +0 -1
  23. package/lib/components/Menu/menuOptionId.js +0 -18
  24. package/lib/components/Menu/useMenuKeyboardNavigation.d.ts +0 -93
  25. package/lib/components/Menu/useMenuKeyboardNavigation.d.ts.map +0 -1
  26. package/lib/components/Menu/useMenuKeyboardNavigation.js +0 -471
  27. package/lib/components/Menu/useMenuTrigger.d.ts +0 -183
  28. package/lib/components/Menu/useMenuTrigger.d.ts.map +0 -1
  29. package/lib/components/Menu/useMenuTrigger.js +0 -347
  30. package/lib/hooks/useInteractionModality/index.d.ts +0 -2
  31. package/lib/hooks/useInteractionModality/index.d.ts.map +0 -1
  32. package/lib/hooks/useInteractionModality/index.js +0 -16
  33. package/lib/hooks/useInteractionModality/useInteractionModality.d.ts +0 -15
  34. package/lib/hooks/useInteractionModality/useInteractionModality.d.ts.map +0 -1
  35. package/lib/hooks/useInteractionModality/useInteractionModality.js +0 -103
@@ -1,12 +0,0 @@
1
- /**
2
- * The DOM id of an option row — the one thing the row itself, the listbox's
3
- * `aria-activedescendant`, and a combobox trigger's `aria-activedescendant`
4
- * must all agree on, so it is built in exactly one place.
5
- *
6
- * Option keys are consumer data and may contain whitespace ("New York"), which
7
- * an id can hold but an IDREF cannot: `aria-activedescendant="m-option-New
8
- * York"` is a list of two references, neither of which resolves. Encoding the
9
- * key keeps the id a single token.
10
- */
11
- export declare const menuOptionId: (menuId: string, key: string) => string;
12
- //# sourceMappingURL=menuOptionId.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"menuOptionId.d.ts","sourceRoot":"","sources":["../../../src/components/Menu/menuOptionId.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,eAAO,MAAM,YAAY,GAAI,QAAQ,MAAM,EAAE,KAAK,MAAM,KAAG,MACZ,CAAC"}
@@ -1,18 +0,0 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports.menuOptionId = void 0;
7
- /**
8
- * The DOM id of an option row — the one thing the row itself, the listbox's
9
- * `aria-activedescendant`, and a combobox trigger's `aria-activedescendant`
10
- * must all agree on, so it is built in exactly one place.
11
- *
12
- * Option keys are consumer data and may contain whitespace ("New York"), which
13
- * an id can hold but an IDREF cannot: `aria-activedescendant="m-option-New
14
- * York"` is a list of two references, neither of which resolves. Encoding the
15
- * key keeps the id a single token.
16
- */
17
- const menuOptionId = (menuId, key) => `${menuId}-option-${encodeURIComponent(key)}`;
18
- exports.menuOptionId = menuOptionId;
@@ -1,93 +0,0 @@
1
- import * as React from 'react';
2
- /**
3
- * Keyboard navigation for the Menu option list. Three interaction models,
4
- * selected via `mode`:
5
- *
6
- * 1. `'roving'` — ROVING FOCUS (default; menu-button popups). Options are native
7
- * <button>s, so ArrowUp/ArrowDown move real DOM focus between the enabled
8
- * options and Space/Enter activate the focused option natively. Home/End jump
9
- * to the first/last option, PageUp/PageDown move by a page, and typing a
10
- * printable character jumps to the next matching label (type-ahead).
11
- *
12
- * 2. `'activedescendant'` — SELECT-ONLY COMBOBOX. DOM focus stays on the
13
- * combobox input; the "active" option is a virtual highlight reported via
14
- * `setActiveOptionKey` (surfaced as the input's aria-activedescendant) and
15
- * NEVER takes real focus. Arrows/Home/End/PageUp/PageDown move the highlight,
16
- * printable characters type-ahead, and Enter selects the active option.
17
- *
18
- * 3. `'search'` — EDITABLE COMBOBOX. Like `'activedescendant'`, but the input is
19
- * a free-text field: printable keys, Left/Right and Home/End are left to the
20
- * input for text editing (no type-ahead). Arrows/PageUp/PageDown move the
21
- * highlight and Enter selects.
22
- *
23
- * Navigation wraps around at the ends. Options are discovered from the DOM
24
- * (`[data-menu-option-key]`) so it always matches what is rendered after search
25
- * filtering / grouping.
26
- *
27
- * Tab / Shift+Tab keep focus moving through the menu's own focus zones in DOM
28
- * order — header → search → options → footer — and only close the menu (via
29
- * `onTabOut`) when focus would leave the menu entirely (Tab past the last
30
- * focusable, Shift+Tab before the first). This keeps focus from silently
31
- * dropping to <body> when the popup renders in a portal with nothing after it.
32
- */
33
- export type MenuNavigationMode = 'roving' | 'activedescendant' | 'search';
34
- /** One navigable option, for menus whose option DOM is windowed. */
35
- export interface MenuNavigationOption {
36
- key: string;
37
- label: string;
38
- }
39
- interface UseMenuKeyboardNavigationArgs {
40
- /** Ref to the element that contains the option buttons (the Menu listbox). */
41
- containerRef: React.RefObject<HTMLElement | null>;
42
- /** Disable all handling (e.g. when the menu is disabled or closed). */
43
- enabled?: boolean;
44
- /** Interaction model — see the file header. Defaults to `'roving'`. */
45
- mode?: MenuNavigationMode;
46
- /** Key of the option currently highlighted (needed for the non-roving modes). */
47
- activeOptionKey?: string | null;
48
- /** Select the option with the given key (used for Enter in the combobox modes). */
49
- onSelectKey?: (key: string) => void;
50
- /** Report the option key that owns the roving tab stop / active descendant. */
51
- setActiveOptionKey: (key: string | null) => void;
52
- /** Close the menu when Tab / Shift+Tab would move focus out of it. */
53
- onTabOut?: () => void;
54
- /**
55
- * Combobox menus only: return focus to the trigger input (keeping the menu
56
- * open) when Tab / Shift+Tab moves off a header/footer control back toward the
57
- * input — which sits OUTSIDE the menu DOM, so the input is the logical centre
58
- * of the tab order (header ← input → footer), not an edge. When omitted (the
59
- * roving menus) the input lives inside the menu, so the simple boundary close
60
- * applies instead.
61
- */
62
- onReturnFocusToInput?: () => void;
63
- /**
64
- * Enabled options in render order. Supply this when the option DOM is WINDOWED
65
- * (react-window virtualization): only a slice of the rows exists at any moment,
66
- * so DOM discovery would confine Home/End, PageUp/PageDown, wrap-around and
67
- * type-ahead to that slice — e.g. End on a 100-page Pagination would stop at
68
- * page 10. When supplied, all index maths resolve against this list and the
69
- * consumer is responsible for scrolling the active option into view (Menu does
70
- * this from `activeOptionKey`). Omit it for plain menus: the DOM is then the
71
- * source of truth, which keeps grouped/compose layouts and `resolveLabel`
72
- * type-ahead working off the text actually rendered.
73
- */
74
- options?: MenuNavigationOption[];
75
- }
76
- /**
77
- * Focusable controls inside the menu's sticky header / footer region, in DOM
78
- * order. Used by combobox triggers (Dropdown / Typeahead) so Tab / Shift+Tab can
79
- * move focus into the footer / header instead of closing the menu when one
80
- * holds focusable controls (e.g. Cancel / Save buttons).
81
- */
82
- export declare const getMenuRegionFocusables: (menu: HTMLElement | null, region: "header" | "footer") => HTMLElement[];
83
- /**
84
- * Move focus into the menu's footer (Tab) or header (Shift+Tab) edge if that
85
- * region has focusable controls. Returns true when it moved focus, so a combobox
86
- * trigger can close the menu only when there was no chrome to land on.
87
- */
88
- export declare const moveFocusToMenuChrome: (menu: HTMLElement | null, shiftKey: boolean) => boolean;
89
- export declare const useMenuKeyboardNavigation: ({ containerRef, enabled, mode, activeOptionKey, onSelectKey, setActiveOptionKey, onTabOut, onReturnFocusToInput, options, }: UseMenuKeyboardNavigationArgs) => {
90
- onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void;
91
- };
92
- export {};
93
- //# sourceMappingURL=useMenuKeyboardNavigation.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"useMenuKeyboardNavigation.d.ts","sourceRoot":"","sources":["../../../src/components/Menu/useMenuKeyboardNavigation.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,MAAM,kBAAkB,GAAG,QAAQ,GAAG,kBAAkB,GAAG,QAAQ,CAAC;AAE1E,oEAAoE;AACpE,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACf;AAaD,UAAU,6BAA6B;IACrC,8EAA8E;IAC9E,YAAY,EAAE,KAAK,CAAC,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAClD,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,uEAAuE;IACvE,IAAI,CAAC,EAAE,kBAAkB,CAAC;IAC1B,iFAAiF;IACjF,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,mFAAmF;IACnF,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,+EAA+E;IAC/E,kBAAkB,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACjD,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,MAAM,IAAI,CAAC;IAClC;;;;;;;;;;OAUG;IACH,OAAO,CAAC,EAAE,oBAAoB,EAAE,CAAC;CAClC;AAkDD;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,GAClC,MAAM,WAAW,GAAG,IAAI,EACxB,QAAQ,QAAQ,GAAG,QAAQ,KAC1B,WAAW,EAUb,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GAChC,MAAM,WAAW,GAAG,IAAI,EACxB,UAAU,OAAO,KAChB,OAYF,CAAC;AASF,eAAO,MAAM,yBAAyB,GAAI,6HAUvC,6BAA6B,KAAG;IACjC,SAAS,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,aAAa,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC;CAiY9D,CAAC"}
@@ -1,471 +0,0 @@
1
- "use strict";
2
-
3
- Object.defineProperty(exports, "__esModule", {
4
- value: true
5
- });
6
- exports.useMenuKeyboardNavigation = exports.moveFocusToMenuChrome = exports.getMenuRegionFocusables = void 0;
7
- var React = _interopRequireWildcard(require("react"));
8
- function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
9
- /**
10
- * Keyboard navigation for the Menu option list. Three interaction models,
11
- * selected via `mode`:
12
- *
13
- * 1. `'roving'` — ROVING FOCUS (default; menu-button popups). Options are native
14
- * <button>s, so ArrowUp/ArrowDown move real DOM focus between the enabled
15
- * options and Space/Enter activate the focused option natively. Home/End jump
16
- * to the first/last option, PageUp/PageDown move by a page, and typing a
17
- * printable character jumps to the next matching label (type-ahead).
18
- *
19
- * 2. `'activedescendant'` — SELECT-ONLY COMBOBOX. DOM focus stays on the
20
- * combobox input; the "active" option is a virtual highlight reported via
21
- * `setActiveOptionKey` (surfaced as the input's aria-activedescendant) and
22
- * NEVER takes real focus. Arrows/Home/End/PageUp/PageDown move the highlight,
23
- * printable characters type-ahead, and Enter selects the active option.
24
- *
25
- * 3. `'search'` — EDITABLE COMBOBOX. Like `'activedescendant'`, but the input is
26
- * a free-text field: printable keys, Left/Right and Home/End are left to the
27
- * input for text editing (no type-ahead). Arrows/PageUp/PageDown move the
28
- * highlight and Enter selects.
29
- *
30
- * Navigation wraps around at the ends. Options are discovered from the DOM
31
- * (`[data-menu-option-key]`) so it always matches what is rendered after search
32
- * filtering / grouping.
33
- *
34
- * Tab / Shift+Tab keep focus moving through the menu's own focus zones in DOM
35
- * order — header → search → options → footer — and only close the menu (via
36
- * `onTabOut`) when focus would leave the menu entirely (Tab past the last
37
- * focusable, Shift+Tab before the first). This keeps focus from silently
38
- * dropping to <body> when the popup renders in a portal with nothing after it.
39
- */
40
-
41
- /** One navigable option, for menus whose option DOM is windowed. */
42
-
43
- const OPTION_SELECTOR = '[data-menu-option-key]';
44
- // Elements that can hold a tab stop inside the menu (header/footer controls,
45
- // the search input, and the single roving option whose tabIndex is 0).
46
- const TABBABLE_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]';
47
- const PAGE_SIZE = 10;
48
- const TYPEAHEAD_RESET_MS = 600;
49
- // Frames to wait for a windowed list to mount the row we just moved to before
50
- // giving up on placing real focus on it.
51
- const MOUNT_RETRY_FRAMES = 3;
52
- const getEnabledOptionEls = container => {
53
- if (!container) {
54
- return [];
55
- }
56
- return Array.from(container.querySelectorAll(OPTION_SELECTOR)).filter(el => el.getAttribute('aria-disabled') !== 'true' && !el.disabled);
57
- };
58
-
59
- // Enabled + visible. Used for elements we focus PROGRAMMATICALLY (footer/header
60
- // controls via moveFocusToMenuChrome), so the natural tab order (tabIndex) is
61
- // irrelevant — el.focus() works regardless. This matters because a combobox
62
- // menu's footer/header controls are observed to sit at tabIndex -1 while the
63
- // input holds focus, which a tabIndex-based filter (isTabbable) would wrongly
64
- // skip.
65
- const isFocusableControl = el => {
66
- if (el.disabled) {
67
- return false;
68
- }
69
- if (el.getAttribute('aria-disabled') === 'true') {
70
- return false;
71
- }
72
- // Skip hidden elements (no layout box).
73
- return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0;
74
- };
75
-
76
- // As above, but also in the natural tab order right now (tabIndex >= 0). Used
77
- // for the roving boundary check, where focus actually rests on the element.
78
- const isTabbable = el => el.tabIndex >= 0 && isFocusableControl(el);
79
-
80
- /**
81
- * Focusable elements inside the menu, in DOM order. With roving tabindex only
82
- * the active option is tabbable (tabIndex 0); the others and the menu container
83
- * are tabIndex -1, so they are excluded — leaving header/footer controls, the
84
- * search input and the single active option.
85
- */
86
- const getTabbables = container => Array.from(container.querySelectorAll(TABBABLE_SELECTOR)).filter(isTabbable);
87
-
88
- /**
89
- * Focusable controls inside the menu's sticky header / footer region, in DOM
90
- * order. Used by combobox triggers (Dropdown / Typeahead) so Tab / Shift+Tab can
91
- * move focus into the footer / header instead of closing the menu when one
92
- * holds focusable controls (e.g. Cancel / Save buttons).
93
- */
94
- const getMenuRegionFocusables = (menu, region) => {
95
- const regionEl = menu?.querySelector(`[data-menu-region="${region}"]`);
96
- if (!regionEl) {
97
- return [];
98
- }
99
- return Array.from(regionEl.querySelectorAll(TABBABLE_SELECTOR)).filter(isFocusableControl);
100
- };
101
-
102
- /**
103
- * Move focus into the menu's footer (Tab) or header (Shift+Tab) edge if that
104
- * region has focusable controls. Returns true when it moved focus, so a combobox
105
- * trigger can close the menu only when there was no chrome to land on.
106
- */
107
- exports.getMenuRegionFocusables = getMenuRegionFocusables;
108
- const moveFocusToMenuChrome = (menu, shiftKey) => {
109
- const focusables = getMenuRegionFocusables(menu, shiftKey ? 'header' : 'footer');
110
- if (!focusables.length) {
111
- return false;
112
- }
113
- // Tab → first control in the footer; Shift+Tab → last control in the header.
114
- const target = shiftKey ? focusables[focusables.length - 1] : focusables[0];
115
- target.focus();
116
- return true;
117
- };
118
- exports.moveFocusToMenuChrome = moveFocusToMenuChrome;
119
- const isPrintableCharacter = event => event.key.length === 1 && event.key !== ' ' && !event.altKey && !event.ctrlKey && !event.metaKey;
120
- const useMenuKeyboardNavigation = _ref => {
121
- let {
122
- containerRef,
123
- enabled = true,
124
- mode = 'roving',
125
- activeOptionKey,
126
- onSelectKey,
127
- setActiveOptionKey,
128
- onTabOut,
129
- onReturnFocusToInput,
130
- options
131
- } = _ref;
132
- const typeahead = React.useRef({
133
- query: '',
134
- at: 0
135
- });
136
- const keyOf = el => el?.getAttribute('data-menu-option-key') ?? null;
137
-
138
- // Move the highlight without moving focus (combobox / active-descendant model).
139
- const highlightAt = (els, index) => {
140
- const el = els[index];
141
- if (!el) {
142
- return;
143
- }
144
- el.scrollIntoView({
145
- block: 'nearest'
146
- });
147
- setActiveOptionKey(keyOf(el));
148
- };
149
-
150
- // Move real DOM focus (roving model).
151
- const focusAt = (els, index) => {
152
- const el = els[index];
153
- if (!el) {
154
- return;
155
- }
156
- el.focus();
157
- el.scrollIntoView({
158
- block: 'nearest'
159
- });
160
- setActiveOptionKey(keyOf(el));
161
- };
162
-
163
- /** Type-ahead: jump to the next option whose label matches the typed text. */
164
- const typeaheadTo = _ref2 => {
165
- let {
166
- count,
167
- labelAt,
168
- currentIndex,
169
- char,
170
- move
171
- } = _ref2;
172
- const now = Date.now();
173
- const state = typeahead.current;
174
- if (now - state.at > TYPEAHEAD_RESET_MS) {
175
- state.query = '';
176
- }
177
- state.at = now;
178
- state.query += char.toLowerCase();
179
- const isRepeat = state.query.length > 1 && state.query.split('').every(c => c === state.query[0]);
180
- const query = isRepeat ? state.query[0] : state.query;
181
- // First keystroke or a repeated character advances past the current option
182
- // so repeats cycle; accumulating distinct characters refines in place.
183
- const offset = state.query.length === 1 || isRepeat ? 1 : 0;
184
- const base = currentIndex < 0 ? -offset : currentIndex;
185
- for (let i = 0; i < count; i++) {
186
- const idx = (base + offset + i + count) % count;
187
- if (labelAt(idx).startsWith(query)) {
188
- move(idx);
189
- return;
190
- }
191
- }
192
- };
193
-
194
- /**
195
- * Search mode only: a printable key pressed while focus is somewhere in the
196
- * menu OTHER than the search input (in practice the menu container — the
197
- * fallback target when every option is disabled) redirects focus to the input
198
- * and forwards the character, so typing still lands in the search box.
199
- * Header/footer controls never reach here: onKeyDown's `[data-menu-region]`
200
- * guard returns before the printable branch when focus is in that chrome.
201
- */
202
- const redirectPrintableToInput = event => {
203
- const container = containerRef.current;
204
- // Scope to the Menu's OWN search field via the marker it renders, not to a
205
- // role — a consumer header/footer could contain an unrelated search input,
206
- // and matching that would replay the user's keystrokes into the wrong field.
207
- const searchInput = container?.querySelector('input[data-menu-search]');
208
- if (!searchInput || typeof document === 'undefined' || document.activeElement === searchInput) {
209
- return false;
210
- }
211
- event.preventDefault();
212
- searchInput.focus();
213
- searchInput.scrollIntoView({
214
- block: 'nearest'
215
- });
216
- if (typeof window !== 'undefined') {
217
- const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
218
- if (nativeSetter) {
219
- nativeSetter.call(searchInput, searchInput.value + event.key);
220
- searchInput.dispatchEvent(new Event('input', {
221
- bubbles: true
222
- }));
223
- }
224
- }
225
- return true;
226
- };
227
-
228
- /**
229
- * Keep Tab / Shift+Tab inside the menu's focus zones (header → search →
230
- * options → footer) and close the menu when focus would leave it entirely,
231
- * instead of letting it fall to <body> below the portal. Internal moves
232
- * (e.g. option → footer button) fall through to the browser default.
233
- *
234
- * Combobox menus (when `onReturnFocusToInput` is set) treat the trigger input
235
- * — which lives OUTSIDE the menu DOM — as the logical centre of the tab order
236
- * (header ← input → footer). So Tabbing toward the input (Shift+Tab off the
237
- * footer's first control, or Tab off the header's last control) returns focus
238
- * to the input with the menu open, and only the OUTER edges (Tab past the last
239
- * footer control, Shift+Tab before the first header control) close the menu.
240
- */
241
- const handleTab = event => {
242
- const container = containerRef.current;
243
- if (!container || !onTabOut) {
244
- return;
245
- }
246
- const focused = typeof document !== 'undefined' ? document.activeElement : null;
247
- if (onReturnFocusToInput) {
248
- const footers = getMenuRegionFocusables(container, 'footer');
249
- const headers = getMenuRegionFocusables(container, 'header');
250
- // Moving off a region back toward the input (Shift+Tab off the footer's
251
- // first control, or Tab off the header's last control) returns focus to
252
- // the input with the menu open — the input is the centre of the tab order.
253
- const returningToInput = event.shiftKey ? footers.length > 0 && focused === footers[0] : headers.length > 0 && focused === headers[headers.length - 1];
254
- if (returningToInput) {
255
- event.preventDefault();
256
- onReturnFocusToInput();
257
- return;
258
- }
259
- // Anything else (footer/header outer edge, or focus not on a region edge)
260
- // falls through to the boundary close logic below, so leaving the menu's
261
- // outer edge closes it and never drops focus to <body>.
262
- }
263
- const tabbables = getTabbables(container);
264
- const index = focused ? tabbables.indexOf(focused) : -1;
265
- // Focus is inside the menu but not on one of its tab stops — in practice the
266
- // menu container itself, which is the documented fallback target when every
267
- // option is disabled or the option list is still empty. Treat it as sitting
268
- // BEFORE the first tab stop: Tab moves forward into the menu's own stops
269
- // (header / footer controls) instead of closing and leaving them unreachable,
270
- // while Shift+Tab keeps moving backwards and falls through to the close
271
- // below — jumping forward to the last control would reverse the user's
272
- // direction of travel.
273
- if (!event.shiftKey && index === -1 && tabbables.length) {
274
- event.preventDefault();
275
- tabbables[0].focus();
276
- return;
277
- }
278
- const leavingMenu = event.shiftKey ? index <= 0 : index === tabbables.length - 1;
279
- if (leavingMenu) {
280
- // Stop the browser moving focus out (to <body>); close instead so the
281
- // consumer's ClickAway returns focus to the trigger.
282
- event.preventDefault();
283
- onTabOut();
284
- }
285
- };
286
- const onKeyDown = event => {
287
- // Tab management runs even when option navigation is disabled (external
288
- // combobox menus) so that, once focus is inside the footer / header, Tabbing
289
- // off the edge still closes the menu instead of dropping focus to <body>.
290
- if (event.key === 'Tab') {
291
- handleTab(event);
292
- return;
293
- }
294
- if (!enabled) {
295
- return;
296
- }
297
- // While focus sits in the header/footer chrome (e.g. after Tabbing to a
298
- // footer button), the menu's key handler must stay out of the way: arrows
299
- // must not move or steal the option highlight, and printable keys must not
300
- // be redirected into the search input. Let the focused control own its keys.
301
- const focused = typeof document !== 'undefined' ? document.activeElement : null;
302
- if (focused?.closest('[data-menu-region]')) {
303
- return;
304
- }
305
- const els = getEnabledOptionEls(containerRef.current);
306
- // A windowed list only has a slice of its rows in the DOM, so the caller
307
- // supplies the real option list and it wins over what we can see.
308
- const windowed = Boolean(options?.length);
309
- const count = windowed ? options.length : els.length;
310
- if (!count) {
311
- return;
312
- }
313
- const movesFocus = mode === 'roving';
314
- const hasSearchField = mode === 'search';
315
- // Home/End (and Cmd+Arrow) belong to the caret only while the caret HAS
316
- // focus. A searchable menu's virtualized listbox is itself a tab stop; once
317
- // focus is on the list, those keys move the highlight like any listbox.
318
- const caretOwnsKeys = hasSearchField && focused instanceof HTMLInputElement && focused.getAttribute('data-menu-search') !== null;
319
- const labelAt = index => windowed ? (options[index].label ?? '').trim().toLowerCase() : (els[index].textContent ?? '').trim().toLowerCase();
320
- const moveWindowed = index => {
321
- const key = options[index].key;
322
- // Publishing the key is the whole move: the consumer keys its virtual list
323
- // off `activeOptionKey`, so this both scrolls the row into view and makes
324
- // aria-activedescendant resolve once that row mounts. Deliberately no
325
- // scrollIntoView here — that would fight the consumer's own scrolling.
326
- setActiveOptionKey(key);
327
- if (!movesFocus) {
328
- return;
329
- }
330
- // Roving mode needs REAL focus, and the row may not be mounted yet when the
331
- // list is windowed. Retry for a few frames until the consumer's scroll has
332
- // rendered it, rather than assuming a single frame is enough.
333
- let attempts = 0;
334
- const focusRow = () => {
335
- const el = containerRef.current?.querySelector(`[data-menu-option-key="${CSS.escape(key)}"]`);
336
- if (el) {
337
- el.focus({
338
- preventScroll: true
339
- });
340
- return;
341
- }
342
- if (attempts < MOUNT_RETRY_FRAMES) {
343
- attempts += 1;
344
- requestAnimationFrame(focusRow);
345
- }
346
- };
347
- focusRow();
348
- };
349
- const move = windowed ? moveWindowed : movesFocus ? index => focusAt(els, index) : index => highlightAt(els, index);
350
- const currentIndex = windowed ? activeOptionKey ? options.findIndex(option => option.key === activeOptionKey) : -1 : movesFocus ? els.findIndex(el => typeof document !== 'undefined' && el === document.activeElement) : activeOptionKey ? els.map(el => keyOf(el)).indexOf(activeOptionKey) : -1;
351
- const fromIndex = currentIndex < 0 ? 0 : currentIndex;
352
-
353
- // Mac keyboards have no End / Home key — those are Fn+Right / Fn+Left — so
354
- // Cmd+Down / Cmd+Up stand in for them, which is the "jump to the end"
355
- // gesture macOS uses everywhere else. Subject to the same search-input
356
- // exemption as End / Home below, because in a text field the platform binds
357
- // them to the caret. Option and Control leave the key to the browser
358
- // instead of being silently treated as an unmodified arrow.
359
- const jumpsToEdge = event.metaKey && !event.altKey && !event.ctrlKey;
360
- if ((event.altKey || event.ctrlKey) && !event.metaKey) {
361
- return;
362
- }
363
- switch (event.key) {
364
- case 'ArrowDown':
365
- {
366
- if (jumpsToEdge) {
367
- if (!caretOwnsKeys) {
368
- event.preventDefault();
369
- move(count - 1);
370
- }
371
- break;
372
- }
373
- // Wraps from the last option back to the first.
374
- event.preventDefault();
375
- move(currentIndex < 0 ? 0 : (currentIndex + 1) % count);
376
- break;
377
- }
378
- case 'ArrowUp':
379
- {
380
- if (jumpsToEdge) {
381
- if (!caretOwnsKeys) {
382
- event.preventDefault();
383
- move(0);
384
- }
385
- break;
386
- }
387
- // Wraps from the first option back to the last.
388
- event.preventDefault();
389
- move(currentIndex < 0 ? count - 1 : (currentIndex - 1 + count) % count);
390
- break;
391
- }
392
- case 'PageDown':
393
- {
394
- event.preventDefault();
395
- move(Math.min(fromIndex + PAGE_SIZE, count - 1));
396
- break;
397
- }
398
- case 'PageUp':
399
- {
400
- event.preventDefault();
401
- move(Math.max(fromIndex - PAGE_SIZE, 0));
402
- break;
403
- }
404
- case 'Home':
405
- {
406
- // Editable (search) input keeps Home/End for text-cursor movement.
407
- if (!caretOwnsKeys) {
408
- event.preventDefault();
409
- move(0);
410
- }
411
- break;
412
- }
413
- case 'End':
414
- {
415
- if (!caretOwnsKeys) {
416
- event.preventDefault();
417
- move(count - 1);
418
- }
419
- break;
420
- }
421
- case ' ':
422
- {
423
- // In the text field Space types; on a roving option the <button> handles
424
- // it natively. With focus on the list itself (the focusable listbox of a
425
- // searchable menu) Space selects the active option, as it does for Enter.
426
- if (!movesFocus && !caretOwnsKeys && activeOptionKey && onSelectKey) {
427
- event.preventDefault();
428
- onSelectKey(activeOptionKey);
429
- }
430
- break;
431
- }
432
- case 'Enter':
433
- {
434
- // Roving mode lets the focused <button> handle Enter natively. The
435
- // combobox modes keep focus on the input, so select explicitly.
436
- if (!movesFocus && activeOptionKey && onSelectKey) {
437
- event.preventDefault();
438
- onSelectKey(activeOptionKey);
439
- }
440
- break;
441
- }
442
- default:
443
- {
444
- // Space/Enter in roving mode are handled natively by the focused option.
445
- if (!isPrintableCharacter(event)) {
446
- break;
447
- }
448
- if (hasSearchField) {
449
- // Editable input: let the character type into the field (it filters).
450
- // Only step in when focus is somewhere else in the menu.
451
- redirectPrintableToInput(event);
452
- } else {
453
- // Roving / select-only combobox: printable jumps to a matching label.
454
- event.preventDefault();
455
- typeaheadTo({
456
- count,
457
- labelAt,
458
- currentIndex,
459
- char: event.key,
460
- move
461
- });
462
- }
463
- break;
464
- }
465
- }
466
- };
467
- return {
468
- onKeyDown
469
- };
470
- };
471
- exports.useMenuKeyboardNavigation = useMenuKeyboardNavigation;