@astryxdesign/core 0.7.0-canary.fe57f54 → 0.7.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 (125) hide show
  1. package/CHANGELOG.md +186 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +5 -4
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheetPanel.d.ts +1 -1
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetPanel.js +1 -1
  7. package/dist/Calendar/styles.d.ts +6 -6
  8. package/dist/Chat/ChatComposerInput.d.ts +0 -11
  9. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  10. package/dist/Chat/ChatComposerInput.js +0 -11
  11. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  12. package/dist/Chat/useTriggerMenu.js +1 -1
  13. package/dist/CommandPalette/CommandPaletteList.d.ts +3 -2
  14. package/dist/CommandPalette/CommandPaletteList.d.ts.map +1 -1
  15. package/dist/CommandPalette/CommandPaletteList.js +3 -2
  16. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  17. package/dist/DateInput/TouchDateField.js +4 -1
  18. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  19. package/dist/DateTimeInput/TouchDateTimeField.js +29 -13
  20. package/dist/DropdownMenu/DropdownMenu.d.ts +11 -0
  21. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  22. package/dist/DropdownMenu/DropdownMenu.js +173 -11
  23. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  24. package/dist/DropdownMenu/DropdownMenuSubMenu.js +13 -2
  25. package/dist/DropdownMenu/menuRegionsContext.d.ts +2 -0
  26. package/dist/DropdownMenu/menuRegionsContext.d.ts.map +1 -0
  27. package/dist/DropdownMenu/menuRegionsContext.js +17 -0
  28. package/dist/Field/inputStyles.stylex.d.ts +3 -3
  29. package/dist/Layout/container.stylex.d.ts +18 -20
  30. package/dist/Layout/container.stylex.d.ts.map +1 -1
  31. package/dist/Layout/container.stylex.js +15 -25
  32. package/dist/Layout/padding.stylex.d.ts +231 -231
  33. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  34. package/dist/Lightbox/Lightbox.js +15 -6
  35. package/dist/Markdown/plugins/protocol.d.ts.map +1 -1
  36. package/dist/Markdown/plugins/protocol.js +33 -8
  37. package/dist/NavItem/navItemStyles.stylex.d.ts +5 -5
  38. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  39. package/dist/PowerSearch/PowerSearch.js +21 -1
  40. package/dist/PowerSearch/PowerSearchEditPopover.d.ts.map +1 -1
  41. package/dist/PowerSearch/PowerSearchEditPopover.js +30 -15
  42. package/dist/Stack/stack.stylex.d.ts +22 -22
  43. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  44. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +89 -14
  45. package/dist/Table/table.stylex.d.ts +6 -6
  46. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  47. package/dist/TimeInput/TouchTimeField.js +4 -1
  48. package/dist/Tokenizer/Tokenizer.d.ts +1 -12
  49. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  50. package/dist/Tokenizer/Tokenizer.js +1 -13
  51. package/dist/Typeahead/BaseTypeahead.d.ts +0 -11
  52. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  53. package/dist/Typeahead/BaseTypeahead.js +1 -9
  54. package/dist/Typeahead/Typeahead.d.ts +1 -12
  55. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  56. package/dist/Typeahead/Typeahead.js +1 -13
  57. package/dist/astryx.css +17 -12
  58. package/dist/hooks/useFocusTrap.d.ts +8 -0
  59. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  60. package/dist/hooks/useFocusTrap.js +5 -1
  61. package/dist/hooks/useRenamedProp.d.ts +6 -6
  62. package/dist/hooks/useRenamedProp.js +6 -6
  63. package/dist/reset.a11y.chromium.spec.d.ts +2 -0
  64. package/dist/reset.a11y.chromium.spec.d.ts.map +1 -0
  65. package/dist/reset.a11y.chromium.spec.js +176 -0
  66. package/dist/theme/tokens.stylex.d.ts +30 -30
  67. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  68. package/dist/theme/tokens.stylex.js +18 -15
  69. package/dist/theme/useTheme.d.ts +1 -1
  70. package/package.json +4 -4
  71. package/src/BottomSheet/BottomSheet.doc.mjs +4 -4
  72. package/src/BottomSheet/BottomSheet.tsx +5 -4
  73. package/src/BottomSheet/BottomSheetPanel.test.tsx +6 -9
  74. package/src/BottomSheet/BottomSheetPanel.tsx +2 -3
  75. package/src/Chat/ChatComposerInput.doc.mjs +3 -3
  76. package/src/Chat/ChatComposerInput.test.tsx +7 -55
  77. package/src/Chat/ChatComposerInput.tsx +0 -36
  78. package/src/Chat/useTriggerMenu.tsx +1 -4
  79. package/src/CommandPalette/CommandPalette.spec.md +2 -0
  80. package/src/CommandPalette/CommandPaletteList.spec.md +189 -0
  81. package/src/CommandPalette/CommandPaletteList.test.tsx +47 -0
  82. package/src/CommandPalette/CommandPaletteList.tsx +3 -2
  83. package/src/DateInput/TouchDateField.tsx +3 -1
  84. package/src/DateTimeInput/DateTimeInputTouch.test.tsx +60 -1
  85. package/src/DateTimeInput/TouchDateTimeField.tsx +31 -14
  86. package/src/DropdownMenu/DropdownMenu.doc.mjs +12 -0
  87. package/src/DropdownMenu/DropdownMenu.spec.md +65 -40
  88. package/src/DropdownMenu/DropdownMenu.test.tsx +457 -0
  89. package/src/DropdownMenu/DropdownMenu.tsx +278 -49
  90. package/src/DropdownMenu/DropdownMenuCheckboxItem.doc.mjs +6 -3
  91. package/src/DropdownMenu/DropdownMenuRadioGroup.doc.mjs +2 -1
  92. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +11 -1
  93. package/src/DropdownMenu/__tests__/MenuHeaderFooter.a11y.chromium.spec.ts +195 -0
  94. package/src/DropdownMenu/menuRegionsContext.ts +17 -0
  95. package/src/Layout/container.stylex.ts +11 -21
  96. package/src/Lightbox/Lightbox.test.tsx +58 -0
  97. package/src/Lightbox/Lightbox.tsx +12 -6
  98. package/src/Markdown/plugins/protocol.ts +45 -12
  99. package/src/Markdown/plugins/sourceLines.test.tsx +121 -0
  100. package/src/PowerSearch/PowerSearch.tsx +28 -1
  101. package/src/PowerSearch/PowerSearchEditPopover.test.tsx +284 -0
  102. package/src/PowerSearch/PowerSearchEditPopover.tsx +41 -13
  103. package/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +33 -9
  104. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +81 -18
  105. package/src/TimeInput/TouchTimeField.tsx +3 -1
  106. package/src/Tokenizer/Tokenizer.doc.mjs +0 -16
  107. package/src/Tokenizer/Tokenizer.test.tsx +12 -63
  108. package/src/Tokenizer/Tokenizer.tsx +1 -25
  109. package/src/Toolbar/Toolbar.test.tsx +29 -0
  110. package/src/Typeahead/BaseTypeahead.doc.mjs +0 -16
  111. package/src/Typeahead/BaseTypeahead.test.tsx +1 -1
  112. package/src/Typeahead/BaseTypeahead.tsx +1 -21
  113. package/src/Typeahead/Typeahead.doc.mjs +0 -7
  114. package/src/Typeahead/Typeahead.test.tsx +24 -42
  115. package/src/Typeahead/Typeahead.tsx +1 -25
  116. package/src/hooks/useFocusTrap.ts +5 -1
  117. package/src/hooks/useRenamedProp.ts +6 -6
  118. package/src/reset.a11y.chromium.spec.ts +178 -0
  119. package/src/reset.css +8 -8
  120. package/src/reset.test.ts +112 -0
  121. package/src/theme/__tests__/SpacingRootFontSize.a11y.chromium.spec.ts +198 -0
  122. package/src/theme/tokens.stylex.ts +18 -15
  123. package/src/theme/tokens.test.ts +1 -1
  124. package/src/theme/useTheme.test.tsx +4 -4
  125. package/src/theme/useTheme.ts +1 -1
