@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.
- package/.cspell/custom-words.txt +0 -9
- package/CHANGELOG.md +9 -0
- package/lib/components/Menu/Menu.d.ts +1 -71
- package/lib/components/Menu/Menu.d.ts.map +1 -1
- package/lib/components/Menu/Menu.js +221 -1035
- package/lib/components/Menu/Menu.module.css +27 -148
- package/lib/components/Menu/MenuOptionButton.d.ts +2 -23
- package/lib/components/Menu/MenuOptionButton.d.ts.map +1 -1
- package/lib/components/Menu/MenuOptionButton.js +38 -87
- package/lib/components/Menu/index.d.ts +0 -2
- package/lib/components/Menu/index.d.ts.map +1 -1
- package/lib/components/Menu/index.js +0 -22
- package/lib/hooks/index.d.ts +0 -1
- package/lib/hooks/index.d.ts.map +1 -1
- package/lib/hooks/index.js +0 -11
- package/lib/utils/click-away/click-away.d.ts +0 -3
- package/lib/utils/click-away/click-away.d.ts.map +1 -1
- package/lib/utils/click-away/click-away.js +0 -49
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/lib/components/Menu/menuOptionId.d.ts +0 -12
- package/lib/components/Menu/menuOptionId.d.ts.map +0 -1
- package/lib/components/Menu/menuOptionId.js +0 -18
- package/lib/components/Menu/useMenuKeyboardNavigation.d.ts +0 -93
- package/lib/components/Menu/useMenuKeyboardNavigation.d.ts.map +0 -1
- package/lib/components/Menu/useMenuKeyboardNavigation.js +0 -471
- package/lib/components/Menu/useMenuTrigger.d.ts +0 -183
- package/lib/components/Menu/useMenuTrigger.d.ts.map +0 -1
- package/lib/components/Menu/useMenuTrigger.js +0 -347
- package/lib/hooks/useInteractionModality/index.d.ts +0 -2
- package/lib/hooks/useInteractionModality/index.d.ts.map +0 -1
- package/lib/hooks/useInteractionModality/index.js +0 -16
- package/lib/hooks/useInteractionModality/useInteractionModality.d.ts +0 -15
- package/lib/hooks/useInteractionModality/useInteractionModality.d.ts.map +0 -1
- 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;
|