@spaced-out/ui-design-system 0.6.34 → 0.6.35-beta.0
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 +9 -0
- package/CHANGELOG.md +7 -0
- package/lib/components/Menu/Menu.d.ts +71 -0
- package/lib/components/Menu/Menu.d.ts.map +1 -1
- package/lib/components/Menu/Menu.js +1028 -185
- package/lib/components/Menu/Menu.module.css +148 -27
- package/lib/components/Menu/MenuOptionButton.d.ts +23 -2
- package/lib/components/Menu/MenuOptionButton.d.ts.map +1 -1
- package/lib/components/Menu/MenuOptionButton.js +87 -38
- package/lib/components/Menu/index.d.ts +2 -0
- package/lib/components/Menu/index.d.ts.map +1 -1
- package/lib/components/Menu/index.js +22 -0
- package/lib/components/Menu/menuOptionId.d.ts +12 -0
- package/lib/components/Menu/menuOptionId.d.ts.map +1 -0
- package/lib/components/Menu/menuOptionId.js +18 -0
- package/lib/components/Menu/useMenuKeyboardNavigation.d.ts +93 -0
- package/lib/components/Menu/useMenuKeyboardNavigation.d.ts.map +1 -0
- package/lib/components/Menu/useMenuKeyboardNavigation.js +471 -0
- package/lib/components/Menu/useMenuTrigger.d.ts +183 -0
- package/lib/components/Menu/useMenuTrigger.d.ts.map +1 -0
- package/lib/components/Menu/useMenuTrigger.js +347 -0
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.d.ts.map +1 -1
- package/lib/hooks/index.js +11 -0
- package/lib/hooks/useInteractionModality/index.d.ts +2 -0
- package/lib/hooks/useInteractionModality/index.d.ts.map +1 -0
- package/lib/hooks/useInteractionModality/index.js +16 -0
- package/lib/hooks/useInteractionModality/useInteractionModality.d.ts +15 -0
- package/lib/hooks/useInteractionModality/useInteractionModality.d.ts.map +1 -0
- package/lib/hooks/useInteractionModality/useInteractionModality.js +103 -0
- package/lib/utils/click-away/click-away.d.ts +3 -0
- package/lib/utils/click-away/click-away.d.ts.map +1 -1
- package/lib/utils/click-away/click-away.js +49 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
|
@@ -3,19 +3,28 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", {
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
|
-
exports.Menu = void 0;
|
|
6
|
+
exports.getInitialActiveOptionKey = exports.getFlatOptions = exports.Menu = void 0;
|
|
7
7
|
var React = _interopRequireWildcard(require("react"));
|
|
8
8
|
var _reactWindow = require("react-window");
|
|
9
|
+
var _hooks = require("../../hooks");
|
|
9
10
|
var _classify = require("../../utils/classify");
|
|
10
11
|
var _menu = require("../../utils/menu");
|
|
12
|
+
var _mergeRefs = require("../../utils/merge-refs");
|
|
11
13
|
var _qa = require("../../utils/qa");
|
|
12
14
|
var _MenuOptionButton = require("./MenuOptionButton");
|
|
15
|
+
var _menuOptionId = require("./menuOptionId");
|
|
16
|
+
var _useMenuKeyboardNavigation = require("./useMenuKeyboardNavigation");
|
|
13
17
|
var _SearchInput = require("../SearchInput");
|
|
14
18
|
var _Text = require("../Text");
|
|
15
19
|
var _MenuModule = _interopRequireDefault(require("./Menu.module.css"));
|
|
16
20
|
var _jsxRuntime = require("react/jsx-runtime");
|
|
17
21
|
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
18
22
|
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); }
|
|
23
|
+
/**
|
|
24
|
+
* Either react-window list Menu may render. Both expose the `scrollToItem`
|
|
25
|
+
* used to keep the keyboard's active option mounted.
|
|
26
|
+
*/
|
|
27
|
+
|
|
19
28
|
// Render first available option set
|
|
20
29
|
|
|
21
30
|
const menuSizeMedium = 276,
|
|
@@ -23,36 +32,290 @@ const menuSizeMedium = 276,
|
|
|
23
32
|
const buttonSizeMedium = 40,
|
|
24
33
|
buttonSizeSmall = 32;
|
|
25
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Live region announcing the filtered result count. Rendered as a SIBLING of
|
|
37
|
+
* the listbox (not inside it) so the listbox owns only option children.
|
|
38
|
+
* `role="status"` already implies `aria-live="polite"`.
|
|
39
|
+
*/
|
|
40
|
+
const ResultStatus = _ref => {
|
|
41
|
+
let {
|
|
42
|
+
text,
|
|
43
|
+
testId
|
|
44
|
+
} = _ref;
|
|
45
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
46
|
+
role: "status",
|
|
47
|
+
className: _MenuModule.default.filterOptionsResultText,
|
|
48
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_Text.FormLabelSmall, {
|
|
49
|
+
color: "tertiary",
|
|
50
|
+
testId: (0, _qa.generateTestId)({
|
|
51
|
+
base: testId,
|
|
52
|
+
slot: 'result-text'
|
|
53
|
+
}),
|
|
54
|
+
children: text
|
|
55
|
+
})
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* role="listbox" around the option children, and — for every shape but a
|
|
60
|
+
* virtualized one — the element that scrolls them (`.optionsListboxScroll`).
|
|
61
|
+
* Under virtualization react-window owns the scroll box and carries the role
|
|
62
|
+
* instead (ListboxOuter), so this falls back to `display: contents` and leaves
|
|
63
|
+
* the card's flex layout untouched.
|
|
64
|
+
*
|
|
65
|
+
* Keeping the role on the element that scrolls is what lets it hold a tab stop
|
|
66
|
+
* when the menu drives its own filter field, and what earns the scroll area
|
|
67
|
+
* axe's combobox-popup exemption when a trigger drives it.
|
|
68
|
+
*/
|
|
69
|
+
const OptionsListbox = _ref2 => {
|
|
70
|
+
let {
|
|
71
|
+
menuId,
|
|
72
|
+
ariaLabelledBy,
|
|
73
|
+
ariaLabel,
|
|
74
|
+
optionsVariant,
|
|
75
|
+
scrolls,
|
|
76
|
+
tabIndex,
|
|
77
|
+
activeDescendantId,
|
|
78
|
+
children
|
|
79
|
+
} = _ref2;
|
|
80
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
81
|
+
role: "listbox",
|
|
82
|
+
id: menuId,
|
|
83
|
+
tabIndex: tabIndex,
|
|
84
|
+
"aria-activedescendant": activeDescendantId
|
|
85
|
+
// The element that actually scrolls, for consumers that need to scroll the
|
|
86
|
+
// menu programmatically (Dropdown's `scrollMenuToBottom`).
|
|
87
|
+
,
|
|
88
|
+
"data-menu-scroll-container": scrolls ? '' : undefined,
|
|
89
|
+
"aria-labelledby": ariaLabelledBy
|
|
90
|
+
// `listbox` is an accessible-name-required role, and Menu is used directly
|
|
91
|
+
// (not only behind a trigger that can name it), so default the name here
|
|
92
|
+
// rather than leaving every inline consumer with an unnamed listbox.
|
|
93
|
+
// Consumers should still pass something meaningful via `ariaLabelledBy` /
|
|
94
|
+
// `ariaLabel`; this only stops the unnamed case from existing at all.
|
|
95
|
+
,
|
|
96
|
+
"aria-label": ariaLabelledBy ? undefined : ariaLabel ?? 'Options',
|
|
97
|
+
"aria-multiselectable": optionsVariant === 'checkbox' || undefined,
|
|
98
|
+
className: scrolls ? _MenuModule.default.optionsListboxScroll : _MenuModule.default.optionsListbox,
|
|
99
|
+
children: children
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
* Wrapper element type for react-window's inner scroll container. Marks it
|
|
105
|
+
* role="presentation" so it doesn't sit as a generic node between the
|
|
106
|
+
* role="listbox" and its role="option" children (valid listbox ownership).
|
|
107
|
+
*/
|
|
108
|
+
const PresentationDiv = /*#__PURE__*/React.forwardRef((props, ref) => /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
109
|
+
ref: ref,
|
|
110
|
+
role: "presentation",
|
|
111
|
+
...props
|
|
112
|
+
}));
|
|
113
|
+
PresentationDiv.displayName = 'PresentationDiv';
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
* react-window owns the element that scrolls, so the listbox has to BE that
|
|
117
|
+
* element rather than a wrapper around it. A listbox wrapped around the
|
|
118
|
+
* scroller leaves a scrollable region that holds no focusable content — the
|
|
119
|
+
* options are `tabindex="-1"` under `aria-activedescendant` — which reads as an
|
|
120
|
+
* unreachable scroll area (axe `scrollable-region-focusable`) even though the
|
|
121
|
+
* combobox driving it scrolls the list with the arrow keys. Sitting on the
|
|
122
|
+
* scroller, it is the combobox's own popup, which is what that rule exempts.
|
|
123
|
+
*
|
|
124
|
+
* The attributes arrive through context because react-window renders
|
|
125
|
+
* `outerElementType` as a component TYPE: an inline wrapper closing over them
|
|
126
|
+
* would be a new type on every render and remount every row (see the row
|
|
127
|
+
* renderers below).
|
|
128
|
+
*/
|
|
129
|
+
const ListboxAttrsContext = /*#__PURE__*/React.createContext({});
|
|
130
|
+
const ListboxOuter = /*#__PURE__*/React.forwardRef((props, ref) => {
|
|
131
|
+
const {
|
|
132
|
+
menuId,
|
|
133
|
+
ariaLabelledBy,
|
|
134
|
+
ariaLabel,
|
|
135
|
+
optionsVariant,
|
|
136
|
+
tabIndex,
|
|
137
|
+
activeDescendantId
|
|
138
|
+
} = React.useContext(ListboxAttrsContext);
|
|
139
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
140
|
+
ref: ref,
|
|
141
|
+
role: "listbox",
|
|
142
|
+
id: menuId
|
|
143
|
+
// The element that actually scrolls, for consumers that need to scroll
|
|
144
|
+
// the menu programmatically (Dropdown's `scrollMenuToBottom`).
|
|
145
|
+
,
|
|
146
|
+
"data-menu-scroll-container": "",
|
|
147
|
+
"aria-labelledby": ariaLabelledBy
|
|
148
|
+
// `listbox` is an accessible-name-required role — see OptionsListbox.
|
|
149
|
+
,
|
|
150
|
+
"aria-label": ariaLabelledBy ? undefined : ariaLabel ?? 'Options',
|
|
151
|
+
"aria-multiselectable": optionsVariant === 'checkbox' || undefined,
|
|
152
|
+
tabIndex: tabIndex,
|
|
153
|
+
"aria-activedescendant": activeDescendantId,
|
|
154
|
+
...props
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
ListboxOuter.displayName = 'ListboxOuter';
|
|
158
|
+
|
|
26
159
|
// default group title row height when virtualizing groupTitleOptions
|
|
27
160
|
const groupTitleSize = 32;
|
|
161
|
+
/**
|
|
162
|
+
* Flatten groups into the single row list a virtualized grouped menu renders.
|
|
163
|
+
* Shared with Menu itself, which needs the same row order to translate an
|
|
164
|
+
* option key into the row index `scrollToItem` expects.
|
|
165
|
+
*/
|
|
166
|
+
const buildGroupVirtualRows = groups => {
|
|
167
|
+
const rows = [];
|
|
168
|
+
let flatOptionIndex = 0;
|
|
169
|
+
groups.forEach((optionsGroup, groupIndex) => {
|
|
170
|
+
if (optionsGroup.groupTitle) {
|
|
171
|
+
rows.push({
|
|
172
|
+
kind: 'title',
|
|
173
|
+
node: optionsGroup.groupTitle,
|
|
174
|
+
groupIndex
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
optionsGroup.options?.forEach((option, optionIndex) => {
|
|
178
|
+
rows.push({
|
|
179
|
+
kind: 'option',
|
|
180
|
+
option,
|
|
181
|
+
groupIndex,
|
|
182
|
+
optionIndex,
|
|
183
|
+
flatOptionIndex
|
|
184
|
+
});
|
|
185
|
+
flatOptionIndex += 1;
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
return rows;
|
|
189
|
+
};
|
|
190
|
+
|
|
28
191
|
// VariableSizeList caches row offsets, so the cache must be flushed whenever
|
|
29
192
|
// the rows or their heights change. `heightSignature` encodes every row's
|
|
30
193
|
// key and height; when it changes, resetAfterIndex invalidates the cache in
|
|
31
194
|
// place without remounting the list (a remount would drop scroll position).
|
|
32
|
-
|
|
195
|
+
|
|
196
|
+
function MenuVariableSizeList(_ref3) {
|
|
33
197
|
let {
|
|
34
198
|
heightSignature,
|
|
35
199
|
height,
|
|
36
200
|
itemCount,
|
|
37
201
|
itemSize,
|
|
38
202
|
itemKey,
|
|
203
|
+
itemData,
|
|
204
|
+
listRef,
|
|
39
205
|
children
|
|
40
|
-
} =
|
|
41
|
-
const
|
|
206
|
+
} = _ref3;
|
|
207
|
+
const innerListRef = React.useRef(null);
|
|
42
208
|
React.useEffect(() => {
|
|
43
|
-
|
|
209
|
+
innerListRef.current?.resetAfterIndex(0, true);
|
|
44
210
|
}, [heightSignature]);
|
|
45
211
|
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactWindow.VariableSizeList, {
|
|
46
|
-
ref: listRef,
|
|
212
|
+
ref: (0, _mergeRefs.mergeRefs)([innerListRef, listRef]),
|
|
47
213
|
height: height,
|
|
48
214
|
itemSize: itemSize,
|
|
49
215
|
itemCount: itemCount,
|
|
50
216
|
itemKey: itemKey,
|
|
217
|
+
itemData: itemData,
|
|
51
218
|
width: "initial",
|
|
219
|
+
outerElementType: ListboxOuter,
|
|
220
|
+
innerElementType: PresentationDiv,
|
|
52
221
|
children: children
|
|
53
222
|
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/*
|
|
226
|
+
* Row renderers for the virtualized lists, and the reason they live out here.
|
|
227
|
+
*
|
|
228
|
+
* react-window renders its `children` as a COMPONENT TYPE, so an inline arrow
|
|
229
|
+
* function is a new type on every render and React unmounts and remounts every
|
|
230
|
+
* row. Under roving focus that destroys the row the user is standing on — focus
|
|
231
|
+
* lands on <body>, the menu stops receiving keys, and arrows, Home/End and
|
|
232
|
+
* type-ahead all go dead. (In the combobox and search modes focus lives on the
|
|
233
|
+
* input, which is why the remount went unnoticed there.) Keeping the renderers
|
|
234
|
+
* stable and passing the per-render values through `itemData` keeps the rows
|
|
235
|
+
* mounted across re-renders.
|
|
236
|
+
*/
|
|
237
|
+
|
|
238
|
+
const FlatRow = _ref4 => {
|
|
239
|
+
let {
|
|
240
|
+
index,
|
|
241
|
+
style,
|
|
242
|
+
data
|
|
243
|
+
} = _ref4;
|
|
244
|
+
const {
|
|
245
|
+
options,
|
|
246
|
+
classNames,
|
|
247
|
+
testId,
|
|
248
|
+
optionProps
|
|
249
|
+
} = data;
|
|
250
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_MenuOptionButton.MenuOptionButton, {
|
|
251
|
+
option: options[index],
|
|
252
|
+
classNames: classNames,
|
|
253
|
+
style: style,
|
|
254
|
+
...optionProps,
|
|
255
|
+
setSize: options.length,
|
|
256
|
+
posInSet: index + 1,
|
|
257
|
+
testId: (0, _qa.generateTestId)({
|
|
258
|
+
base: testId,
|
|
259
|
+
slot: 'option',
|
|
260
|
+
index: index.toString()
|
|
261
|
+
})
|
|
262
|
+
});
|
|
54
263
|
};
|
|
55
|
-
const
|
|
264
|
+
const GroupRow = _ref5 => {
|
|
265
|
+
let {
|
|
266
|
+
index,
|
|
267
|
+
style,
|
|
268
|
+
data
|
|
269
|
+
} = _ref5;
|
|
270
|
+
const {
|
|
271
|
+
rows,
|
|
272
|
+
optionCount,
|
|
273
|
+
menuId,
|
|
274
|
+
classNames,
|
|
275
|
+
testId,
|
|
276
|
+
optionProps
|
|
277
|
+
} = data;
|
|
278
|
+
const row = rows[index];
|
|
279
|
+
if (row.kind === 'title') {
|
|
280
|
+
// A flattened row cannot contain the options that follow it — react-window
|
|
281
|
+
// positions every row absolutely — so the title is its own `group` naming
|
|
282
|
+
// itself from its content, rather than a presentation row. That keeps the
|
|
283
|
+
// listbox owning only option and group children (a group title passed as a
|
|
284
|
+
// custom node would otherwise expose that node as generic content inside
|
|
285
|
+
// the listbox) and keeps the group NAME in the tree, which a presentation
|
|
286
|
+
// row dropped. It is a real limitation all the same: the options sit beside
|
|
287
|
+
// the group rather than inside it, so AT reads the group as a row of its own
|
|
288
|
+
// and does not announce it as context for the options that follow.
|
|
289
|
+
const titleId = `${menuId}-group-${row.groupIndex}`;
|
|
290
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
291
|
+
style: style,
|
|
292
|
+
id: titleId,
|
|
293
|
+
role: "group",
|
|
294
|
+
"aria-labelledby": titleId,
|
|
295
|
+
className: (0, _classify.classify)(_MenuModule.default.groupTitleWrapper, classNames?.groupTitle),
|
|
296
|
+
"data-testid": (0, _qa.generateTestId)({
|
|
297
|
+
base: testId,
|
|
298
|
+
slot: 'group-title',
|
|
299
|
+
index: row.groupIndex.toString()
|
|
300
|
+
}),
|
|
301
|
+
children: row.node
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_MenuOptionButton.MenuOptionButton, {
|
|
305
|
+
option: row.option,
|
|
306
|
+
classNames: classNames,
|
|
307
|
+
style: style,
|
|
308
|
+
...optionProps,
|
|
309
|
+
setSize: optionCount,
|
|
310
|
+
posInSet: row.flatOptionIndex + 1,
|
|
311
|
+
testId: (0, _qa.generateTestId)({
|
|
312
|
+
base: testId,
|
|
313
|
+
slot: 'option',
|
|
314
|
+
index: `${row.groupIndex}-${row.optionIndex}`
|
|
315
|
+
})
|
|
316
|
+
});
|
|
317
|
+
};
|
|
318
|
+
const RenderOption = _ref6 => {
|
|
56
319
|
let {
|
|
57
320
|
options,
|
|
58
321
|
composeOptions,
|
|
@@ -61,17 +324,24 @@ const RenderOption = _ref2 => {
|
|
|
61
324
|
searchText = '',
|
|
62
325
|
showResultText = true,
|
|
63
326
|
testId,
|
|
327
|
+
listRef,
|
|
64
328
|
staticLabels = {
|
|
65
329
|
RESULT: 'result',
|
|
66
330
|
RESULTS: 'results',
|
|
67
331
|
SEARCH_PLACEHOLDER: 'Search...'
|
|
68
332
|
},
|
|
69
333
|
...restProps
|
|
70
|
-
} =
|
|
334
|
+
} = _ref6;
|
|
71
335
|
const {
|
|
72
336
|
allowSearch,
|
|
73
337
|
size,
|
|
74
|
-
|
|
338
|
+
menuId,
|
|
339
|
+
ariaLabelledBy,
|
|
340
|
+
ariaLabel,
|
|
341
|
+
optionsVariant,
|
|
342
|
+
virtualization,
|
|
343
|
+
externalKeyboardNav,
|
|
344
|
+
activeOptionKey
|
|
75
345
|
} = restProps;
|
|
76
346
|
const {
|
|
77
347
|
enable: isVirtualizationEnabled = false,
|
|
@@ -84,135 +354,147 @@ const RenderOption = _ref2 => {
|
|
|
84
354
|
const listHeight = menuHeight || (size === 'medium' ? menuSizeMedium : menuSizeSmall);
|
|
85
355
|
const resolveItemHeight = (option, index) => typeof itemHeight === 'function' ? itemHeight(option, index) : itemHeight || (size === 'medium' ? buttonSizeMedium : buttonSizeSmall);
|
|
86
356
|
const resolveGroupTitleHeight = (groupTitle, groupIndex) => typeof groupTitleHeight === 'function' ? groupTitleHeight(groupTitle, groupIndex) : groupTitleHeight || groupTitleSize;
|
|
357
|
+
/*
|
|
358
|
+
* When the menu owns its filter field and the list is virtualized, the
|
|
359
|
+
* scrolling list is a standalone listbox, and APG makes that focusable with
|
|
360
|
+
* the active option reported through `aria-activedescendant` — the field and
|
|
361
|
+
* the list each carry it, so whichever holds focus reports the same option. It
|
|
362
|
+
* also stops the virtualized scroll area reading as unreachable: the options
|
|
363
|
+
* are `tabindex="-1"`, so without a tab stop of its own the region has no
|
|
364
|
+
* focusable content at all (axe `scrollable-region-focusable`).
|
|
365
|
+
*
|
|
366
|
+
* Only the virtualized scroller can take that tab stop; the non-virtualized
|
|
367
|
+
* listbox is a `display: contents` wrapper with no box at all, and such an
|
|
368
|
+
* element is skipped by sequential focus navigation (see OptionsListbox).
|
|
369
|
+
* Behind a
|
|
370
|
+
* combobox trigger none of this applies — the trigger is the single tab stop
|
|
371
|
+
* and owns `aria-activedescendant`, and axe exempts a combobox's own popup —
|
|
372
|
+
* and a roving menu needs no tab stop here either, since its options are real
|
|
373
|
+
* focusable buttons.
|
|
374
|
+
*/
|
|
375
|
+
const listboxIsFocusable = Boolean(allowSearch && !externalKeyboardNav);
|
|
376
|
+
const listboxAttrs = {
|
|
377
|
+
menuId,
|
|
378
|
+
ariaLabelledBy,
|
|
379
|
+
ariaLabel,
|
|
380
|
+
optionsVariant,
|
|
381
|
+
// Under virtualization react-window's outer box scrolls and carries the
|
|
382
|
+
// role; every other shape scrolls on the listbox itself.
|
|
383
|
+
scrolls: !isVirtualizationEnabled,
|
|
384
|
+
tabIndex: listboxIsFocusable ? 0 : undefined,
|
|
385
|
+
activeDescendantId: listboxIsFocusable && activeOptionKey && menuId ? (0, _menuOptionId.menuOptionId)(menuId, activeOptionKey) : undefined
|
|
386
|
+
};
|
|
87
387
|
if (options && Array.isArray(options) && options.length) {
|
|
88
388
|
const optionsFiltered = !allowSearch ? options : (0, _menu.getFilteredOptionsFromSearchText)(options, searchText);
|
|
89
389
|
const finalResultText = !allowSearch ? '' : (0, _menu.getFilteredOptionsResultText)(optionsFiltered, staticLabels);
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const buttonOption = optionsFiltered[idx];
|
|
96
|
-
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_MenuOptionButton.MenuOptionButton, {
|
|
97
|
-
option: buttonOption,
|
|
98
|
-
classNames: classNames,
|
|
99
|
-
style: style,
|
|
100
|
-
...restProps,
|
|
101
|
-
isLastItem: idx === optionsFiltered.length - 1,
|
|
102
|
-
testId: (0, _qa.generateTestId)({
|
|
103
|
-
base: testId,
|
|
104
|
-
slot: 'option',
|
|
105
|
-
index: idx.toString()
|
|
106
|
-
})
|
|
107
|
-
});
|
|
390
|
+
const flatRowData = {
|
|
391
|
+
options: optionsFiltered,
|
|
392
|
+
classNames,
|
|
393
|
+
testId,
|
|
394
|
+
optionProps: restProps
|
|
108
395
|
};
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
...restProps,
|
|
137
|
-
isLastItem: idx === optionsFiltered.length - 1,
|
|
138
|
-
testId: (0, _qa.generateTestId)({
|
|
139
|
-
base: testId,
|
|
140
|
-
slot: 'option',
|
|
141
|
-
index: idx.toString()
|
|
396
|
+
if (isVirtualizationEnabled) {
|
|
397
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
|
|
398
|
+
children: [allowSearch && showResultText && /*#__PURE__*/(0, _jsxRuntime.jsx)(ResultStatus, {
|
|
399
|
+
text: finalResultText,
|
|
400
|
+
testId: testId
|
|
401
|
+
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(ListboxAttrsContext.Provider, {
|
|
402
|
+
value: listboxAttrs,
|
|
403
|
+
children: typeof itemHeight === 'function' ? /*#__PURE__*/(0, _jsxRuntime.jsx)(MenuVariableSizeList, {
|
|
404
|
+
listRef: listRef,
|
|
405
|
+
heightSignature: optionsFiltered.map((option, idx) => `${option.key}:${resolveItemHeight(option, idx)}`).join('|'),
|
|
406
|
+
height: listHeight,
|
|
407
|
+
itemSize: idx => resolveItemHeight(optionsFiltered[idx], idx),
|
|
408
|
+
itemCount: optionsFiltered.length,
|
|
409
|
+
itemKey: idx => optionsFiltered[idx].key,
|
|
410
|
+
itemData: flatRowData,
|
|
411
|
+
children: FlatRow
|
|
412
|
+
}) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactWindow.FixedSizeList, {
|
|
413
|
+
ref: listRef,
|
|
414
|
+
height: listHeight,
|
|
415
|
+
itemSize: itemHeight || (size === 'medium' ? buttonSizeMedium : buttonSizeSmall),
|
|
416
|
+
itemCount: optionsFiltered.length,
|
|
417
|
+
itemKey: idx => optionsFiltered[idx].key,
|
|
418
|
+
itemData: flatRowData,
|
|
419
|
+
width: "initial",
|
|
420
|
+
outerElementType: ListboxOuter,
|
|
421
|
+
innerElementType: PresentationDiv,
|
|
422
|
+
children: FlatRow
|
|
142
423
|
})
|
|
143
|
-
})
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
if (composeOptions && Array.isArray(composeOptions) && composeOptions.length) {
|
|
148
|
-
const optionsFiltered = !allowSearch ? composeOptions : (0, _menu.getFilteredComposeOptionsFromSearchText)(composeOptions, searchText);
|
|
149
|
-
const finalResultText = !allowSearch ? '' : (0, _menu.getFilteredComposeOptionsResultText)(optionsFiltered, staticLabels);
|
|
424
|
+
})]
|
|
425
|
+
});
|
|
426
|
+
}
|
|
150
427
|
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
|
|
151
|
-
children: [allowSearch && showResultText && /*#__PURE__*/(0, _jsxRuntime.jsx)(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}),
|
|
158
|
-
children: finalResultText
|
|
159
|
-
}), optionsFiltered.map((composeMenuOptions, index) =>
|
|
160
|
-
/*#__PURE__*/
|
|
161
|
-
// eslint-disable-next-line react/no-array-index-key
|
|
162
|
-
(0, _jsxRuntime.jsx)("span", {
|
|
163
|
-
className: _MenuModule.default.menuDivider,
|
|
164
|
-
children: composeMenuOptions.map((option, idx) => /*#__PURE__*/(0, _jsxRuntime.jsx)(React.Fragment, {
|
|
428
|
+
children: [allowSearch && showResultText && /*#__PURE__*/(0, _jsxRuntime.jsx)(ResultStatus, {
|
|
429
|
+
text: finalResultText,
|
|
430
|
+
testId: testId
|
|
431
|
+
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(OptionsListbox, {
|
|
432
|
+
...listboxAttrs,
|
|
433
|
+
children: optionsFiltered.map((option, idx) => /*#__PURE__*/(0, _jsxRuntime.jsx)(React.Fragment, {
|
|
165
434
|
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_MenuOptionButton.MenuOptionButton, {
|
|
166
435
|
option: option,
|
|
167
436
|
classNames: classNames,
|
|
168
437
|
...restProps,
|
|
169
|
-
isLastItem: index === optionsFiltered.length - 1 && idx === composeMenuOptions.length - 1,
|
|
170
438
|
testId: (0, _qa.generateTestId)({
|
|
171
439
|
base: testId,
|
|
172
440
|
slot: 'option',
|
|
173
|
-
index:
|
|
441
|
+
index: idx.toString()
|
|
174
442
|
})
|
|
175
443
|
})
|
|
176
444
|
}, option.key))
|
|
177
|
-
}
|
|
445
|
+
})]
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
if (composeOptions && Array.isArray(composeOptions) && composeOptions.length) {
|
|
449
|
+
const optionsFiltered = !allowSearch ? composeOptions : (0, _menu.getFilteredComposeOptionsFromSearchText)(composeOptions, searchText);
|
|
450
|
+
const finalResultText = !allowSearch ? '' : (0, _menu.getFilteredComposeOptionsResultText)(optionsFiltered, staticLabels);
|
|
451
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
|
|
452
|
+
children: [allowSearch && showResultText && /*#__PURE__*/(0, _jsxRuntime.jsx)(ResultStatus, {
|
|
453
|
+
text: finalResultText,
|
|
454
|
+
testId: testId
|
|
455
|
+
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(OptionsListbox, {
|
|
456
|
+
...listboxAttrs,
|
|
457
|
+
children: optionsFiltered.map((composeMenuOptions, index) =>
|
|
458
|
+
/*#__PURE__*/
|
|
459
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
460
|
+
(0, _jsxRuntime.jsx)("span", {
|
|
461
|
+
role: "presentation",
|
|
462
|
+
className: _MenuModule.default.menuDivider,
|
|
463
|
+
children: composeMenuOptions.map((option, idx) => /*#__PURE__*/(0, _jsxRuntime.jsx)(React.Fragment, {
|
|
464
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_MenuOptionButton.MenuOptionButton, {
|
|
465
|
+
option: option,
|
|
466
|
+
classNames: classNames,
|
|
467
|
+
...restProps,
|
|
468
|
+
testId: (0, _qa.generateTestId)({
|
|
469
|
+
base: testId,
|
|
470
|
+
slot: 'option',
|
|
471
|
+
index: `${index}-${idx}`
|
|
472
|
+
})
|
|
473
|
+
})
|
|
474
|
+
}, option.key))
|
|
475
|
+
}, index))
|
|
476
|
+
})]
|
|
178
477
|
});
|
|
179
478
|
}
|
|
180
479
|
if (groupTitleOptions && Array.isArray(groupTitleOptions) && groupTitleOptions.length) {
|
|
181
480
|
const optionsFiltered = !allowSearch ? groupTitleOptions : (0, _menu.getFilteredGroupTitleOptionsFromSearchText)(groupTitleOptions, searchText);
|
|
182
481
|
const finalResultText = !allowSearch ? '' : (0, _menu.getFilteredGroupTitleOptionsResultText)(optionsFiltered, staticLabels);
|
|
183
|
-
const resultText = allowSearch && showResultText && /*#__PURE__*/(0, _jsxRuntime.jsx)(_Text.FormLabelSmall, {
|
|
184
|
-
className: _MenuModule.default.filterOptionsResultText,
|
|
185
|
-
color: "tertiary",
|
|
186
|
-
testId: (0, _qa.generateTestId)({
|
|
187
|
-
base: testId,
|
|
188
|
-
slot: 'result-text'
|
|
189
|
-
}),
|
|
190
|
-
children: finalResultText
|
|
191
|
-
});
|
|
192
482
|
if (isVirtualizationEnabled) {
|
|
193
|
-
//
|
|
194
|
-
// flatten the groups into one row list and size each row
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
option,
|
|
209
|
-
groupIndex,
|
|
210
|
-
optionIndex,
|
|
211
|
-
flatOptionIndex
|
|
212
|
-
});
|
|
213
|
-
flatOptionIndex += 1;
|
|
214
|
-
});
|
|
215
|
-
});
|
|
483
|
+
// Group titles and options have different heights, which rules out
|
|
484
|
+
// FixedSizeList — flatten the groups into one row list and size each row
|
|
485
|
+
// individually.
|
|
486
|
+
//
|
|
487
|
+
// Flattening also costs the role="group" WRAPPERS the non-virtualized
|
|
488
|
+
// path renders: react-window positions every row absolutely, so a title
|
|
489
|
+
// cannot contain the options that follow it. Each title is instead its
|
|
490
|
+
// own `group` row naming itself (see GroupRow), so the listbox still owns
|
|
491
|
+
// only option/group children and the group name stays in the tree — but
|
|
492
|
+
// the options sit beside that group rather than inside it, so AT will not
|
|
493
|
+
// announce the group as context while arrowing through them. Every option
|
|
494
|
+
// carries aria-setsize / aria-posinset so its place in the whole list is
|
|
495
|
+
// still reported rather than its place in the mounted window.
|
|
496
|
+
const rows = buildGroupVirtualRows(optionsFiltered);
|
|
497
|
+
const optionCount = rows.filter(row => row.kind === 'option').length;
|
|
216
498
|
const rowHeight = idx => {
|
|
217
499
|
const row = rows[idx];
|
|
218
500
|
return row.kind === 'title' ? resolveGroupTitleHeight(row.node, row.groupIndex) : resolveItemHeight(row.option, row.flatOptionIndex);
|
|
@@ -221,78 +503,123 @@ const RenderOption = _ref2 => {
|
|
|
221
503
|
const row = rows[idx];
|
|
222
504
|
return row.kind === 'title' ? `group-title-${row.groupIndex}` : row.option.key;
|
|
223
505
|
};
|
|
506
|
+
const groupRowData = {
|
|
507
|
+
rows,
|
|
508
|
+
optionCount,
|
|
509
|
+
menuId,
|
|
510
|
+
classNames,
|
|
511
|
+
testId,
|
|
512
|
+
optionProps: restProps
|
|
513
|
+
};
|
|
224
514
|
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
|
|
225
|
-
children: [
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
className: (0, _classify.classify)(_MenuModule.default.groupTitleWrapper, classNames?.groupTitle),
|
|
241
|
-
"data-testid": (0, _qa.generateTestId)({
|
|
242
|
-
base: testId,
|
|
243
|
-
slot: 'group-title',
|
|
244
|
-
index: row.groupIndex.toString()
|
|
245
|
-
}),
|
|
246
|
-
children: row.node
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_MenuOptionButton.MenuOptionButton, {
|
|
250
|
-
option: row.option,
|
|
251
|
-
classNames: classNames,
|
|
252
|
-
style: style,
|
|
253
|
-
...restProps,
|
|
254
|
-
isLastItem: idx === rows.length - 1,
|
|
255
|
-
testId: (0, _qa.generateTestId)({
|
|
256
|
-
base: testId,
|
|
257
|
-
slot: 'option',
|
|
258
|
-
index: `${row.groupIndex}-${row.optionIndex}`
|
|
259
|
-
})
|
|
260
|
-
});
|
|
261
|
-
}
|
|
515
|
+
children: [allowSearch && showResultText && /*#__PURE__*/(0, _jsxRuntime.jsx)(ResultStatus, {
|
|
516
|
+
text: finalResultText,
|
|
517
|
+
testId: testId
|
|
518
|
+
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(ListboxAttrsContext.Provider, {
|
|
519
|
+
value: listboxAttrs,
|
|
520
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(MenuVariableSizeList, {
|
|
521
|
+
listRef: listRef,
|
|
522
|
+
heightSignature: rows.map((_row, idx) => `${rowKey(idx)}:${rowHeight(idx)}`).join('|'),
|
|
523
|
+
height: listHeight,
|
|
524
|
+
itemSize: rowHeight,
|
|
525
|
+
itemCount: rows.length,
|
|
526
|
+
itemKey: rowKey,
|
|
527
|
+
itemData: groupRowData,
|
|
528
|
+
children: GroupRow
|
|
529
|
+
})
|
|
262
530
|
})]
|
|
263
531
|
});
|
|
264
532
|
}
|
|
265
533
|
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
|
|
266
|
-
children: [
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
(0, _jsxRuntime.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
"
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
534
|
+
children: [allowSearch && showResultText && /*#__PURE__*/(0, _jsxRuntime.jsx)(ResultStatus, {
|
|
535
|
+
text: finalResultText,
|
|
536
|
+
testId: testId
|
|
537
|
+
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(OptionsListbox, {
|
|
538
|
+
...listboxAttrs,
|
|
539
|
+
children: optionsFiltered.map((optionsGroup, index) => {
|
|
540
|
+
// Link each group's options to its visible title via role="group"
|
|
541
|
+
// so the listbox owns only group/option children and AT announces
|
|
542
|
+
// group boundaries + names.
|
|
543
|
+
const groupTitleId = optionsGroup.groupTitle ? `${menuId}-group-${index}` : undefined;
|
|
544
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
|
|
545
|
+
role: "group",
|
|
546
|
+
"aria-labelledby": groupTitleId,
|
|
547
|
+
className: _MenuModule.default.optionGroup,
|
|
548
|
+
children: [!!optionsGroup.groupTitle && /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
549
|
+
id: groupTitleId,
|
|
550
|
+
className: (0, _classify.classify)(_MenuModule.default.groupTitleWrapper, classNames?.groupTitle),
|
|
551
|
+
"data-testid": (0, _qa.generateTestId)({
|
|
552
|
+
base: testId,
|
|
553
|
+
slot: 'group-title',
|
|
554
|
+
index: index.toString()
|
|
555
|
+
}),
|
|
556
|
+
children: optionsGroup.groupTitle
|
|
557
|
+
}), optionsGroup.options?.map((option, idx) => /*#__PURE__*/(0, _jsxRuntime.jsx)(React.Fragment, {
|
|
558
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_MenuOptionButton.MenuOptionButton, {
|
|
559
|
+
option: option,
|
|
560
|
+
classNames: classNames,
|
|
561
|
+
...restProps,
|
|
562
|
+
testId: (0, _qa.generateTestId)({
|
|
563
|
+
base: testId,
|
|
564
|
+
slot: 'option',
|
|
565
|
+
index: `${index}-${idx}`
|
|
566
|
+
})
|
|
567
|
+
})
|
|
568
|
+
}, option.key))]
|
|
569
|
+
}, index);
|
|
570
|
+
})
|
|
571
|
+
})]
|
|
292
572
|
});
|
|
293
573
|
}
|
|
294
|
-
|
|
574
|
+
// No option source yet (e.g. async options still loading). Render the listbox
|
|
575
|
+
// element anyway: the trigger advertises aria-expanded + aria-controls={menuId}
|
|
576
|
+
// for as long as the menu is mounted, and an IDREF that resolves to nothing is
|
|
577
|
+
// an invalid-value violation. An empty listbox is honest and valid.
|
|
578
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(OptionsListbox, {
|
|
579
|
+
menuId: menuId,
|
|
580
|
+
ariaLabelledBy: ariaLabelledBy,
|
|
581
|
+
ariaLabel: ariaLabel,
|
|
582
|
+
optionsVariant: optionsVariant
|
|
583
|
+
});
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Flatten the active option source (options | compose | groups) into one list.
|
|
588
|
+
* Exported because a combobox trigger drives the highlight from outside Menu
|
|
589
|
+
* and needs the same list Menu itself navigates — see `useMenuTrigger`.
|
|
590
|
+
*/
|
|
591
|
+
const getFlatOptions = props => {
|
|
592
|
+
const {
|
|
593
|
+
options,
|
|
594
|
+
composeOptions,
|
|
595
|
+
groupTitleOptions
|
|
596
|
+
} = props;
|
|
597
|
+
if (options && options.length) {
|
|
598
|
+
return options;
|
|
599
|
+
}
|
|
600
|
+
if (composeOptions && composeOptions.length) {
|
|
601
|
+
return composeOptions.flat();
|
|
602
|
+
}
|
|
603
|
+
if (groupTitleOptions && groupTitleOptions.length) {
|
|
604
|
+
return groupTitleOptions.flatMap(group => group.options ?? []);
|
|
605
|
+
}
|
|
606
|
+
return [];
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Key of the option that should own the roving tab stop when the menu opens:
|
|
611
|
+
* the selected option if present, otherwise the first enabled option.
|
|
612
|
+
*/
|
|
613
|
+
exports.getFlatOptions = getFlatOptions;
|
|
614
|
+
const getInitialActiveOptionKey = props => {
|
|
615
|
+
const enabled = getFlatOptions(props).filter(option => !option.disabled);
|
|
616
|
+
if (!enabled.length) {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
const selectedKey = enabled.find(option => props.selectedKeys?.includes(option.key) || option.key === props.selectedOption?.key)?.key;
|
|
620
|
+
return selectedKey ?? enabled[0].key;
|
|
295
621
|
};
|
|
622
|
+
exports.getInitialActiveOptionKey = getInitialActiveOptionKey;
|
|
296
623
|
const Menu = exports.Menu = /*#__PURE__*/React.forwardRef((props, ref) => {
|
|
297
624
|
const {
|
|
298
625
|
classNames,
|
|
@@ -308,15 +635,491 @@ const Menu = exports.Menu = /*#__PURE__*/React.forwardRef((props, ref) => {
|
|
|
308
635
|
header,
|
|
309
636
|
footer,
|
|
310
637
|
staticLabels,
|
|
638
|
+
menuId,
|
|
639
|
+
menuDisabled,
|
|
640
|
+
onSelect,
|
|
641
|
+
onTabOut,
|
|
642
|
+
onReturnFocusToInput,
|
|
643
|
+
initialFocusRef,
|
|
644
|
+
externalKeyboardNav = false,
|
|
645
|
+
onActiveOptionKeyChange,
|
|
646
|
+
activeOptionKey: controlledActiveOptionKey,
|
|
311
647
|
testId
|
|
312
648
|
} = props;
|
|
313
649
|
const [searchText, setSearchText] = React.useState('');
|
|
314
650
|
const {
|
|
315
651
|
menuHeight
|
|
316
652
|
} = virtualization;
|
|
653
|
+
|
|
654
|
+
// A virtualized list only mounts a window of rows, so the DOM cannot be the
|
|
655
|
+
// source of truth for keyboard navigation — Home/End and friends would stop
|
|
656
|
+
// at the edge of the window. Build the real option list and hand it to the
|
|
657
|
+
// hook. The plain `options` and the grouped `groupTitleOptions` branches
|
|
658
|
+
// both virtualize (see RenderOption); compose menus never do, so they keep
|
|
659
|
+
// the DOM-driven path.
|
|
660
|
+
const isVirtualized = Boolean(virtualization.enable && (props.options?.length || props.groupTitleOptions?.length));
|
|
661
|
+
const virtualListRef = React.useRef(null);
|
|
662
|
+
// A callback ref, because which of the two react-window list types is
|
|
663
|
+
// mounted depends on the virtualization mode.
|
|
664
|
+
const setVirtualList = React.useCallback(instance => {
|
|
665
|
+
virtualListRef.current = instance;
|
|
666
|
+
}, []);
|
|
667
|
+
// Last `key:rowIndex` the virtual list was scrolled to. Guards against
|
|
668
|
+
// re-scrolling on unrelated re-renders, and is pre-set by the pointer path so
|
|
669
|
+
// hovering a partially clipped row never scrolls the list under the cursor.
|
|
670
|
+
// The row index is part of the stamp because filtering can keep the active
|
|
671
|
+
// key while moving its row — a key-only guard would leave the highlight
|
|
672
|
+
// stranded off-screen with `aria-activedescendant` pointing at nothing.
|
|
673
|
+
const lastScrolledKeyRef = React.useRef(null);
|
|
674
|
+
// Which option the pointer last highlighted. State (not a ref) because the
|
|
675
|
+
// focus ring is rendered from it and has to repaint when it changes.
|
|
676
|
+
const [pointerActiveKey, setPointerActiveKey] = React.useState(null);
|
|
677
|
+
// Exactly the rows react-window renders, in its order. Disabled options ARE
|
|
678
|
+
// included, because they still occupy a row.
|
|
679
|
+
//
|
|
680
|
+
// `scrollToItem` counts ROWS, and a virtualized grouped menu interleaves
|
|
681
|
+
// title rows among the options, so an option's row index is not its index
|
|
682
|
+
// among the options. Keep both: `options` drives navigation, `rowIndexByKey`
|
|
683
|
+
// translates the active option into the index the list expects.
|
|
684
|
+
const virtualLayout = React.useMemo(() => {
|
|
685
|
+
if (!isVirtualized) {
|
|
686
|
+
return undefined;
|
|
687
|
+
}
|
|
688
|
+
const rowIndexByKey = new Map();
|
|
689
|
+
if (props.options?.length) {
|
|
690
|
+
const options = allowSearch ? (0, _menu.getFilteredOptionsFromSearchText)(props.options, searchText) : props.options;
|
|
691
|
+
options.forEach((option, index) => {
|
|
692
|
+
rowIndexByKey.set(option.key, index);
|
|
693
|
+
});
|
|
694
|
+
return {
|
|
695
|
+
options,
|
|
696
|
+
rowIndexByKey
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
if (props.groupTitleOptions?.length) {
|
|
700
|
+
const groups = allowSearch ? (0, _menu.getFilteredGroupTitleOptionsFromSearchText)(props.groupTitleOptions, searchText) : props.groupTitleOptions;
|
|
701
|
+
const options = [];
|
|
702
|
+
buildGroupVirtualRows(groups).forEach((row, index) => {
|
|
703
|
+
if (row.kind === 'option') {
|
|
704
|
+
rowIndexByKey.set(row.option.key, index);
|
|
705
|
+
options.push(row.option);
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
return {
|
|
709
|
+
options,
|
|
710
|
+
rowIndexByKey
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
return undefined;
|
|
714
|
+
}, [isVirtualized, props.options, props.groupTitleOptions, allowSearch, searchText]);
|
|
715
|
+
const virtualRows = virtualLayout?.options;
|
|
716
|
+
// The navigable subset (arrow keys skip disabled options). Kept separate from
|
|
717
|
+
// the row layout because mixing the two would make `scrollToItem` land on
|
|
718
|
+
// the wrong row whenever the list contains a disabled option or a title.
|
|
719
|
+
const navigationOptions = React.useMemo(() => virtualRows?.filter(option => !option.disabled && !menuDisabled).map(option => {
|
|
720
|
+
// Type-ahead must match what the user can read, so prefer the
|
|
721
|
+
// resolved label when the consumer supplies a string one.
|
|
722
|
+
const resolved = props.resolveLabel?.(option);
|
|
723
|
+
return {
|
|
724
|
+
key: option.key,
|
|
725
|
+
label: typeof resolved === 'string' ? resolved : option.label ?? ''
|
|
726
|
+
};
|
|
727
|
+
}), [virtualRows, menuDisabled, props.resolveLabel]);
|
|
728
|
+
const generatedMenuId = React.useId();
|
|
729
|
+
const resolvedMenuId = menuId ?? generatedMenuId;
|
|
730
|
+
const containerRef = React.useRef(null);
|
|
731
|
+
// Last pointer position seen by an option's mousemove — used to ignore
|
|
732
|
+
// scroll-under-stationary-cursor moves so hover doesn't fight the keyboard.
|
|
733
|
+
const lastPointerRef = React.useRef(null);
|
|
734
|
+
const [internalActiveKey, setInternalActiveKey] = React.useState(() => getInitialActiveOptionKey(props));
|
|
735
|
+
|
|
736
|
+
/*
|
|
737
|
+
* The menu opens with an option already active so the keyboard has somewhere
|
|
738
|
+
* to start, but painting that highlight after a MOUSE open makes the menu
|
|
739
|
+
* look like it has a cursor the user never asked for. Suppress it until the
|
|
740
|
+
* keyboard or the pointer actually picks an option.
|
|
741
|
+
*
|
|
742
|
+
* Read imperatively, not as reactive state: what matters is how the menu was
|
|
743
|
+
* OPENED. Re-rendering when the user later switches to the mouse would undo
|
|
744
|
+
* the highlight they just navigated to with the keyboard.
|
|
745
|
+
*/
|
|
746
|
+
const [suppressInitialHighlight, setSuppressInitialHighlight] = React.useState(() => (0, _hooks.getInteractionModality)() !== 'keyboard');
|
|
747
|
+
|
|
748
|
+
// When an external combobox input drives navigation the active option is a
|
|
749
|
+
// controlled prop; otherwise the Menu owns it (roving / internal search).
|
|
750
|
+
const activeOptionKey = externalKeyboardNav ? controlledActiveOptionKey ?? null : internalActiveKey;
|
|
751
|
+
const setActiveOptionKey = externalKeyboardNav ? onActiveOptionKeyChange ?? (() => undefined) : setInternalActiveKey;
|
|
752
|
+
|
|
753
|
+
// Once the user reaches for the keyboard the highlight is wanted, wherever
|
|
754
|
+
// they pressed. This is what covers the combobox consumers, whose key
|
|
755
|
+
// handling lives on the trigger OUTSIDE this component and so never reaches
|
|
756
|
+
// the keydown handler below.
|
|
757
|
+
//
|
|
758
|
+
// Deliberately keyed off real interaction rather than off `activeOptionKey`
|
|
759
|
+
// changing: async options arriving after a mouse open also change that key,
|
|
760
|
+
// and treating it as intent would paint the very highlight this suppresses.
|
|
761
|
+
//
|
|
762
|
+
// Popups only. An inline Menu has no trigger outside itself, so the only
|
|
763
|
+
// keyboard that should reveal its highlight is the one pressed inside it
|
|
764
|
+
// (the card's own onKeyDown below) — not typing in some unrelated field on
|
|
765
|
+
// the same page, which also flips the session-wide modality.
|
|
766
|
+
const modality = (0, _hooks.useInteractionModality)();
|
|
767
|
+
const isPopup = Boolean(initialFocusRef || externalKeyboardNav);
|
|
768
|
+
React.useEffect(() => {
|
|
769
|
+
if (isPopup && modality === 'keyboard') {
|
|
770
|
+
setSuppressInitialHighlight(false);
|
|
771
|
+
}
|
|
772
|
+
}, [isPopup, modality]);
|
|
773
|
+
|
|
774
|
+
// Keep the roving tab stop valid as the rendered option set changes
|
|
775
|
+
// (search filtering, async options, etc.). Skipped under external nav —
|
|
776
|
+
// the parent owns the active option there.
|
|
777
|
+
React.useLayoutEffect(() => {
|
|
778
|
+
if (externalKeyboardNav) {
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const container = containerRef.current;
|
|
782
|
+
// Under virtualization the DOM holds only a window of rows, so validating
|
|
783
|
+
// against it would discard a perfectly good active key the moment it
|
|
784
|
+
// scrolled out and snap the highlight back to the top of the window.
|
|
785
|
+
const keys = navigationOptions ? navigationOptions.map(option => option.key) : container ? Array.from(container.querySelectorAll('[data-menu-option-key]')).filter(el => el.getAttribute('aria-disabled') !== 'true' && !el.disabled).map(el => el.getAttribute('data-menu-option-key')).filter(key => key !== null) : [];
|
|
786
|
+
setInternalActiveKey(prev => {
|
|
787
|
+
if (!keys.length) {
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
if (prev && keys.includes(prev)) {
|
|
791
|
+
return prev;
|
|
792
|
+
}
|
|
793
|
+
const selectedKey = keys.find(key => props.selectedKeys?.includes(key) || key === props.selectedOption?.key);
|
|
794
|
+
return selectedKey ?? keys[0];
|
|
795
|
+
});
|
|
796
|
+
}, [searchText, props.options, props.composeOptions, props.groupTitleOptions, props.selectedKeys, props.selectedOption, navigationOptions]);
|
|
797
|
+
|
|
798
|
+
// Keep the active option MOUNTED in a virtualized list: the row the keyboard
|
|
799
|
+
// just moved to may be outside the render window, which would leave roving
|
|
800
|
+
// focus with no target and aria-activedescendant pointing at nothing.
|
|
801
|
+
React.useLayoutEffect(() => {
|
|
802
|
+
if (!isVirtualized || !activeOptionKey || !virtualLayout) {
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
// Only act on an actual CHANGE of active option. The row layout is memoized
|
|
806
|
+
// on the consumer's own array, so a parent that rebuilds it every render (e.g.
|
|
807
|
+
// Pagination's inline `menu={{options: allPages.map(...)}}`) would
|
|
808
|
+
// otherwise re-run this on every render and yank the list back to the
|
|
809
|
+
// active row, throwing away wherever the user had scrolled to.
|
|
810
|
+
// Index within the RENDERED rows — scrollToItem counts disabled options
|
|
811
|
+
// and group titles too. A key that is not in the layout yet (async options
|
|
812
|
+
// still arriving) is left unstamped, so it is scrolled to once it exists.
|
|
813
|
+
const index = virtualLayout.rowIndexByKey.get(activeOptionKey);
|
|
814
|
+
if (index === undefined) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const stamp = `${activeOptionKey}:${index}`;
|
|
818
|
+
if (lastScrolledKeyRef.current === stamp) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
lastScrolledKeyRef.current = stamp;
|
|
822
|
+
// Whether the menu owns focus RIGHT NOW, captured before the scroll can
|
|
823
|
+
// unmount the row holding it. Focus is only ever reclaimed below when the
|
|
824
|
+
// answer is yes: on the very first run of this effect (the menu mounting
|
|
825
|
+
// with an option already seeded) focus is still wherever the user left it,
|
|
826
|
+
// and an inline Menu must not pull it out of the page.
|
|
827
|
+
const hadFocusInside = Boolean(typeof document !== 'undefined' && containerRef.current?.contains(document.activeElement));
|
|
828
|
+
virtualListRef.current?.scrollToItem(index, 'smart');
|
|
829
|
+
// react-window positions using the `height` it was handed, which under this
|
|
830
|
+
// flex layout can be taller than the scroll box actually ends up, leaving
|
|
831
|
+
// the last rows a few pixels clipped. Once the row has mounted, nudge it
|
|
832
|
+
// with the real box so the highlight is never cut off, and — for roving
|
|
833
|
+
// focus — put real focus on it, since the row the open-time effect wanted
|
|
834
|
+
// may not have existed until this scroll rendered it.
|
|
835
|
+
const frame = requestAnimationFrame(() => {
|
|
836
|
+
const row = containerRef.current?.querySelector(`[data-menu-option-key="${CSS.escape(activeOptionKey)}"]`);
|
|
837
|
+
if (!row) {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
row.scrollIntoView({
|
|
841
|
+
block: 'nearest'
|
|
842
|
+
});
|
|
843
|
+
// Reclaim focus only if the scroll actually cost us it (the previously
|
|
844
|
+
// focused row was unmounted, dropping focus to <body>). Never steal it
|
|
845
|
+
// from the search field, the chrome, a combobox trigger outside — or
|
|
846
|
+
// from whatever owned it before this menu existed.
|
|
847
|
+
if (hadFocusInside && !allowSearch && !externalKeyboardNav && typeof document !== 'undefined' && (document.activeElement === document.body || document.activeElement === null)) {
|
|
848
|
+
row.focus({
|
|
849
|
+
preventScroll: true
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
return () => cancelAnimationFrame(frame);
|
|
854
|
+
}, [activeOptionKey, isVirtualized, virtualLayout, allowSearch, externalKeyboardNav]);
|
|
855
|
+
|
|
856
|
+
// A virtualized row can be unmounted underneath the focus it holds: the user
|
|
857
|
+
// scrolls the options with the wheel, the scrollbar, or a key the browser
|
|
858
|
+
// scrolls with, and react-window drops the row they were standing on. Focus
|
|
859
|
+
// then falls to <body>, which takes the menu's key handling with it — and
|
|
860
|
+
// because the roving tab stop lives on the active row, that row is no longer
|
|
861
|
+
// rendered either, so there is nothing left to Tab back to. The options are
|
|
862
|
+
// unreachable by keyboard until the user clicks one.
|
|
863
|
+
//
|
|
864
|
+
// The removal cannot be caught on blur: React's onBlur needs the event to
|
|
865
|
+
// bubble up through this container, and the row is already detached by then.
|
|
866
|
+
// The scroll is the live signal. Park focus on the card — always mounted,
|
|
867
|
+
// and it carries the key handler — and the next arrow key puts real focus
|
|
868
|
+
// back on a row, resuming from `activeOptionKey`.
|
|
869
|
+
React.useLayoutEffect(() => {
|
|
870
|
+
const container = containerRef.current;
|
|
871
|
+
if (!isVirtualized || allowSearch || externalKeyboardNav || !container) {
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
let frame = 0;
|
|
875
|
+
// The row is not removed during the scroll event — the commit that drops it
|
|
876
|
+
// lands a frame or two later, and focus only falls to <body> then. So the
|
|
877
|
+
// check runs for a few frames after a scroll rather than once.
|
|
878
|
+
const CHECK_FRAMES = 4;
|
|
879
|
+
const check = remaining => {
|
|
880
|
+
frame = 0;
|
|
881
|
+
if (typeof document === 'undefined' || !container.isConnected) {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (document.activeElement === document.body || document.activeElement === null) {
|
|
885
|
+
container.focus({
|
|
886
|
+
preventScroll: true
|
|
887
|
+
});
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (remaining > 0) {
|
|
891
|
+
frame = requestAnimationFrame(() => check(remaining - 1));
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
const onScroll = () => {
|
|
895
|
+
// Sample ownership NOW, while the doomed row is still mounted: only a
|
|
896
|
+
// scroll that starts with focus inside the menu can be the one that
|
|
897
|
+
// unmounts the focused row. Someone who clicked the page away from the
|
|
898
|
+
// menu and then scrolled it with the wheel is not asking for focus back.
|
|
899
|
+
if (typeof document === 'undefined' || !container.contains(document.activeElement)) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (!frame) {
|
|
903
|
+
frame = requestAnimationFrame(() => check(CHECK_FRAMES));
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
// `scroll` does not bubble, so listen for it in the capture phase rather
|
|
907
|
+
// than reaching into react-window's own scroll container.
|
|
908
|
+
container.addEventListener('scroll', onScroll, true);
|
|
909
|
+
return () => {
|
|
910
|
+
container.removeEventListener('scroll', onScroll, true);
|
|
911
|
+
if (frame) {
|
|
912
|
+
cancelAnimationFrame(frame);
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
}, [isVirtualized, allowSearch, externalKeyboardNav]);
|
|
916
|
+
|
|
917
|
+
// Move focus into the menu when it opens — the search input (so typing
|
|
918
|
+
// works) or the active option (so arrow-key roving works) — never a
|
|
919
|
+
// focusable header that happens to come first in the DOM. This is what a
|
|
920
|
+
// trigger relies on instead of wrapping the menu in FloatingFocusManager.
|
|
921
|
+
// Gated on `initialFocusRef` so only popup consumers (the dropdown family)
|
|
922
|
+
// auto-focus on open; an inline Menu without that prop never steals focus.
|
|
923
|
+
React.useLayoutEffect(() => {
|
|
924
|
+
if (!initialFocusRef || externalKeyboardNav) {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const container = containerRef.current;
|
|
928
|
+
// A virtualized list mounts only a window of rows, so the active option may
|
|
929
|
+
// not exist yet on open. Focusing whatever IS mounted would be undone a
|
|
930
|
+
// moment later when the scroll effect renders the real row and unmounts the
|
|
931
|
+
// one we focused, dropping focus to <body> and leaving the popup with no
|
|
932
|
+
// keyboard owner. Scroll first and let the scroll effect place focus.
|
|
933
|
+
if (isVirtualized && !allowSearch && activeOptionKey && virtualLayout) {
|
|
934
|
+
const index = virtualLayout.rowIndexByKey.get(activeOptionKey);
|
|
935
|
+
if (index !== undefined) {
|
|
936
|
+
// Park focus on the card so it is inside the popup straight away (Tab
|
|
937
|
+
// and Escape behave, nothing falls to <body>), scroll the real row in,
|
|
938
|
+
// then hand focus over once it has mounted.
|
|
939
|
+
initialFocusRef.current = container;
|
|
940
|
+
container?.focus();
|
|
941
|
+
lastScrolledKeyRef.current = `${activeOptionKey}:${index}`;
|
|
942
|
+
virtualListRef.current?.scrollToItem(index, 'smart');
|
|
943
|
+
const openFrame = requestAnimationFrame(() => {
|
|
944
|
+
const row = containerRef.current?.querySelector(`[data-menu-option-key="${CSS.escape(activeOptionKey)}"]`);
|
|
945
|
+
if (!row) {
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
initialFocusRef.current = row;
|
|
949
|
+
row.focus({
|
|
950
|
+
preventScroll: true
|
|
951
|
+
});
|
|
952
|
+
row.scrollIntoView({
|
|
953
|
+
block: 'nearest'
|
|
954
|
+
});
|
|
955
|
+
});
|
|
956
|
+
return () => cancelAnimationFrame(openFrame);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const target = container ? allowSearch ? container.querySelector('input[data-menu-search]') :
|
|
960
|
+
// The roving tab stop, unless `menuDisabled` has rendered it disabled —
|
|
961
|
+
// focusing a disabled <button> is a no-op that would leave focus on
|
|
962
|
+
// the trigger with the popup open and keyboard-dead.
|
|
963
|
+
container.querySelector('[data-menu-option-key][tabindex="0"]:not(:disabled):not([aria-disabled="true"])') ??
|
|
964
|
+
// Fallback must skip disabled options — focusing a disabled <button>
|
|
965
|
+
// is a no-op that would drop focus to <body> (all-disabled menus).
|
|
966
|
+
container.querySelector('[data-menu-option-key]:not([aria-disabled="true"]):not(:disabled)') : null;
|
|
967
|
+
// When no focusable option exists (every option disabled / menuDisabled),
|
|
968
|
+
// focus the menu container itself (it has tabIndex={-1}) so focus still
|
|
969
|
+
// enters the popup instead of falling to <body>.
|
|
970
|
+
const focusTarget = target ?? container;
|
|
971
|
+
initialFocusRef.current = focusTarget;
|
|
972
|
+
focusTarget?.focus();
|
|
973
|
+
// Only needs to resolve once when the menu mounts (opens).
|
|
974
|
+
}, []);
|
|
975
|
+
const selectByKey = key => {
|
|
976
|
+
const option = getFlatOptions(props).find(opt => opt.key === key);
|
|
977
|
+
if (option) {
|
|
978
|
+
onSelect?.(option, null);
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
const {
|
|
982
|
+
onKeyDown
|
|
983
|
+
} = (0, _useMenuKeyboardNavigation.useMenuKeyboardNavigation)({
|
|
984
|
+
containerRef,
|
|
985
|
+
enabled: !menuDisabled && !externalKeyboardNav,
|
|
986
|
+
mode: allowSearch ? 'search' : 'roving',
|
|
987
|
+
activeOptionKey,
|
|
988
|
+
onSelectKey: selectByKey,
|
|
989
|
+
setActiveOptionKey,
|
|
990
|
+
onTabOut,
|
|
991
|
+
onReturnFocusToInput,
|
|
992
|
+
options: navigationOptions
|
|
993
|
+
});
|
|
994
|
+
const activeDescendantId = allowSearch && activeOptionKey ? (0, _menuOptionId.menuOptionId)(resolvedMenuId, activeOptionKey) : undefined;
|
|
995
|
+
|
|
996
|
+
// Hovering an option makes it the single active highlight, so pointer and
|
|
997
|
+
// keyboard never diverge. In roving mode focus follows the pointer too, so
|
|
998
|
+
// arrow-key navigation resumes from wherever the mouse last was and the
|
|
999
|
+
// previously focused option's highlight clears. Search / external-combobox
|
|
1000
|
+
// modes keep DOM focus on the input and only move the active descendant.
|
|
1001
|
+
const handleOptionPointerActivate = (key, el, clientX, clientY) => {
|
|
1002
|
+
// Ignore moves with no real pointer delta — e.g. the list scrolling under
|
|
1003
|
+
// a stationary cursor during keyboard nav fires mousemove with unchanged
|
|
1004
|
+
// viewport coordinates. Acting on those would steal focus from the
|
|
1005
|
+
// keyboard / fight the arrow keys.
|
|
1006
|
+
const last = lastPointerRef.current;
|
|
1007
|
+
if (last && last.x === clientX && last.y === clientY) {
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
lastPointerRef.current = {
|
|
1011
|
+
x: clientX,
|
|
1012
|
+
y: clientY
|
|
1013
|
+
};
|
|
1014
|
+
// The row is already under the cursor, so it must not be scrolled to.
|
|
1015
|
+
// Claiming it here makes the virtual-scroll effect treat it as handled —
|
|
1016
|
+
// otherwise hovering a partially clipped edge row would nudge the list
|
|
1017
|
+
// under a stationary mouse and shift every row out from under it.
|
|
1018
|
+
lastScrolledKeyRef.current = `${key}:${virtualLayout?.rowIndexByKey.get(key) ?? '?'}`;
|
|
1019
|
+
setPointerActiveKey(key);
|
|
1020
|
+
// Hovering an option IS the user picking it out, so show the highlight
|
|
1021
|
+
// even though the menu was opened by mouse.
|
|
1022
|
+
setSuppressInitialHighlight(false);
|
|
1023
|
+
setActiveOptionKey(key);
|
|
1024
|
+
// Roving mode only: move real focus so the keyboard cursor / tab stop
|
|
1025
|
+
// follows the pointer. Search & external-combobox keep focus on the input.
|
|
1026
|
+
// Gate on focus ALREADY being inside the menu so an inline menu (or a
|
|
1027
|
+
// consumer like TokenListInput, where the user types in an external input
|
|
1028
|
+
// and the menu is just a mouse target) is never robbed of its focus by a
|
|
1029
|
+
// stray hover.
|
|
1030
|
+
if (!allowSearch && !externalKeyboardNav) {
|
|
1031
|
+
const container = containerRef.current;
|
|
1032
|
+
if (container && typeof document !== 'undefined' && container.contains(document.activeElement)) {
|
|
1033
|
+
el.focus({
|
|
1034
|
+
preventScroll: true
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
// Close when focus leaves the menu entirely (e.g. Tab past the footer or
|
|
1041
|
+
// Shift+Tab before the header). Internal moves between the search input,
|
|
1042
|
+
// options, header and footer keep focus inside, so they don't close.
|
|
1043
|
+
const handleMenuBlur = event => {
|
|
1044
|
+
const next = event.relatedTarget;
|
|
1045
|
+
if (!next || event.currentTarget.contains(next)) {
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
// Focus moving into ANOTHER floating-ui portal is a nested popup opening
|
|
1049
|
+
// from this menu's own chrome (a Dropdown in the footer, say), not the user
|
|
1050
|
+
// leaving — ClickAway tolerates the click for the same reason, so the blur
|
|
1051
|
+
// has to as well or the parent closes under the child.
|
|
1052
|
+
const ownPortal = event.currentTarget.closest('[data-floating-ui-portal]');
|
|
1053
|
+
const nextPortal = next.closest?.('[data-floating-ui-portal]') ?? null;
|
|
1054
|
+
if (nextPortal && nextPortal !== ownPortal) {
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
// Focus landing on the trigger that controls this menu is never a tab-out.
|
|
1058
|
+
// For combobox menus it is a deliberate return to the input (Shift+Tab off
|
|
1059
|
+
// a footer control). For roving menus it is the user pressing the trigger
|
|
1060
|
+
// to dismiss — and mousedown moves focus BEFORE the click, so closing here
|
|
1061
|
+
// would let the trigger's own toggle re-open the menu a moment later.
|
|
1062
|
+
// Leaving the menu by keyboard is handled explicitly in handleTab, and
|
|
1063
|
+
// clicking elsewhere is handled by ClickAway, so neither needs this path.
|
|
1064
|
+
if (next.getAttribute?.('aria-controls') === resolvedMenuId) {
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
onTabOut?.();
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
// The card is tabIndex={-1} so it can take focus programmatically, but that
|
|
1071
|
+
// also makes it CLICK-focusable. Without this, clicking its padding, a group
|
|
1072
|
+
// title, the result-count row or its scrollbar pulls focus off the combobox
|
|
1073
|
+
// input (or the roving option) onto the card, leaving the menu open but
|
|
1074
|
+
// keyboard-dead: the trigger's key handler can't fire and Menu's own is off
|
|
1075
|
+
// under externalKeyboardNav. Suppressing the default only for non-control
|
|
1076
|
+
// targets keeps focus where it was; real controls still focus normally.
|
|
1077
|
+
const handleMenuMouseDown = event => {
|
|
1078
|
+
const target = event.target;
|
|
1079
|
+
// A press on a scrollbar reports its scroll container as the target but
|
|
1080
|
+
// lands OUTSIDE that element's client box; preventing the default there
|
|
1081
|
+
// would cancel the drag. Test the target itself, not just the card — under
|
|
1082
|
+
// virtualization the scroll container is react-window's own wrapper, so a
|
|
1083
|
+
// card-only check would leave that scrollbar unguarded.
|
|
1084
|
+
if (target) {
|
|
1085
|
+
const scrolls = target.scrollHeight > target.clientHeight || target.scrollWidth > target.clientWidth;
|
|
1086
|
+
const rect = target.getBoundingClientRect();
|
|
1087
|
+
if (scrolls && (event.clientX > rect.left + target.clientWidth || event.clientY > rect.top + target.clientHeight)) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
if (!target?.closest('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])')) {
|
|
1092
|
+
event.preventDefault();
|
|
1093
|
+
}
|
|
1094
|
+
};
|
|
317
1095
|
const hasHeader = header ? true : false;
|
|
318
1096
|
const hasFooter = footer ? true : false;
|
|
1097
|
+
// The card used to collapse via `:empty` when it rendered nothing at all.
|
|
1098
|
+
// It now always contains the (possibly empty) listbox element so the
|
|
1099
|
+
// trigger's aria-controls resolves, which means `:empty` can never match —
|
|
1100
|
+
// so decide emptiness here instead and keep the collapsed styling.
|
|
1101
|
+
// Mirror RenderOption's own branch conditions rather than using the FLATTENED
|
|
1102
|
+
// option count: `groupTitleOptions: [{groupTitle: 'Recent', options: []}]`
|
|
1103
|
+
// still renders a visible group title, so collapsing the card there would
|
|
1104
|
+
// strip its border and padding while leaving that title on screen.
|
|
1105
|
+
const isCardEmpty = !hasHeader && !hasFooter && !allowSearch && !props.options?.length && !props.composeOptions?.length && !props.groupTitleOptions?.length;
|
|
319
1106
|
return /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
|
|
1107
|
+
// -1 so it is never in the tab order but CAN receive programmatic focus
|
|
1108
|
+
// as the open-time fallback when no option is focusable (all disabled).
|
|
1109
|
+
tabIndex: -1,
|
|
1110
|
+
onKeyDown: event => {
|
|
1111
|
+
// Any key press means the keyboard is driving again, so a stale pointer
|
|
1112
|
+
// highlight must stop suppressing the focus ring — otherwise arrowing
|
|
1113
|
+
// back onto the row the mouse last touched would show no focus.
|
|
1114
|
+
setPointerActiveKey(null);
|
|
1115
|
+
// A key press means the keyboard is driving, so the opening highlight
|
|
1116
|
+
// is now wanted even if the key did not move the active option (Home
|
|
1117
|
+
// on the first option, say).
|
|
1118
|
+
setSuppressInitialHighlight(false);
|
|
1119
|
+
onKeyDown(event);
|
|
1120
|
+
},
|
|
1121
|
+
onMouseDown: handleMenuMouseDown,
|
|
1122
|
+
onBlur: handleMenuBlur,
|
|
320
1123
|
className: (0, _classify.classify)(_MenuModule.default.menuCard, {
|
|
321
1124
|
[_MenuModule.default.fluid]: isFluid,
|
|
322
1125
|
[_MenuModule.default.medium]: size === 'medium',
|
|
@@ -328,25 +1131,39 @@ const Menu = exports.Menu = /*#__PURE__*/React.forwardRef((props, ref) => {
|
|
|
328
1131
|
[_MenuModule.default.smallWithFooter]: size === 'small' && !hasHeader && hasFooter,
|
|
329
1132
|
[_MenuModule.default.smallWithHeaderAndFooter]: size === 'small' && hasFooter && hasHeader,
|
|
330
1133
|
[_MenuModule.default.menuCardTopPaddingZero]: header,
|
|
331
|
-
[_MenuModule.default.menuCardBottomPaddingZero]: footer
|
|
1134
|
+
[_MenuModule.default.menuCardBottomPaddingZero]: footer,
|
|
1135
|
+
// Whatever scrolls the options, the card must not scroll too.
|
|
1136
|
+
[_MenuModule.default.menuCardNoScroll]: !isVirtualized,
|
|
1137
|
+
[_MenuModule.default.menuCardEmpty]: isCardEmpty
|
|
332
1138
|
}, classNames?.wrapper),
|
|
333
1139
|
style: {
|
|
334
1140
|
width,
|
|
335
1141
|
maxHeight: menuHeight ? menuHeight + 'px' : ''
|
|
336
1142
|
},
|
|
337
|
-
ref: ref,
|
|
1143
|
+
ref: (0, _mergeRefs.mergeRefs)([ref, containerRef]),
|
|
338
1144
|
"data-testid": (0, _qa.generateTestId)({
|
|
339
1145
|
base: testId,
|
|
340
1146
|
slot: 'wrapper'
|
|
341
1147
|
}),
|
|
342
1148
|
children: [hasHeader && /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
343
1149
|
className: (0, _classify.classify)(_MenuModule.default.menuHeader, classNames?.header),
|
|
1150
|
+
"data-menu-region": "header",
|
|
344
1151
|
"data-testid": (0, _qa.generateTestId)({
|
|
345
1152
|
base: testId,
|
|
346
1153
|
slot: 'header'
|
|
347
1154
|
}),
|
|
348
1155
|
children: header
|
|
349
|
-
}), allowSearch && /*#__PURE__*/(0, _jsxRuntime.jsx)(_SearchInput.SearchInput
|
|
1156
|
+
}), allowSearch && /*#__PURE__*/(0, _jsxRuntime.jsx)(_SearchInput.SearchInput
|
|
1157
|
+
// A filter field, NOT a combobox: an always-expanded combobox is
|
|
1158
|
+
// meaningless, and a second combobox pointing aria-controls at the
|
|
1159
|
+
// same listbox as the trigger that opened the popup has no defined
|
|
1160
|
+
// mapping. `searchbox` describes what it actually is; the trigger
|
|
1161
|
+
// outside the menu remains the single combobox owning the listbox.
|
|
1162
|
+
, {
|
|
1163
|
+
role: "searchbox",
|
|
1164
|
+
"data-menu-search": "true",
|
|
1165
|
+
"aria-controls": resolvedMenuId,
|
|
1166
|
+
"aria-activedescendant": activeDescendantId,
|
|
350
1167
|
value: searchText,
|
|
351
1168
|
onChange: e => setSearchText(e.target.value),
|
|
352
1169
|
onClear: () => setSearchText(''),
|
|
@@ -356,12 +1173,38 @@ const Menu = exports.Menu = /*#__PURE__*/React.forwardRef((props, ref) => {
|
|
|
356
1173
|
base: testId,
|
|
357
1174
|
slot: 'search'
|
|
358
1175
|
})
|
|
359
|
-
}),
|
|
1176
|
+
}), (hasHeader || hasFooter) && isVirtualized ?
|
|
1177
|
+
/*#__PURE__*/
|
|
1178
|
+
// Only a virtualized list needs this wrapper: it is sized by
|
|
1179
|
+
// `menuHeight` rather than by the flex box, so it needs something to
|
|
1180
|
+
// sit between the pinned bars. A plain option list scrolls on the
|
|
1181
|
+
// listbox itself.
|
|
1182
|
+
(0, _jsxRuntime.jsx)("div", {
|
|
1183
|
+
className: _MenuModule.default.optionsScroll,
|
|
1184
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(RenderOption, {
|
|
1185
|
+
...props,
|
|
1186
|
+
searchText: searchText,
|
|
1187
|
+
testId: testId,
|
|
1188
|
+
menuId: resolvedMenuId,
|
|
1189
|
+
activeOptionKey: activeOptionKey,
|
|
1190
|
+
onPointerActivate: handleOptionPointerActivate,
|
|
1191
|
+
pointerActiveKey: pointerActiveKey,
|
|
1192
|
+
suppressInitialHighlight: suppressInitialHighlight,
|
|
1193
|
+
listRef: setVirtualList
|
|
1194
|
+
})
|
|
1195
|
+
}) : /*#__PURE__*/(0, _jsxRuntime.jsx)(RenderOption, {
|
|
360
1196
|
...props,
|
|
361
1197
|
searchText: searchText,
|
|
362
|
-
testId: testId
|
|
1198
|
+
testId: testId,
|
|
1199
|
+
menuId: resolvedMenuId,
|
|
1200
|
+
activeOptionKey: activeOptionKey,
|
|
1201
|
+
onPointerActivate: handleOptionPointerActivate,
|
|
1202
|
+
pointerActiveKey: pointerActiveKey,
|
|
1203
|
+
suppressInitialHighlight: suppressInitialHighlight,
|
|
1204
|
+
listRef: setVirtualList
|
|
363
1205
|
}), hasFooter && /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
364
1206
|
className: (0, _classify.classify)(_MenuModule.default.menuFooter, classNames?.footer),
|
|
1207
|
+
"data-menu-region": "footer",
|
|
365
1208
|
"data-testid": (0, _qa.generateTestId)({
|
|
366
1209
|
base: testId,
|
|
367
1210
|
slot: 'footer'
|