@@ -69,6 +69,8 @@ import type {MenuPressPointerType} from '../hooks/menuPressGesture';
69
69
  import {useTypeahead} from '../hooks/useTypeahead';
70
70
  import {useFocusReturnVisibility} from '../hooks/useFocusReturnVisibility';
71
71
  import {useMenuOverflow} from './useMenuOverflow';
72
+ import {MenuRegionsContext} from './menuRegionsContext';
73
+ import {getFocusableElements} from '../hooks/useFocusTrap';
72
74
  import {useDevWarning} from '../hooks/useDevWarning';
73
75
  import {useMenuDrillIn} from './useMenuDrillIn';
74
76
  import {resolveMenuWidth} from './menuWidth';
@@ -88,10 +90,13 @@ import {
88
90
  durationVars,
89
91
  easeVars,
90
92
  } from '../theme/tokens.stylex';
91
- import {mergeProps, rtlStyles} from '../utils';
93
+ import {isRenderable, mergeProps, rtlStyles} from '../utils';
92
94
  import type {BaseProps} from '../BaseProps';
93
95
  import {themeProps} from '../utils/themeProps';
94
- import {useInteractionModalityTracking} from '../utils/interactionModality';
96
+ import {
97
+ getInteractionModality,
98
+ useInteractionModalityTracking,
99
+ } from '../utils/interactionModality';
95
100
  import {useTranslator} from '../i18n';
