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