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

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