@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
|
@@ -3,319 +3,71 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", {
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
|
-
exports.
|
|
6
|
+
exports.Menu = void 0;
|
|
7
7
|
var React = _interopRequireWildcard(require("react"));
|
|
8
8
|
var _reactWindow = require("react-window");
|
|
9
|
-
var _hooks = require("../../hooks");
|
|
10
9
|
var _classify = require("../../utils/classify");
|
|
11
10
|
var _menu = require("../../utils/menu");
|
|
12
|
-
var _mergeRefs = require("../../utils/merge-refs");
|
|
13
11
|
var _qa = require("../../utils/qa");
|
|
14
12
|
var _MenuOptionButton = require("./MenuOptionButton");
|
|
15
|
-
var _menuOptionId = require("./menuOptionId");
|
|
16
|
-
var _useMenuKeyboardNavigation = require("./useMenuKeyboardNavigation");
|
|
17
13
|
var _SearchInput = require("../SearchInput");
|
|
18
14
|
var _Text = require("../Text");
|
|
19
15
|
var _MenuModule = _interopRequireDefault(require("./Menu.module.css"));
|
|
20
16
|
var _jsxRuntime = require("react/jsx-runtime");
|
|
21
17
|
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
22
18
|
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
|
-
|
|
28
19
|
// Render first available option set
|
|
29
20
|
|
|
30
21
|
const menuSizeMedium = 276,
|
|
31
22
|
menuSizeSmall = 228;
|
|
32
23
|
const buttonSizeMedium = 40,
|
|
33
24
|
buttonSizeSmall = 32;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
25
|
+
const searchInputSizeMedium = 42,
|
|
26
|
+
searchInputSizeSmall = 34;
|
|
27
|
+
const cardPadding = 8;
|
|
28
|
+
const groupTitleSize = 32;
|
|
29
|
+
const resultTextSize = 41;
|
|
30
|
+
const headerFooterSize = 58;
|
|
31
|
+
const getMenuChromeHeight = _ref => {
|
|
41
32
|
let {
|
|
42
|
-
|
|
43
|
-
|
|
33
|
+
size,
|
|
34
|
+
allowSearch,
|
|
35
|
+
showResultText,
|
|
36
|
+
hasHeader,
|
|
37
|
+
hasFooter
|
|
44
38
|
} = _ref;
|
|
45
|
-
return
|
|
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
|
-
});
|
|
39
|
+
return (hasHeader ? headerFooterSize : cardPadding) + (hasFooter ? headerFooterSize : cardPadding) + (allowSearch ? size === 'medium' ? searchInputSizeMedium : searchInputSizeSmall : 0) + (allowSearch && showResultText ? resultTextSize : 0);
|
|
101
40
|
};
|
|
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
|
-
|
|
159
|
-
// default group title row height when virtualizing groupTitleOptions
|
|
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
|
-
|
|
41
|
+
const makeItemHeightResolver = (itemHeight, size) => (option, index) => typeof itemHeight === 'function' ? itemHeight(option, index) : itemHeight || (size === 'medium' ? buttonSizeMedium : buttonSizeSmall);
|
|
42
|
+
const makeGroupTitleHeightResolver = groupTitleHeight => (groupTitle, groupIndex) => typeof groupTitleHeight === 'function' ? groupTitleHeight(groupTitle, groupIndex) : groupTitleHeight || groupTitleSize;
|
|
191
43
|
// VariableSizeList caches row offsets, so the cache must be flushed whenever
|
|
192
44
|
// the rows or their heights change. `heightSignature` encodes every row's
|
|
193
45
|
// key and height; when it changes, resetAfterIndex invalidates the cache in
|
|
194
46
|
// place without remounting the list (a remount would drop scroll position).
|
|
195
|
-
|
|
196
|
-
function MenuVariableSizeList(_ref3) {
|
|
47
|
+
const MenuVariableSizeList = _ref2 => {
|
|
197
48
|
let {
|
|
198
49
|
heightSignature,
|
|
199
50
|
height,
|
|
200
51
|
itemCount,
|
|
201
52
|
itemSize,
|
|
202
53
|
itemKey,
|
|
203
|
-
itemData,
|
|
204
|
-
listRef,
|
|
205
54
|
children
|
|
206
|
-
} =
|
|
207
|
-
const
|
|
55
|
+
} = _ref2;
|
|
56
|
+
const listRef = React.useRef(null);
|
|
208
57
|
React.useEffect(() => {
|
|
209
|
-
|
|
58
|
+
listRef.current?.resetAfterIndex(0, true);
|
|
210
59
|
}, [heightSignature]);
|
|
211
60
|
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactWindow.VariableSizeList, {
|
|
212
|
-
ref:
|
|
61
|
+
ref: listRef,
|
|
213
62
|
height: height,
|
|
214
63
|
itemSize: itemSize,
|
|
215
64
|
itemCount: itemCount,
|
|
216
65
|
itemKey: itemKey,
|
|
217
|
-
itemData: itemData,
|
|
218
66
|
width: "initial",
|
|
219
|
-
outerElementType: ListboxOuter,
|
|
220
|
-
innerElementType: PresentationDiv,
|
|
221
67
|
children: children
|
|
222
68
|
});
|
|
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
|
-
});
|
|
263
69
|
};
|
|
264
|
-
const
|
|
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 => {
|
|
70
|
+
const RenderOption = _ref3 => {
|
|
319
71
|
let {
|
|
320
72
|
options,
|
|
321
73
|
composeOptions,
|
|
@@ -324,24 +76,17 @@ const RenderOption = _ref6 => {
|
|
|
324
76
|
searchText = '',
|
|
325
77
|
showResultText = true,
|
|
326
78
|
testId,
|
|
327
|
-
listRef,
|
|
328
79
|
staticLabels = {
|
|
329
80
|
RESULT: 'result',
|
|
330
81
|
RESULTS: 'results',
|
|
331
82
|
SEARCH_PLACEHOLDER: 'Search...'
|
|
332
83
|
},
|
|
333
84
|
...restProps
|
|
334
|
-
} =
|
|
85
|
+
} = _ref3;
|
|
335
86
|
const {
|
|
336
87
|
allowSearch,
|
|
337
88
|
size,
|
|
338
|
-
|
|
339
|
-
ariaLabelledBy,
|
|
340
|
-
ariaLabel,
|
|
341
|
-
optionsVariant,
|
|
342
|
-
virtualization,
|
|
343
|
-
externalKeyboardNav,
|
|
344
|
-
activeOptionKey
|
|
89
|
+
virtualization
|
|
345
90
|
} = restProps;
|
|
346
91
|
const {
|
|
347
92
|
enable: isVirtualizationEnabled = false,
|
|
@@ -352,149 +97,137 @@ const RenderOption = _ref6 => {
|
|
|
352
97
|
enable: false
|
|
353
98
|
};
|
|
354
99
|
const listHeight = menuHeight || (size === 'medium' ? menuSizeMedium : menuSizeSmall);
|
|
355
|
-
const resolveItemHeight = (
|
|
356
|
-
const resolveGroupTitleHeight = (
|
|
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
|
-
};
|
|
100
|
+
const resolveItemHeight = makeItemHeightResolver(itemHeight, size);
|
|
101
|
+
const resolveGroupTitleHeight = makeGroupTitleHeightResolver(groupTitleHeight);
|
|
387
102
|
if (options && Array.isArray(options) && options.length) {
|
|
388
103
|
const optionsFiltered = !allowSearch ? options : (0, _menu.getFilteredOptionsFromSearchText)(options, searchText);
|
|
389
104
|
const finalResultText = !allowSearch ? '' : (0, _menu.getFilteredOptionsResultText)(optionsFiltered, staticLabels);
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
105
|
+
const renderFlatRow = _ref4 => {
|
|
106
|
+
let {
|
|
107
|
+
index: idx,
|
|
108
|
+
style
|
|
109
|
+
} = _ref4;
|
|
110
|
+
const buttonOption = optionsFiltered[idx];
|
|
111
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_MenuOptionButton.MenuOptionButton, {
|
|
112
|
+
option: buttonOption,
|
|
113
|
+
classNames: classNames,
|
|
114
|
+
style: style,
|
|
115
|
+
...restProps,
|
|
116
|
+
isLastItem: idx === optionsFiltered.length - 1,
|
|
117
|
+
testId: (0, _qa.generateTestId)({
|
|
118
|
+
base: testId,
|
|
119
|
+
slot: 'option',
|
|
120
|
+
index: idx.toString()
|
|
121
|
+
})
|
|
122
|
+
});
|
|
395
123
|
};
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
124
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
|
|
125
|
+
children: [allowSearch && showResultText && /*#__PURE__*/(0, _jsxRuntime.jsx)(_Text.FormLabelSmall, {
|
|
126
|
+
className: _MenuModule.default.filterOptionsResultText,
|
|
127
|
+
color: "tertiary",
|
|
128
|
+
testId: (0, _qa.generateTestId)({
|
|
129
|
+
base: testId,
|
|
130
|
+
slot: 'result-text'
|
|
131
|
+
}),
|
|
132
|
+
children: finalResultText
|
|
133
|
+
}), virtualization && isVirtualizationEnabled ? typeof itemHeight === 'function' ? /*#__PURE__*/(0, _jsxRuntime.jsx)(MenuVariableSizeList, {
|
|
134
|
+
heightSignature: optionsFiltered.map((option, idx) => `${option.key}:${resolveItemHeight(option, idx)}`).join('|'),
|
|
135
|
+
height: listHeight,
|
|
136
|
+
itemSize: idx => resolveItemHeight(optionsFiltered[idx], idx),
|
|
137
|
+
itemCount: optionsFiltered.length,
|
|
138
|
+
itemKey: idx => optionsFiltered[idx].key,
|
|
139
|
+
children: renderFlatRow
|
|
140
|
+
}) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactWindow.FixedSizeList, {
|
|
141
|
+
height: listHeight,
|
|
142
|
+
itemSize: itemHeight || (size === 'medium' ? buttonSizeMedium : buttonSizeSmall),
|
|
143
|
+
itemCount: optionsFiltered.length,
|
|
144
|
+
itemKey: idx => optionsFiltered[idx].key,
|
|
145
|
+
width: "initial",
|
|
146
|
+
children: renderFlatRow
|
|
147
|
+
}) : optionsFiltered.map((option, idx) => /*#__PURE__*/(0, _jsxRuntime.jsx)(React.Fragment, {
|
|
148
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_MenuOptionButton.MenuOptionButton, {
|
|
149
|
+
option: option,
|
|
150
|
+
classNames: classNames,
|
|
151
|
+
...restProps,
|
|
152
|
+
isLastItem: idx === optionsFiltered.length - 1,
|
|
153
|
+
testId: (0, _qa.generateTestId)({
|
|
154
|
+
base: testId,
|
|
155
|
+
slot: 'option',
|
|
156
|
+
index: idx.toString()
|
|
423
157
|
})
|
|
424
|
-
})
|
|
425
|
-
})
|
|
426
|
-
}
|
|
158
|
+
})
|
|
159
|
+
}, option.key))]
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (composeOptions && Array.isArray(composeOptions) && composeOptions.length) {
|
|
163
|
+
const optionsFiltered = !allowSearch ? composeOptions : (0, _menu.getFilteredComposeOptionsFromSearchText)(composeOptions, searchText);
|
|
164
|
+
const finalResultText = !allowSearch ? '' : (0, _menu.getFilteredComposeOptionsResultText)(optionsFiltered, staticLabels);
|
|
427
165
|
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
|
|
428
|
-
children: [allowSearch && showResultText && /*#__PURE__*/(0, _jsxRuntime.jsx)(
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
166
|
+
children: [allowSearch && showResultText && /*#__PURE__*/(0, _jsxRuntime.jsx)(_Text.FormLabelSmall, {
|
|
167
|
+
className: _MenuModule.default.filterOptionsResultText,
|
|
168
|
+
color: "tertiary",
|
|
169
|
+
testId: (0, _qa.generateTestId)({
|
|
170
|
+
base: testId,
|
|
171
|
+
slot: 'result-text'
|
|
172
|
+
}),
|
|
173
|
+
children: finalResultText
|
|
174
|
+
}), optionsFiltered.map((composeMenuOptions, index) =>
|
|
175
|
+
/*#__PURE__*/
|
|
176
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
177
|
+
(0, _jsxRuntime.jsx)("span", {
|
|
178
|
+
className: _MenuModule.default.menuDivider,
|
|
179
|
+
children: composeMenuOptions.map((option, idx) => /*#__PURE__*/(0, _jsxRuntime.jsx)(React.Fragment, {
|
|
434
180
|
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_MenuOptionButton.MenuOptionButton, {
|
|
435
181
|
option: option,
|
|
436
182
|
classNames: classNames,
|
|
437
183
|
...restProps,
|
|
184
|
+
isLastItem: index === optionsFiltered.length - 1 && idx === composeMenuOptions.length - 1,
|
|
438
185
|
testId: (0, _qa.generateTestId)({
|
|
439
186
|
base: testId,
|
|
440
187
|
slot: 'option',
|
|
441
|
-
index: idx
|
|
188
|
+
index: `${index}-${idx}`
|
|
442
189
|
})
|
|
443
190
|
})
|
|
444
191
|
}, option.key))
|
|
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
|
-
})]
|
|
192
|
+
}, index))]
|
|
477
193
|
});
|
|
478
194
|
}
|
|
479
195
|
if (groupTitleOptions && Array.isArray(groupTitleOptions) && groupTitleOptions.length) {
|
|
480
196
|
const optionsFiltered = !allowSearch ? groupTitleOptions : (0, _menu.getFilteredGroupTitleOptionsFromSearchText)(groupTitleOptions, searchText);
|
|
481
197
|
const finalResultText = !allowSearch ? '' : (0, _menu.getFilteredGroupTitleOptionsResultText)(optionsFiltered, staticLabels);
|
|
198
|
+
const resultText = allowSearch && showResultText && /*#__PURE__*/(0, _jsxRuntime.jsx)(_Text.FormLabelSmall, {
|
|
199
|
+
className: _MenuModule.default.filterOptionsResultText,
|
|
200
|
+
color: "tertiary",
|
|
201
|
+
testId: (0, _qa.generateTestId)({
|
|
202
|
+
base: testId,
|
|
203
|
+
slot: 'result-text'
|
|
204
|
+
}),
|
|
205
|
+
children: finalResultText
|
|
206
|
+
});
|
|
482
207
|
if (isVirtualizationEnabled) {
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
208
|
+
// Mixed row heights (group titles vs options) rule out FixedSizeList —
|
|
209
|
+
// flatten the groups into one row list and size each row individually.
|
|
210
|
+
const rows = [];
|
|
211
|
+
let flatOptionIndex = 0;
|
|
212
|
+
optionsFiltered.forEach((optionsGroup, groupIndex) => {
|
|
213
|
+
if (optionsGroup.groupTitle) {
|
|
214
|
+
rows.push({
|
|
215
|
+
kind: 'title',
|
|
216
|
+
node: optionsGroup.groupTitle,
|
|
217
|
+
groupIndex
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
optionsGroup.options?.forEach((option, optionIndex) => {
|
|
221
|
+
rows.push({
|
|
222
|
+
kind: 'option',
|
|
223
|
+
option,
|
|
224
|
+
groupIndex,
|
|
225
|
+
optionIndex,
|
|
226
|
+
flatOptionIndex
|
|
227
|
+
});
|
|
228
|
+
flatOptionIndex += 1;
|
|
229
|
+
});
|
|
230
|
+
});
|
|
498
231
|
const rowHeight = idx => {
|
|
499
232
|
const row = rows[idx];
|
|
500
233
|
return row.kind === 'title' ? resolveGroupTitleHeight(row.node, row.groupIndex) : resolveItemHeight(row.option, row.flatOptionIndex);
|
|
@@ -503,123 +236,78 @@ const RenderOption = _ref6 => {
|
|
|
503
236
|
const row = rows[idx];
|
|
504
237
|
return row.kind === 'title' ? `group-title-${row.groupIndex}` : row.option.key;
|
|
505
238
|
};
|
|
506
|
-
const groupRowData = {
|
|
507
|
-
rows,
|
|
508
|
-
optionCount,
|
|
509
|
-
menuId,
|
|
510
|
-
classNames,
|
|
511
|
-
testId,
|
|
512
|
-
optionProps: restProps
|
|
513
|
-
};
|
|
514
239
|
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
|
|
515
|
-
children: [
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
240
|
+
children: [resultText, /*#__PURE__*/(0, _jsxRuntime.jsx)(MenuVariableSizeList, {
|
|
241
|
+
heightSignature: rows.map((_row, idx) => `${rowKey(idx)}:${rowHeight(idx)}`).join('|'),
|
|
242
|
+
height: listHeight,
|
|
243
|
+
itemSize: rowHeight,
|
|
244
|
+
itemCount: rows.length,
|
|
245
|
+
itemKey: rowKey,
|
|
246
|
+
children: _ref5 => {
|
|
247
|
+
let {
|
|
248
|
+
index: idx,
|
|
249
|
+
style
|
|
250
|
+
} = _ref5;
|
|
251
|
+
const row = rows[idx];
|
|
252
|
+
if (row.kind === 'title') {
|
|
253
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
254
|
+
style: style,
|
|
255
|
+
className: (0, _classify.classify)(_MenuModule.default.groupTitleWrapper, classNames?.groupTitle),
|
|
256
|
+
"data-testid": (0, _qa.generateTestId)({
|
|
257
|
+
base: testId,
|
|
258
|
+
slot: 'group-title',
|
|
259
|
+
index: row.groupIndex.toString()
|
|
260
|
+
}),
|
|
261
|
+
children: row.node
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_MenuOptionButton.MenuOptionButton, {
|
|
265
|
+
option: row.option,
|
|
266
|
+
classNames: classNames,
|
|
267
|
+
style: style,
|
|
268
|
+
...restProps,
|
|
269
|
+
isLastItem: idx === rows.length - 1,
|
|
270
|
+
testId: (0, _qa.generateTestId)({
|
|
271
|
+
base: testId,
|
|
272
|
+
slot: 'option',
|
|
273
|
+
index: `${row.groupIndex}-${row.optionIndex}`
|
|
274
|
+
})
|
|
275
|
+
});
|
|
276
|
+
}
|
|
530
277
|
})]
|
|
531
278
|
});
|
|
532
279
|
}
|
|
533
280
|
return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
|
|
534
|
-
children: [
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
})]
|
|
281
|
+
children: [resultText, optionsFiltered.map((optionsGroup, index) =>
|
|
282
|
+
/*#__PURE__*/
|
|
283
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
284
|
+
(0, _jsxRuntime.jsxs)(React.Fragment, {
|
|
285
|
+
children: [!!optionsGroup.groupTitle && /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
286
|
+
className: (0, _classify.classify)(_MenuModule.default.groupTitleWrapper, classNames?.groupTitle),
|
|
287
|
+
"data-testid": (0, _qa.generateTestId)({
|
|
288
|
+
base: testId,
|
|
289
|
+
slot: 'group-title',
|
|
290
|
+
index: index.toString()
|
|
291
|
+
}),
|
|
292
|
+
children: optionsGroup.groupTitle
|
|
293
|
+
}), optionsGroup.options?.map((option, idx) => /*#__PURE__*/(0, _jsxRuntime.jsx)(React.Fragment, {
|
|
294
|
+
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_MenuOptionButton.MenuOptionButton, {
|
|
295
|
+
option: option,
|
|
296
|
+
classNames: classNames,
|
|
297
|
+
...restProps,
|
|
298
|
+
isLastItem: index === optionsFiltered.length - 1 && idx === (optionsGroup.options && optionsGroup.options.length - 1),
|
|
299
|
+
testId: (0, _qa.generateTestId)({
|
|
300
|
+
base: testId,
|
|
301
|
+
slot: 'option',
|
|
302
|
+
index: `${index}-${idx}`
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
}, option.key))]
|
|
306
|
+
}, index))]
|
|
572
307
|
});
|
|
573
308
|
}
|
|
574
|
-
|
|
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;
|
|
309
|
+
return /*#__PURE__*/(0, _jsxRuntime.jsx)(_jsxRuntime.Fragment, {});
|
|
621
310
|
};
|
|
622
|
-
exports.getInitialActiveOptionKey = getInitialActiveOptionKey;
|
|
623
311
|
const Menu = exports.Menu = /*#__PURE__*/React.forwardRef((props, ref) => {
|
|
624
312
|
const {
|
|
625
313
|
classNames,
|
|
@@ -627,499 +315,37 @@ const Menu = exports.Menu = /*#__PURE__*/React.forwardRef((props, ref) => {
|
|
|
627
315
|
width,
|
|
628
316
|
isFluid = true,
|
|
629
317
|
allowSearch,
|
|
318
|
+
showResultText = true,
|
|
319
|
+
options,
|
|
320
|
+
composeOptions,
|
|
321
|
+
groupTitleOptions,
|
|
630
322
|
virtualization = {
|
|
631
|
-
enable: false
|
|
632
|
-
menuHeight: null,
|
|
633
|
-
itemHeight: null
|
|
323
|
+
enable: false
|
|
634
324
|
},
|
|
635
325
|
header,
|
|
636
326
|
footer,
|
|
637
327
|
staticLabels,
|
|
638
|
-
menuId,
|
|
639
|
-
menuDisabled,
|
|
640
|
-
onSelect,
|
|
641
|
-
onTabOut,
|
|
642
|
-
onReturnFocusToInput,
|
|
643
|
-
initialFocusRef,
|
|
644
|
-
externalKeyboardNav = false,
|
|
645
|
-
onActiveOptionKeyChange,
|
|
646
|
-
activeOptionKey: controlledActiveOptionKey,
|
|
647
328
|
testId
|
|
648
329
|
} = props;
|
|
649
330
|
const [searchText, setSearchText] = React.useState('');
|
|
650
331
|
const {
|
|
651
|
-
menuHeight
|
|
332
|
+
menuHeight,
|
|
333
|
+
itemHeight,
|
|
334
|
+
groupTitleHeight
|
|
652
335
|
} = 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
|
-
};
|
|
1095
336
|
const hasHeader = header ? true : false;
|
|
1096
337
|
const hasFooter = footer ? true : false;
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
338
|
+
const firstOption = options?.[0] ?? composeOptions?.[0]?.[0] ?? groupTitleOptions?.[0]?.options?.[0];
|
|
339
|
+
const firstRowHeight = (groupTitleOptions?.length ? makeGroupTitleHeightResolver(groupTitleHeight)(groupTitleOptions[0].groupTitle, 0) : 0) + (firstOption ? makeItemHeightResolver(itemHeight, size)(firstOption, 0) : 0);
|
|
340
|
+
const minCardHeight = getMenuChromeHeight({
|
|
341
|
+
size,
|
|
342
|
+
allowSearch: Boolean(allowSearch),
|
|
343
|
+
showResultText,
|
|
344
|
+
hasHeader,
|
|
345
|
+
hasFooter
|
|
346
|
+
}) + firstRowHeight;
|
|
347
|
+
const cardMaxHeight = menuHeight ? Math.max(menuHeight, minCardHeight) : null;
|
|
1106
348
|
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,
|
|
1123
349
|
className: (0, _classify.classify)(_MenuModule.default.menuCard, {
|
|
1124
350
|
[_MenuModule.default.fluid]: isFluid,
|
|
1125
351
|
[_MenuModule.default.medium]: size === 'medium',
|
|
@@ -1131,39 +357,25 @@ const Menu = exports.Menu = /*#__PURE__*/React.forwardRef((props, ref) => {
|
|
|
1131
357
|
[_MenuModule.default.smallWithFooter]: size === 'small' && !hasHeader && hasFooter,
|
|
1132
358
|
[_MenuModule.default.smallWithHeaderAndFooter]: size === 'small' && hasFooter && hasHeader,
|
|
1133
359
|
[_MenuModule.default.menuCardTopPaddingZero]: header,
|
|
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
|
|
360
|
+
[_MenuModule.default.menuCardBottomPaddingZero]: footer
|
|
1138
361
|
}, classNames?.wrapper),
|
|
1139
362
|
style: {
|
|
1140
363
|
width,
|
|
1141
|
-
maxHeight:
|
|
364
|
+
maxHeight: cardMaxHeight ? cardMaxHeight + 'px' : ''
|
|
1142
365
|
},
|
|
1143
|
-
ref:
|
|
366
|
+
ref: ref,
|
|
1144
367
|
"data-testid": (0, _qa.generateTestId)({
|
|
1145
368
|
base: testId,
|
|
1146
369
|
slot: 'wrapper'
|
|
1147
370
|
}),
|
|
1148
371
|
children: [hasHeader && /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
1149
372
|
className: (0, _classify.classify)(_MenuModule.default.menuHeader, classNames?.header),
|
|
1150
|
-
"data-menu-region": "header",
|
|
1151
373
|
"data-testid": (0, _qa.generateTestId)({
|
|
1152
374
|
base: testId,
|
|
1153
375
|
slot: 'header'
|
|
1154
376
|
}),
|
|
1155
377
|
children: header
|
|
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,
|
|
378
|
+
}), allowSearch && /*#__PURE__*/(0, _jsxRuntime.jsx)(_SearchInput.SearchInput, {
|
|
1167
379
|
value: searchText,
|
|
1168
380
|
onChange: e => setSearchText(e.target.value),
|
|
1169
381
|
onClear: () => setSearchText(''),
|
|
@@ -1173,38 +385,12 @@ const Menu = exports.Menu = /*#__PURE__*/React.forwardRef((props, ref) => {
|
|
|
1173
385
|
base: testId,
|
|
1174
386
|
slot: 'search'
|
|
1175
387
|
})
|
|
1176
|
-
}), (
|
|
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, {
|
|
388
|
+
}), /*#__PURE__*/(0, _jsxRuntime.jsx)(RenderOption, {
|
|
1196
389
|
...props,
|
|
1197
390
|
searchText: searchText,
|
|
1198
|
-
testId: testId
|
|
1199
|
-
menuId: resolvedMenuId,
|
|
1200
|
-
activeOptionKey: activeOptionKey,
|
|
1201
|
-
onPointerActivate: handleOptionPointerActivate,
|
|
1202
|
-
pointerActiveKey: pointerActiveKey,
|
|
1203
|
-
suppressInitialHighlight: suppressInitialHighlight,
|
|
1204
|
-
listRef: setVirtualList
|
|
391
|
+
testId: testId
|
|
1205
392
|
}), hasFooter && /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
|
|
1206
393
|
className: (0, _classify.classify)(_MenuModule.default.menuFooter, classNames?.footer),
|
|
1207
|
-
"data-menu-region": "footer",
|
|
1208
394
|
"data-testid": (0, _qa.generateTestId)({
|
|
1209
395
|
base: testId,
|
|
1210
396
|
slot: 'footer'
|