96
101
  import {focusOutlineStyles} from '../utils/focusOutline.stylex';
97
102
 
@@ -102,6 +107,54 @@ const MENU_MAX_BLOCK_SIZE = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBloc
102
107
  const MENU_MAX_BLOCK_SIZE_FALLBACK = `min(${MENU_BLOCK_CAP}, ${layerViewportInset.maxBlockSizeFallback})`;
103
108
  const MENU_TRIGGER_OPEN_BACKGROUND = `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`;
104
109
 
110
+ /**
111
+ * The elements Tab reaches inside a header or footer region, in order. Hidden
112
+ * controls (`hidden`, `display: none`, `visibility: hidden`, inert or
113
+ * aria-hidden subtrees) are not reachable, judged by the popover focus trap's
114
+ * own test so the two never disagree about where Tab can go.
115
+ */
116
+ function tabbablesIn(region: HTMLElement | null): HTMLElement[] {
117
+ if (region == null) {
118
+ return [];
119
+ }
120
+ return getFocusableElements(region).filter(
121
+ element => element.tabIndex >= 0 && !isInsideHiddenWrapper(element, region),
122
+ );
123
+ }
124
+
125
+ /**
126
+ * Whether a `display: none` ancestor between the element and its region hides
127
+ * it — a control in a wrapper hidden at some widths, say. The trap's test reads
128
+ * only the element's own display.
129
+ */
130
+ function isInsideHiddenWrapper(
131
+ element: HTMLElement,
132
+ region: HTMLElement,
133
+ ): boolean {
134
+ if (typeof window === 'undefined' || !window.getComputedStyle) {
135
+ return false;
136
+ }
137
+ for (
138
+ let node = element.parentElement;
139
+ node != null && node !== region.parentElement;
140
+ node = node.parentElement
141
+ ) {
142
+ if (window.getComputedStyle(node).display === 'none') {
143
+ return true;
144
+ }
145
+ }
146
+ return false;
147
+ }
148
+
149
+ /**
150
+ * Move focus to a region control. Reports whether focus actually arrived, so a
151
+ * caller can close the menu instead of stranding focus on the page.
152
+ */
153
+ function focusRegionControl(element: HTMLElement): boolean {
154
+ element.focus();
155
+ return element.ownerDocument.activeElement === element;
156
+ }
157
+
105
158
  const styles = stylex.create({
106
159
  triggerOpen: {
107
160
  backgroundImage: MENU_TRIGGER_OPEN_BACKGROUND,
@@ -148,6 +201,27 @@ const styles = stylex.create({
148
201
  overflowX: 'hidden',
149
202
  overscrollBehavior: 'contain',
150
203
  },
204
+ // With a header or footer, the regions and the menu share one column under
205
+ // the menu's block cap: the regions keep their height and the menu shrinks
206
+ // and scrolls between them.
207
+ regionsFrame: {
208
+ boxSizing: 'border-box',
209
+ display: 'flex',
210
+ flexDirection: 'column',
211
+ maxHeight: stylex.firstThatWorks(
212
+ MENU_MAX_BLOCK_SIZE,
213
+ MENU_MAX_BLOCK_SIZE_FALLBACK,
214
+ ),
215
+ },
216
+ menuInFrame: {
217
+ flexGrow: 1,
218
+ flexShrink: 1,
219
+ minBlockSize: 0,
220
+ },
221
+ region: {
222
+ flexShrink: 0,
223
+ padding: spacingVars['--spacing-1'],
224
+ },
151
225
  // Scroll ownership by the browser's own signal. A menu whose rows fit keeps
152
226
  // every one-finger move: a slide over its rows stays a slide. One that
153
227
  // scrolls lets the browser pan it vertically and cancel the press when it
@@ -216,6 +290,12 @@ const bottomSheetStyles = stylex.create({
216
290
  viewHeading: {
217
291
  outline: 'none',
218
292
  },
293
+ // Caller content above or below the action list, inset to the list's
294
+ // content edge.
295
+ region: {
296
+ paddingInline: spacingVars['--spacing-3'],
297
+ paddingBlock: spacingVars['--spacing-2'],
298
+ },
219
299
  });
220
300
 
221
301
  // =============================================================================
@@ -371,6 +451,17 @@ interface DropdownMenuBaseProps extends BaseProps {
371
451
  * @default 'start'
372
452
  */
373
453
  alignment?: LayerAlignment;
454
+ /**
455
+ * Content above the menu's rows and outside the menu itself — a filter
456
+ * field, a toggle. The menu holds only its rows, so a control that is not
457
+ * a row goes here or in `footer`, never among the rows.
458
+ */
459
+ header?: ReactNode;
460
+ /**
461
+ * Content below the menu's rows and outside the menu itself — a "Create"
462
+ * button. See `header`.
463
+ */
464
+ footer?: ReactNode;
374
465
 
375
466
  'data-testid'?: string;
376
467
  }
@@ -508,6 +599,8 @@ function DropdownMenuBottomSheet({
508
599
  menuMaxHeight: _menuMaxHeight,
509
600
  placement: _placement,
510
601
  alignment: _alignment,
602
+ header,
603
+ footer,
511
604
  className,
512
605
  style,
513
606
  xstyle,
@@ -718,11 +811,17 @@ function DropdownMenuBottomSheet({
718
811
  {currentTitle}
719
812
  </Heading>
720
813
  </div>
814
+ {isRenderable(header) && (
815
+ <div {...stylex.props(bottomSheetStyles.region)}>{header}</div>
816
+ )}
721
817
  <MenuBottomSheetActionList
722
818
  items={currentItems}
723
819
  onSelect={handleSelect}
724
820
  onOpenSubmenu={item => setSubmenuPath(path => [...path, item])}
725
821
  />
822
+ {isRenderable(footer) && (
823
+ <div {...stylex.props(bottomSheetStyles.region)}>{footer}</div>
824
+ )}
726
825
  </div>
727
826
  </MenuBottomSheet>
728
827
  </>
@@ -741,6 +840,8 @@ function DropdownMenuPopover({
741
840
  placement = 'below',
742
841
  alignment = 'start',
743
842
  presentation: _presentation,
843
+ header,
844
+ footer,
744
845
  className,
745
846
  style,
746
847
  xstyle,
@@ -786,6 +887,16 @@ function DropdownMenuPopover({
786
887
  resetFocusReturn,
787
888
  } = useFocusReturnVisibility();
788
889
  const contentRef = useRef<HTMLDivElement | null>(null);
890
+ const headerRef = useRef<HTMLDivElement | null>(null);
891
+ const footerRef = useRef<HTMLDivElement | null>(null);
892
+ const hasRegions = useMemo(
893
+ () => isRenderable(header) || isRenderable(footer),
894
+ [header, footer],
895
+ );
896
+ // Whether the menu element itself took focus from the keyboard. A pointer
897
+ // open focuses it programmatically, and pointer history must not paint the
898
+ // keyboard ring (architecture:interaction-modality INV1).
899
+ const [isMenuKeyboardFocused, setIsMenuKeyboardFocused] = useState(false);
789
900
  // The press model is mounted below (it needs the popover); the hide path
790
901
  // above it ends the gesture in flight through this ref.
791
902
  const cancelMenuPressRef = useRef<() => void>(() => {});
@@ -966,6 +1077,23 @@ function DropdownMenuPopover({
966
1077
  // A submenu flyout renders inline inside this menu; its key events bubble
967
1078
  // up here. Let that level own them — only handle events from this level.
968
1079
  if (!ownsEvent(e)) {
1080
+ // Except Tab with a header or footer: each open flyout has closed
1081
+ // itself as the key bubbled up, and the root moves focus on as Tab
1082
+ // from one of its own rows would.
1083
+ if (e.key === 'Tab' && hasRegions) {
1084
+ const region = e.shiftKey ? headerRef.current : footerRef.current;
1085
+ const tabbables = tabbablesIn(region);
1086
+ e.stopPropagation();
1087
+ const target = e.shiftKey
1088
+ ? tabbables[tabbables.length - 1]
1089
+ : tabbables[0];
1090
+ if (target != null) {
1091
+ e.preventDefault();
1092
+ }
1093
+ if (target == null || !focusRegionControl(target)) {
1094
+ closeMenu();
1095
+ }
1096
+ }
969
1097
  return;
970
1098
  }
971
1099
  if (e.key === 'Enter' || e.key === ' ') {
@@ -991,7 +1119,27 @@ function DropdownMenuPopover({
991
1119
  // otherwise leak into the page while the menu stayed open (menus-5).
992
1120
  // Do NOT preventDefault — closing restores focus to the trigger, and the
993
1121
  // browser's default Tab then continues from there to the next element.
1122
+ // A header or footer with something Tab can reach in that direction
1123
+ // takes the Tab instead, and the menu stays open.
994
1124
  if (e.key === 'Tab') {
1125
+ const region = e.shiftKey ? headerRef.current : footerRef.current;
1126
+ const tabbables = tabbablesIn(region);
1127
+ if (tabbables.length > 0) {
1128
+ // From a row, the browser's Shift+Tab would stop on the menu element
1129
+ // itself — the row's ancestor and a Tab stop — before reaching the
1130
+ // header. The menu is one stop, so go straight to the header.
1131
+ if (e.shiftKey && e.target !== listRef.current) {
1132
+ e.preventDefault();
1133
+ // The popover's focus trap listens on the document and would read
1134
+ // this same Shift+Tab against the field just focused — the trap's
1135
+ // first element — and wrap to the footer. This key is handled.
1136
+ e.stopPropagation();
1137
+ if (!focusRegionControl(tabbables[tabbables.length - 1])) {
1138
+ closeMenu();
1139
+ }
1140
+ }
1141
+ return;
1142
+ }
995
1143
  closeMenu();
996
1144
  return;
997
1145
  }
@@ -1002,7 +1150,33 @@ function DropdownMenuPopover({
1002
1150
  }
1003
1151
  listNavKeyDown(e);
1004
1152
  },
1005
- [listNavKeyDown, closeMenu, typeahead, ownsEvent],
1153
+ [listNavKeyDown, closeMenu, typeahead, ownsEvent, listRef, hasRegions],
1154
+ );
1155
+
1156
+ // Tab past the last control of the footer, or Shift+Tab before the first
1157
+ // control of the header, leaves the menu as Tab from its rows does: the
1158
+ // menu closes, focus returns to the trigger, and the browser's own Tab
1159
+ // continues from there. Between the regions, Tab moves through the menu
1160
+ // as one stop.
1161
+ const handleRegionKeyDown = useCallback(
1162
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
1163
+ if (e.key !== 'Tab') {
1164
+ return;
1165
+ }
1166
+ const tabbables = tabbablesIn(e.currentTarget);
1167
+ const isHeader = e.currentTarget === headerRef.current;
1168
+ const exit = isHeader
1169
+ ? e.shiftKey
1170
+ ? tabbables[0]
1171
+ : undefined
1172
+ : e.shiftKey
1173
+ ? undefined
1174
+ : tabbables[tabbables.length - 1];
1175
+ if (exit != null && exit === document.activeElement) {
1176
+ closeMenu();
1177
+ }
1178
+ },
1179
+ [closeMenu],
1006
1180
  );
1007
1181
 
1008
1182
  const openAndFocus = useCallback(
@@ -1215,6 +1389,38 @@ function DropdownMenuPopover({
1215
1389
  );
1216
1390
  const hasOverflow = useMenuOverflow(listRef, menuContent, popover.isOpen);
1217
1391
 
1392
+ // The header and footer sit beside the menu inside the popover, never inside
1393
+ // the role="menu" element: a menu holds only its rows.
1394
+ const renderWithRegions = (menu: ReactNode): ReactNode =>
1395
+ hasRegions ? (
1396
+ <div
1397
+ {...stylex.props(
1398
+ styles.regionsFrame,
1399
+ resolvedMaxHeight != null &&
1400
+ dynamicStyles.menuMaxHeight(resolvedMaxHeight),
1401
+ )}>
1402
+ {isRenderable(header) && (
1403
+ <div
1404
+ ref={headerRef}
1405
+ onKeyDown={handleRegionKeyDown}
1406
+ {...stylex.props(styles.region)}>
1407
+ {header}
1408
+ </div>
1409
+ )}
1410
+ {menu}
1411
+ {isRenderable(footer) && (
1412
+ <div
1413
+ ref={footerRef}
1414
+ onKeyDown={handleRegionKeyDown}
1415
+ {...stylex.props(styles.region)}>
1416
+ {footer}
1417
+ </div>
1418
+ )}
1419
+ </div>
1420
+ ) : (
1421
+ menu
1422
+ );
1423
+
1218
1424
  const triggerProps: DropdownMenuTriggerProps = {
1219
1425
  ref: el => {
1220
1426
  buttonRef.current = el;
@@ -1286,52 +1492,75 @@ function DropdownMenuPopover({
1286
1492
  />
1287
1493
  )}
1288
1494
  {popover.render(
1289
- <div
1290
- {...rest}
1291
- ref={el => {
1292
- listRef.current = el;
1293
- contentRef.current =
1294
- el?.closest<HTMLDivElement>('[popover]') ?? null;
1295
- }}
1296
- id={menuId}
1297
- role="menu"
1298
- // Pointer opens focus the container so arrows, typeahead, Escape and
1299
- // Tab remain owned by the menu without pre-highlighting an item.
1300
- // An overflowing menu joins the Tab order so its scrollable region is
1301
- // keyboard-accessible; Tab still dismisses through listKeyDown.
1302
- tabIndex={hasOverflow ? 0 : -1}
1303
- // Give the menu an accessible name from its trigger's label, so
1304
- // screen readers announce e.g. "Actions menu" rather than an unnamed
1305
- // menu (menus-13). A custom trigger names it by reference.
1306
- aria-label={
1307
- renderTrigger != null
1308
- ? (rest['aria-label'] as string | undefined)
1309
- : button.label
1310
- }
1311
- aria-labelledby={
1312
- renderTrigger != null && rest['aria-label'] == null
1313
- ? triggerId
1314
- : (rest['aria-labelledby'] as string | undefined)
1315
- }
1316
- onKeyDown={listKeyDown}
1317
- {...menuPress.menuProps}
1318
- {...mergeProps(
1319
- themeProps('dropdown-menu'),
1320
- stylex.props(
1321
- styles.dropdown,
1322
- resolvedMaxHeight != null &&
1323
- dynamicStyles.menuMaxHeight(resolvedMaxHeight),
1324
- hasOverflow ? styles.touchPanYPinchZoom : styles.touchPinchZoom,
1325
- hasOverflow && styles.scrollable,
1326
- xstyle,
1327
- ),
1328
- className,
1329
- style,
1330
- )}>
1331
- <DropdownMenuContext value={contextValue}>
1332
- {menuContent}
1333
- </DropdownMenuContext>
1334
- </div>,
1495
+ renderWithRegions(
1496
+ <div
1497
+ {...rest}
1498
+ ref={el => {
1499
+ listRef.current = el;
1500
+ contentRef.current =
1501
+ el?.closest<HTMLDivElement>('[popover]') ?? null;
1502
+ }}
1503
+ id={menuId}
1504
+ role="menu"
1505
+ // Pointer opens focus the container so arrows, typeahead, Escape and
1506
+ // Tab remain owned by the menu without pre-highlighting an item.
1507
+ // An overflowing menu joins the Tab order so its scrollable region is
1508
+ // keyboard-accessible; Tab still dismisses through listKeyDown. With
1509
+ // a header or footer the menu is one Tab stop between them.
1510
+ tabIndex={hasOverflow || hasRegions ? 0 : -1}
1511
+ // Give the menu an accessible name from its trigger's label, so
1512
+ // screen readers announce e.g. "Actions menu" rather than an unnamed
1513
+ // menu (menus-13). A custom trigger names it by reference.
1514
+ aria-label={
1515
+ renderTrigger != null
1516
+ ? (rest['aria-label'] as string | undefined)
1517
+ : button.label
1518
+ }
1519
+ aria-labelledby={
1520
+ renderTrigger != null && rest['aria-label'] == null
1521
+ ? triggerId
1522
+ : (rest['aria-labelledby'] as string | undefined)
1523
+ }
1524
+ onKeyDown={listKeyDown}
1525
+ onFocus={event => {
1526
+ (
1527
+ rest.onFocus as
1528
+ React.FocusEventHandler<HTMLDivElement> | undefined
1529
+ )?.(event);
1530
+ if (event.target === event.currentTarget) {
1531
+ setIsMenuKeyboardFocused(
1532
+ getInteractionModality() === 'keyboard',
1533
+ );
1534
+ }
1535
+ }}
1536
+ {...menuPress.menuProps}
1537
+ {...mergeProps(
1538
+ themeProps('dropdown-menu'),
1539
+ stylex.props(
1540
+ styles.dropdown,
1541
+ resolvedMaxHeight != null &&
1542
+ dynamicStyles.menuMaxHeight(resolvedMaxHeight),
1543
+ hasOverflow ? styles.touchPanYPinchZoom : styles.touchPinchZoom,
1544
+ hasOverflow && styles.scrollable,
1545
+ hasRegions && styles.menuInFrame,
1546
+ // Between a header and a footer the menu is a Tab stop, so
1547
+ // keyboard focus on it must be visible (the base style removes
1548
+ // the ring for the pointer-open focus target).
1549
+ hasRegions &&
1550
+ isMenuKeyboardFocused &&
1551
+ focusOutlineStyles.focusVisible,
1552
+ xstyle,
1553
+ ),
1554
+ className,
1555
+ style,
1556
+ )}>
1557
+ <MenuRegionsContext value={hasRegions}>
1558
+ <DropdownMenuContext value={contextValue}>
1559
+ {menuContent}
1560
+ </DropdownMenuContext>
1561
+ </MenuRegionsContext>
1562
+ </div>,
1563
+ ),
1335
1564
  {
1336
1565
  placement,
1337
1566
  alignment,
@@ -29,12 +29,14 @@ export const docs = {
29
29
  {
30
30
  name: 'value',
31
31
  type: 'boolean',
32
- description: 'Whether the item is checked. Controlled; pair with onChange.',
32
+ description:
33
+ 'Whether the item is checked. Controlled; pair with onChange.',
33
34
  },
34
35
  {
35
36
  name: 'onChange',
36
37
  type: '(checked: boolean) => void',
37
- description: 'Callback fired with the next checked state when the item is toggled.',
38
+ description:
39
+ 'Callback fired with the next checked state when the item is toggled.',
38
40
  },
39
41
  {
40
42
  name: 'isDisabled',
@@ -88,7 +90,8 @@ export const docsDense = {
88
90
  name: 'DropdownMenuCheckboxItem',
89
91
  isHiddenFromOverview: true,
90
92
  displayName: 'Dropdown Menu Checkbox Item',
91
- description: 'checkable menu item (menuitemcheckbox); independent boolean toggle',
93
+ description:
94
+ 'checkable menu item (menuitemcheckbox); independent boolean toggle',
92
95
  propDescriptions: {
93
96
  label: 'primary label text',
94
97
  description: 'secondary text below label',
@@ -78,7 +78,8 @@ export const docsZh = {
78
78
  propDescriptions: {
79
79
  value: '组中当前选中的值。尚无选中项时传 undefined。',
80
80
  onChange: '所选值变化时触发的回调。',
81
- label: '组的无障碍名称,作为 aria-label 应用,以便屏幕阅读器将单选项作为命名集合朗读,例如“Sort by”。必需。当名称已作为可见元素存在时,可改用 aria-labelledby(通过基础属性)。',
81
+ label:
82
+ '组的无障碍名称,作为 aria-label 应用,以便屏幕阅读器将单选项作为命名集合朗读,例如“Sort by”。必需。当名称已作为可见元素存在时,可改用 aria-labelledby(通过基础属性)。',
82
83
  hasCloseOnSelect: '选择某值是否关闭菜单。默认关闭。',
83
84
  children: '组成该组的 DropdownMenuRadioItem。',
84
85
  },
@@ -50,6 +50,7 @@
50
50
  */
51
51
 
52
52
  import React, {
53
+ use,
53
54
  useCallback,
54
55
  useEffect,
55
56
  useId,
@@ -63,6 +64,7 @@ import {createPortal} from 'react-dom';
63
64
  import * as stylex from '@stylexjs/stylex';
64
65
  import {Icon, renderIconSlot, type IconType} from '../Icon';
65
66
  import {Spinner} from '../Spinner';
67
+ import {MenuRegionsContext} from './menuRegionsContext';
66
68
  import {Item} from '../Item';
67
69
  import {useLayer} from '../Layer/useLayer';
68
70
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
@@ -356,6 +358,7 @@ export function DropdownMenuSubMenu(
356
358
 
357
359
  const t = useTranslator();
358
360
  const menuCtx = useDropdownMenuContext();
361
+ const rootHasRegions = use(MenuRegionsContext);
359
362
  const menuSize = menuCtx?.menuSize ?? 'md';
360
363
  const canOpen = !isDisabled;
361
364
 
@@ -655,6 +658,13 @@ export function DropdownMenuSubMenu(
655
658
  // stopPropagation needed — each level self-filters.
656
659
  const handleContentKeyDown = useCallback(
657
660
  (e: React.KeyboardEvent) => {
661
+ // In a menu with a header or footer, Tab leaves the whole flyout stack:
662
+ // every open level closes as the key bubbles past it — this one's own
663
+ // rows or a deeper level's — and the root menu moves focus to a region.
664
+ if (e.key === 'Tab' && rootHasRegions) {
665
+ close({focusTrigger: false});
666
+ return;
667
+ }
658
668
  if (!ownsEvent(e)) {
659
669
  return;
660
670
  }
@@ -702,7 +712,7 @@ export function DropdownMenuSubMenu(
702
712
  }
703
713
  listNavKeyDown(e);
704
714
  },
705
- [ownsEvent, close, listNavKeyDown, typeahead, menuRef],
715
+ [ownsEvent, close, listNavKeyDown, typeahead, menuRef, rootHasRegions],
706
716
  );
707
717
 
708
718
  // Re-provide the menu context so nested items behave exactly like top-level