@astryxdesign/core 0.5.2-canary.c9c8564 → 0.5.2-canary.e4f8e4e

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 (146) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +3 -1
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +4 -3
  4. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts +2 -2
  5. package/dist/BottomSheet/BottomSheetEdgeTint.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetEdgeTint.js +7 -10
  7. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  8. package/dist/BottomSheet/BottomSheetSwitcher.js +1 -1
  9. package/dist/Button/Button.d.ts +2 -1
  10. package/dist/Button/Button.d.ts.map +1 -1
  11. package/dist/Button/Button.js +11 -4
  12. package/dist/Carousel/Carousel.d.ts.map +1 -1
  13. package/dist/Carousel/Carousel.js +2 -2
  14. package/dist/Chat/ChatToolCalls.d.ts.map +1 -1
  15. package/dist/Chat/ChatToolCalls.js +34 -17
  16. package/dist/ContextMenu/ContextMenu.d.ts +14 -3
  17. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  18. package/dist/ContextMenu/ContextMenu.js +149 -26
  19. package/dist/ContextMenu/index.d.ts +1 -0
  20. package/dist/ContextMenu/index.d.ts.map +1 -1
  21. package/dist/DropdownMenu/DropdownMenu.d.ts +24 -5
  22. package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
  23. package/dist/DropdownMenu/DropdownMenu.js +340 -26
  24. package/dist/DropdownMenu/DropdownMenuContext.d.ts +1 -1
  25. package/dist/DropdownMenu/DropdownMenuContext.d.ts.map +1 -1
  26. package/dist/DropdownMenu/DropdownMenuItem.d.ts.map +1 -1
  27. package/dist/DropdownMenu/DropdownMenuItem.js +1 -1
  28. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts +6 -2
  29. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  30. package/dist/DropdownMenu/DropdownMenuSubMenu.js +43 -14
  31. package/dist/DropdownMenu/MenuBottomSheet.d.ts +20 -0
  32. package/dist/DropdownMenu/MenuBottomSheet.d.ts.map +1 -0
  33. package/dist/DropdownMenu/MenuBottomSheet.js +36 -0
  34. package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts +10 -0
  35. package/dist/DropdownMenu/MenuBottomSheetActionList.d.ts.map +1 -0
  36. package/dist/DropdownMenu/MenuBottomSheetActionList.js +111 -0
  37. package/dist/DropdownMenu/index.d.ts +2 -1
  38. package/dist/DropdownMenu/index.d.ts.map +1 -1
  39. package/dist/DropdownMenu/menuWidth.d.ts +14 -0
  40. package/dist/DropdownMenu/menuWidth.d.ts.map +1 -0
  41. package/dist/DropdownMenu/menuWidth.js +35 -0
  42. package/dist/DropdownMenu/useMenuOverflow.d.ts +10 -0
  43. package/dist/DropdownMenu/useMenuOverflow.d.ts.map +1 -0
  44. package/dist/DropdownMenu/useMenuOverflow.js +60 -0
  45. package/dist/MoreMenu/MoreMenu.d.ts +8 -2
  46. package/dist/MoreMenu/MoreMenu.d.ts.map +1 -1
  47. package/dist/MoreMenu/MoreMenu.js +2 -0
  48. package/dist/MultiSelector/MultiSelector.d.ts +11 -1
  49. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  50. package/dist/MultiSelector/MultiSelector.js +97 -68
  51. package/dist/MultiSelector/index.d.ts +1 -1
  52. package/dist/MultiSelector/index.d.ts.map +1 -1
  53. package/dist/Popover/Popover.d.ts +10 -6
  54. package/dist/Popover/Popover.d.ts.map +1 -1
  55. package/dist/Popover/Popover.js +141 -16
  56. package/dist/Popover/usePopover.d.ts +4 -0
  57. package/dist/Popover/usePopover.d.ts.map +1 -1
  58. package/dist/Popover/usePopover.js +52 -5
  59. package/dist/Selector/Selector.d.ts +10 -0
  60. package/dist/Selector/Selector.d.ts.map +1 -1
  61. package/dist/Selector/Selector.js +106 -80
  62. package/dist/Selector/SelectorBottomSheet.d.ts +21 -0
  63. package/dist/Selector/SelectorBottomSheet.d.ts.map +1 -0
  64. package/dist/Selector/SelectorBottomSheet.js +83 -0
  65. package/dist/Selector/index.d.ts +1 -1
  66. package/dist/Selector/index.d.ts.map +1 -1
  67. package/dist/Selector/selectorPresentation.stylex.d.ts +16 -0
  68. package/dist/Selector/selectorPresentation.stylex.d.ts.map +1 -0
  69. package/dist/Selector/selectorPresentation.stylex.js +21 -0
  70. package/dist/Selector/useSelectorPresentation.d.ts +32 -0
  71. package/dist/Selector/useSelectorPresentation.d.ts.map +1 -0
  72. package/dist/Selector/useSelectorPresentation.js +90 -0
  73. package/dist/Toast/useToastGesture.d.ts.map +1 -1
  74. package/dist/Toast/useToastGesture.js +46 -9
  75. package/dist/astryx.css +19 -1
  76. package/dist/hooks/useAdaptivePresentation.d.ts +5 -0
  77. package/dist/hooks/useAdaptivePresentation.d.ts.map +1 -0
  78. package/dist/hooks/useAdaptivePresentation.js +19 -0
  79. package/dist/hooks/useFocusReturnVisibility.d.ts +7 -0
  80. package/dist/hooks/useFocusReturnVisibility.d.ts.map +1 -0
  81. package/dist/hooks/useFocusReturnVisibility.js +35 -0
  82. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  83. package/dist/hooks/useFocusTrap.js +12 -0
  84. package/locales/en.json +20 -0
  85. package/locales/pseudo.json +15 -0
  86. package/package.json +2 -2
  87. package/src/BottomSheet/BottomSheet.doc.mjs +6 -0
  88. package/src/BottomSheet/BottomSheet.test.tsx +29 -0
  89. package/src/BottomSheet/BottomSheet.tsx +7 -4
  90. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -6
  91. package/src/BottomSheet/BottomSheetEdgeTint.tsx +7 -10
  92. package/src/BottomSheet/BottomSheetSwitcher.tsx +1 -2
  93. package/src/Button/Button.doc.mjs +56 -0
  94. package/src/Button/Button.test.tsx +10 -0
  95. package/src/Button/Button.tsx +13 -5
  96. package/src/ButtonGroup/ButtonGroup.doc.mjs +47 -0
  97. package/src/Carousel/Carousel.test.tsx +83 -0
  98. package/src/Carousel/Carousel.tsx +8 -2
  99. package/src/Chat/ChatToolCalls.test.tsx +44 -0
  100. package/src/Chat/ChatToolCalls.tsx +36 -15
  101. package/src/ContextMenu/ContextMenu.doc.mjs +13 -1
  102. package/src/ContextMenu/ContextMenu.test.tsx +131 -1
  103. package/src/ContextMenu/ContextMenu.tsx +199 -32
  104. package/src/ContextMenu/index.ts +1 -0
  105. package/src/DropdownMenu/DropdownMenu.doc.mjs +201 -40
  106. package/src/DropdownMenu/DropdownMenu.test.tsx +518 -7
  107. package/src/DropdownMenu/DropdownMenu.tsx +459 -28
  108. package/src/DropdownMenu/DropdownMenuContext.tsx +1 -1
  109. package/src/DropdownMenu/DropdownMenuItem.tsx +2 -0
  110. package/src/DropdownMenu/DropdownMenuSubMenu.doc.mjs +4 -2
  111. package/src/DropdownMenu/DropdownMenuSubMenu.test.tsx +124 -0
  112. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +72 -17
  113. package/src/DropdownMenu/MenuBottomSheet.tsx +46 -0
  114. package/src/DropdownMenu/MenuBottomSheetActionList.tsx +141 -0
  115. package/src/DropdownMenu/index.ts +2 -0
  116. package/src/DropdownMenu/menuWidth.ts +51 -0
  117. package/src/DropdownMenu/useMenuOverflow.ts +77 -0
  118. package/src/IconButton/IconButton.doc.mjs +38 -0
  119. package/src/MoreMenu/MoreMenu.doc.mjs +17 -0
  120. package/src/MoreMenu/MoreMenu.test.tsx +47 -0
  121. package/src/MoreMenu/MoreMenu.tsx +13 -1
  122. package/src/MultiSelector/MultiSelector.doc.mjs +16 -0
  123. package/src/MultiSelector/MultiSelector.test.tsx +127 -0
  124. package/src/MultiSelector/MultiSelector.tsx +121 -73
  125. package/src/MultiSelector/index.ts +1 -0
  126. package/src/Popover/Popover.doc.mjs +11 -11
  127. package/src/Popover/Popover.test.tsx +397 -2
  128. package/src/Popover/Popover.tsx +228 -21
  129. package/src/Popover/usePopover.tsx +92 -15
  130. package/src/ProgressBar/ProgressBar.spec.md +208 -0
  131. package/src/SegmentedControl/SegmentedControl.doc.mjs +56 -0
  132. package/src/Selector/Selector.doc.mjs +12 -0
  133. package/src/Selector/Selector.test.tsx +135 -0
  134. package/src/Selector/Selector.tsx +141 -82
  135. package/src/Selector/SelectorBottomSheet.tsx +97 -0
  136. package/src/Selector/index.ts +1 -0
  137. package/src/Selector/selectorPresentation.stylex.ts +20 -0
  138. package/src/Selector/useSelectorPresentation.ts +147 -0
  139. package/src/Toast/ToastViewport.test.tsx +118 -4
  140. package/src/Toast/useToastGesture.ts +61 -8
  141. package/src/ToggleButton/ToggleButton.doc.mjs +56 -0
  142. package/src/hooks/useAdaptivePresentation.ts +35 -0
  143. package/src/hooks/useFocusReturnVisibility.ts +45 -0
  144. package/src/hooks/useFocusTrap.test.tsx +40 -0
  145. package/src/hooks/useFocusTrap.ts +13 -0
  146. package/src/theme/themingTargets.test.ts +91 -25
@@ -10,9 +10,11 @@ import {describe, it, expect, vi, beforeEach} from 'vitest';
10
10
  import {useState} from 'react';
11
11
  import {render, screen, waitFor, fireEvent, act} from '@testing-library/react';
12
12
  import userEvent from '@testing-library/user-event';
13
+ import * as stylex from '@stylexjs/stylex';
13
14
  import {DropdownMenu} from './DropdownMenu';
14
15
  import {DropdownMenuItem} from './DropdownMenuItem';
15
16
  import {DropdownMenuSubMenu} from './DropdownMenuSubMenu';
17
+ import {rtlStyles} from '../utils';
16
18
 
17
19
  beforeEach(() => {
18
20
  HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) {
@@ -81,6 +83,82 @@ describe('DropdownMenuSubMenu', () => {
81
83
  ).toBeInTheDocument();
82
84
  });
83
85
 
86
+ it('caps the flyout width and height to the available viewport', async () => {
87
+ const user = userEvent.setup();
88
+ render(
89
+ <DropdownMenu button={{label: 'Actions'}}>
90
+ <DropdownMenuSubMenu label="Move to" menuWidth={640}>
91
+ <DropdownMenuItem label="Folder A" onClick={() => {}} />
92
+ </DropdownMenuSubMenu>
93
+ </DropdownMenu>,
94
+ );
95
+ await user.click(screen.getByRole('button', {name: /Actions/}));
96
+ const trigger = screen.getByRole('menuitem', {
97
+ name: /Move to/,
98
+ hidden: true,
99
+ });
100
+ await user.click(trigger);
101
+
102
+ const flyout = screen.getByRole('menu', {name: /Move to/, hidden: true});
103
+ const popover = flyout.closest('[popover]');
104
+ expect(popover?.className).toContain(
105
+ 'DropdownMenuSubMenu__flyoutStyles.popoverViewport',
106
+ );
107
+ expect(popover).not.toHaveStyle(
108
+ 'margin-inline-start: max(var(--spacing-2),env(safe-area-inset-left,0px))',
109
+ );
110
+ expect(popover).not.toHaveStyle(
111
+ 'margin-inline-end: max(var(--spacing-2),env(safe-area-inset-right,0px))',
112
+ );
113
+ expect(popover).toHaveStyle({minWidth: 'var(--x-minWidth)'});
114
+ expect(popover?.getAttribute('style')).toContain('min(640px, calc(100vw');
115
+ expect(flyout).toHaveStyle(
116
+ 'max-height: min(300px,calc(100dvb - max(var(--spacing-4),env(safe-area-inset-top,0px)) - max(var(--spacing-4),env(safe-area-inset-bottom,0px))))',
117
+ );
118
+ expect(flyout).not.toHaveStyle({overflowY: 'auto'});
119
+ expect(flyout).toHaveAttribute('tabindex', '-1');
120
+
121
+ Object.defineProperties(flyout, {
122
+ clientHeight: {configurable: true, value: 300},
123
+ scrollHeight: {configurable: true, value: 480},
124
+ });
125
+ fireEvent(window, new Event('resize'));
126
+
127
+ await waitFor(() => {
128
+ expect(flyout).toHaveStyle({overflowY: 'auto'});
129
+ expect(flyout).toHaveAttribute('tabindex', '0');
130
+ });
131
+ });
132
+
133
+ it.each(['max-content', 'fit-content', 'auto'])(
134
+ 'preserves the valid %s flyout width while retaining the viewport cap',
135
+ async menuWidth => {
136
+ const user = userEvent.setup();
137
+ render(
138
+ <DropdownMenu button={{label: 'Actions'}}>
139
+ <DropdownMenuSubMenu label="Move to" menuWidth={menuWidth}>
140
+ <DropdownMenuItem label="Folder A" onClick={() => {}} />
141
+ </DropdownMenuSubMenu>
142
+ </DropdownMenu>,
143
+ );
144
+ await user.click(screen.getByRole('button', {name: /Actions/}));
145
+ await user.click(
146
+ screen.getByRole('menuitem', {name: /Move to/, hidden: true}),
147
+ );
148
+
149
+ const flyout = screen.getByRole('menu', {name: /Move to/, hidden: true});
150
+ const popover = flyout.closest('[popover]');
151
+ expect(popover?.className).toContain(
152
+ 'DropdownMenuSubMenu__flyoutStyles.popoverCustomIntrinsicWidth',
153
+ );
154
+ expect(popover?.getAttribute('style')).toContain(menuWidth);
155
+ expect(popover?.className).toContain(
156
+ 'DropdownMenuSubMenu__flyoutStyles.popoverViewport',
157
+ );
158
+ expect(popover?.getAttribute('style')).not.toContain(`min(${menuWidth},`);
159
+ },
160
+ );
161
+
84
162
  it('opens on ArrowRight and returns focus to the trigger on ArrowLeft', async () => {
85
163
  const user = userEvent.setup();
86
164
  render(<MoveMenu />);
@@ -115,6 +193,52 @@ describe('DropdownMenuSubMenu', () => {
115
193
  expect(trigger).toHaveFocus();
116
194
  });
117
195
 
196
+ it('mirrors its indicator and keyboard directions under RTL', async () => {
197
+ const user = userEvent.setup();
198
+ const {className: mirrorClassName} = stylex.props(rtlStyles.mirror);
199
+ const mirrorClasses = mirrorClassName?.split(' ') ?? [];
200
+ expect(mirrorClasses.length).toBeGreaterThan(0);
201
+ render(
202
+ <div dir="rtl" style={{direction: 'rtl'}}>
203
+ <MoveMenu />
204
+ </div>,
205
+ );
206
+
207
+ await user.click(screen.getByRole('button', {name: /Actions/}));
208
+ const trigger = screen.getByRole('menuitem', {
209
+ name: /Move to/,
210
+ hidden: true,
211
+ });
212
+ const indicator = trigger.querySelector('.astryx-icon');
213
+ expect(indicator).not.toBeNull();
214
+ for (const className of mirrorClasses) {
215
+ expect(indicator).toHaveClass(className);
216
+ }
217
+
218
+ await waitFor(() => {
219
+ expect(
220
+ screen.getByRole('menu', {name: 'Actions', hidden: true}),
221
+ ).toHaveFocus();
222
+ });
223
+ // jsdom does not propagate the ancestor's computed direction, so set the
224
+ // same RTL direction directly on each keyboard event owner.
225
+ trigger.style.direction = 'rtl';
226
+ trigger.focus();
227
+ await user.keyboard('{ArrowLeft}');
228
+ const flyout = screen.getByRole('menu', {name: /Move to/, hidden: true});
229
+ flyout.style.direction = 'rtl';
230
+ await waitFor(() => {
231
+ expect(
232
+ screen.getByRole('menuitem', {name: 'Folder A', hidden: true}),
233
+ ).toHaveFocus();
234
+ });
235
+ await user.keyboard('{ArrowRight}');
236
+ await waitFor(() => {
237
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
238
+ });
239
+ expect(trigger).toHaveFocus();
240
+ });
241
+
118
242
  it('names the flyout from its trigger via aria-labelledby', async () => {
119
243
  const user = userEvent.setup();
120
244
  render(<MoveMenu />);
@@ -5,7 +5,8 @@
5
5
  /**
6
6
  * @file DropdownMenuSubMenu.tsx
7
7
  * @input React, stylex, useLayer (context mode), useListFocus, useMenuHover,
8
- * useTypeahead, Item, Icon, Spinner, DropdownMenu context + item roles.
8
+ * useTypeahead, the shared viewport-safe menu-width resolver, Item, Icon,
9
+ * Spinner, and DropdownMenu context + item roles
9
10
  * @output Exports DropdownMenuSubMenu — a single menu row that reveals a nested
10
11
  * flyout menu of its own children/items.
11
12
  * @position Sub-component; place inside a DropdownMenu (or ContextMenu)
@@ -56,6 +57,8 @@ import {layerAnimations} from '../Layer/layerAnimations.stylex';
56
57
  import {useListFocus} from '../hooks/useListFocus';
57
58
  import {useMenuHover} from '../hooks/useMenuHover';
58
59
  import {useTypeahead} from '../hooks/useTypeahead';
60
+ import {useMenuOverflow} from './useMenuOverflow';
61
+ import {resolveMenuWidth} from './menuWidth';
59
62
  import {
60
63
  colorVars,
61
64
  spacingVars,
@@ -66,7 +69,7 @@ import {
66
69
  typographyVars,
67
70
  typeScaleVars,
68
71
  } from '../theme/tokens.stylex';
69
- import {mergeProps} from '../utils';
72
+ import {mergeProps, rtlStyles} from '../utils';
70
73
  import {themeProps} from '../utils/themeProps';
71
74
  import type {BaseProps} from '../BaseProps';
72
75
  import {
@@ -81,6 +84,14 @@ import {
81
84
  } from './DropdownMenuContext';
82
85
  import {focusMenuItemOnHover} from './menuItemHover';
83
86
 
87
+ const MENU_VIEWPORT_GUTTER = spacingVars['--spacing-4'];
88
+ // `useLayer` adds 4px of anchor clearance. An 8px collision margin resolves
89
+ // to a 4px visible gap after the browser flips the flyout.
90
+ const MENU_MAX_INLINE_SIZE = `calc(100vi - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-left, 0px)) - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-right, 0px)))`;
91
+ const MENU_MAX_INLINE_SIZE_FALLBACK = `calc(100vw - ${MENU_VIEWPORT_GUTTER} - ${MENU_VIEWPORT_GUTTER})`;
92
+ const MENU_MAX_BLOCK_SIZE = `min(300px, calc(100dvb - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-top, 0px)) - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-bottom, 0px))))`;
93
+ const MENU_MAX_BLOCK_SIZE_FALLBACK = `min(300px, calc(100vh - ${MENU_VIEWPORT_GUTTER} - ${MENU_VIEWPORT_GUTTER}))`;
94
+
84
95
  const triggerStyles = stylex.create({
85
96
  root: {
86
97
  boxSizing: 'border-box',
@@ -135,8 +146,14 @@ const flyoutStyles = stylex.create({
135
146
  display: 'flex',
136
147
  flexDirection: 'column',
137
148
  gap: spacingVars['--spacing-0-5'],
138
- maxHeight: '300px',
139
- overflowY: 'auto',
149
+ maxInlineSize: stylex.firstThatWorks(
150
+ MENU_MAX_INLINE_SIZE,
151
+ MENU_MAX_INLINE_SIZE_FALLBACK,
152
+ ),
153
+ maxHeight: stylex.firstThatWorks(
154
+ MENU_MAX_BLOCK_SIZE,
155
+ MENU_MAX_BLOCK_SIZE_FALLBACK,
156
+ ),
140
157
  '--_dropdown-menu-radius': radiusVars['--radius-container'],
141
158
  '--_dropdown-menu-padding': spacingVars['--spacing-1'],
142
159
  padding: spacingVars['--spacing-1'],
@@ -148,11 +165,33 @@ const flyoutStyles = stylex.create({
148
165
  transitionDuration: durationVars['--duration-fast'],
149
166
  transitionTimingFunction: easeVars['--ease-standard'],
150
167
  },
168
+ scrollable: {
169
+ overflowY: 'auto',
170
+ overflowX: 'hidden',
171
+ overscrollBehavior: 'contain',
172
+ },
173
+ popoverViewport: {
174
+ boxSizing: 'border-box',
175
+ // Keep the inline viewport cap on the menu surface above. Applying it to
176
+ // the anchor-positioned popover itself prevents Chromium from selecting
177
+ // `flip-inline` and instead shifts the flyout back across its parent.
178
+ maxBlockSize: stylex.firstThatWorks(
179
+ MENU_MAX_BLOCK_SIZE,
180
+ MENU_MAX_BLOCK_SIZE_FALLBACK,
181
+ ),
182
+ },
151
183
  popover: {
152
- minWidth: '160px',
184
+ minWidth: stylex.firstThatWorks(
185
+ `min(160px, ${MENU_MAX_INLINE_SIZE})`,
186
+ `min(160px, ${MENU_MAX_INLINE_SIZE_FALLBACK})`,
187
+ '160px',
188
+ ),
153
189
  },
154
- popoverCustomWidth: (width: string | number) => ({
155
- minWidth: typeof width === 'number' ? `${width}px` : width,
190
+ popoverCustomWidth: (width: string) => ({
191
+ minWidth: width,
192
+ }),
193
+ popoverCustomIntrinsicWidth: (width: string) => ({
194
+ inlineSize: width,
156
195
  }),
157
196
  });
158
197
 
@@ -178,7 +217,10 @@ interface DropdownMenuSubMenuBaseProps extends Pick<
178
217
  * @default false
179
218
  */
180
219
  hasSpinner?: boolean;
181
- /** Fixed flyout width. Defaults to sizing to its content (min 160px). */
220
+ /**
221
+ * Minimum flyout width. The flyout may grow for its content but is capped to
222
+ * the available viewport space. Defaults to intrinsic sizing (min 160px).
223
+ */
182
224
  menuWidth?: number | string;
183
225
  /** Called when the flyout opens or closes. */
184
226
  onOpenChange?: (isOpen: boolean) => void;
@@ -289,6 +331,7 @@ export function DropdownMenuSubMenu(
289
331
  wrap: false,
290
332
  onEscape: () => close({focusTrigger: true}),
291
333
  });
334
+ const hasOverflow = useMenuOverflow(menuRef, children, isOpen);
292
335
 
293
336
  const typeahead = useTypeahead({
294
337
  getItemLabels: () => getItems().map(el => el.textContent),
@@ -485,13 +528,19 @@ export function DropdownMenuSubMenu(
485
528
  icon="chevronRight"
486
529
  size="sm"
487
530
  color="secondary"
531
+ xstyle={rtlStyles.mirror}
488
532
  {...themeProps('dropdown-menu-indicator-icon')}
489
533
  />
490
534
  </span>
491
535
  );
492
536
 
493
- const popoverXstyle = menuWidth
494
- ? flyoutStyles.popoverCustomWidth(menuWidth)
537
+ const resolvedMenuWidth = menuWidth
538
+ ? resolveMenuWidth(menuWidth, MENU_MAX_INLINE_SIZE_FALLBACK)
539
+ : null;
540
+ const popoverXstyle = resolvedMenuWidth
541
+ ? resolvedMenuWidth.property === 'inlineSize'
542
+ ? flyoutStyles.popoverCustomIntrinsicWidth(resolvedMenuWidth.value)
543
+ : flyoutStyles.popoverCustomWidth(resolvedMenuWidth.value)
495
544
  : flyoutStyles.popover;
496
545
 
497
546
  return (
@@ -537,11 +586,10 @@ export function DropdownMenuSubMenu(
537
586
  ref={menuRef}
538
587
  id={contentId}
539
588
  role="menu"
540
- // Focusable as a fallback target so an empty/loading flyout (e.g.
541
- // hasSpinner with only a disabled row) can still receive keyboard
542
- // focus and own arrow/Escape keys instead of leaving focus on the
543
- // parent list.
544
- tabIndex={-1}
589
+ // Focusable as a fallback target so an empty/loading flyout can own
590
+ // arrow/Escape keys. An overflowing flyout joins the Tab order so its
591
+ // scrollable region is keyboard-accessible.
592
+ tabIndex={hasOverflow ? 0 : -1}
545
593
  aria-labelledby={triggerId}
546
594
  onKeyDown={handleContentKeyDown}
547
595
  onMouseEnter={contentProps.onMouseEnter}
@@ -549,7 +597,10 @@ export function DropdownMenuSubMenu(
549
597
  data-testid={menuDataTestId}
550
598
  {...mergeProps(
551
599
  themeProps('dropdown-menu'),
552
- stylex.props(flyoutStyles.menu),
600
+ stylex.props(
601
+ flyoutStyles.menu,
602
+ hasOverflow && flyoutStyles.scrollable,
603
+ ),
553
604
  )}>
554
605
  <DropdownMenuContext value={nestedMenuContext}>
555
606
  {children}
@@ -559,7 +610,11 @@ export function DropdownMenuSubMenu(
559
610
  placement: 'end',
560
611
  alignment: 'start',
561
612
  offset: spacingVars['--spacing-1'],
562
- xstyle: [popoverXstyle, layerAnimations.end],
613
+ xstyle: [
614
+ flyoutStyles.popoverViewport,
615
+ popoverXstyle,
616
+ layerAnimations.end,
617
+ ],
563
618
  },
564
619
  )}
565
620
  </>
@@ -0,0 +1,46 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file MenuBottomSheet.tsx
7
+ * @input Uses BottomSheet and Section
8
+ * @output Internal action-sheet host for menu content
9
+ * @position Shared by DropdownMenu and ContextMenu adaptive presentations
10
+ */
11
+
12
+ import type {ReactNode, RefObject} from 'react';
13
+ import {BottomSheet} from '../BottomSheet/BottomSheet';
14
+ import {Section} from '../Section/Section';
15
+
16
+ interface MenuBottomSheetProps {
17
+ finalFocusRef?: RefObject<HTMLElement | null>;
18
+ isOpen: boolean;
19
+ onOpenChange: (isOpen: boolean) => void;
20
+ label: string;
21
+ children: ReactNode;
22
+ }
23
+
24
+ export function MenuBottomSheet({
25
+ finalFocusRef,
26
+ isOpen,
27
+ onOpenChange,
28
+ label,
29
+ children,
30
+ }: MenuBottomSheetProps) {
31
+ return (
32
+ <BottomSheet
33
+ finalFocusRef={finalFocusRef}
34
+ isOpen={isOpen}
35
+ onOpenChange={onOpenChange}
36
+ label={label}
37
+ height="hug"
38
+ purpose="info">
39
+ <Section paddingBlockStart={4} paddingBlockEnd={0} paddingInline={1}>
40
+ {children}
41
+ </Section>
42
+ </BottomSheet>
43
+ );
44
+ }
45
+
46
+ MenuBottomSheet.displayName = 'MenuBottomSheet';
@@ -0,0 +1,141 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file MenuBottomSheetActionList.tsx
7
+ * @input Uses menu data entries and callbacks for selection and drill-in
8
+ * @output Internal touch-friendly action list shared by DropdownMenu and ContextMenu
9
+ * @position Shared implementation for menu bottom-sheet presentations
10
+ */
11
+
12
+ import type {ReactElement} from 'react';
13
+ import * as stylex from '@stylexjs/stylex';
14
+ import {Divider} from '../Divider';
15
+ import {Heading} from '../Heading';
16
+ import {Icon, renderIconSlot} from '../Icon';
17
+ import {List, ListItem} from '../List';
18
+ import {colorVars, spacingVars} from '../theme/tokens.stylex';
19
+ import {rtlStyles} from '../utils';
20
+ import {getInteractionModality} from '../utils/interactionModality';
21
+ import type {DropdownMenuItemData, DropdownMenuOption} from './DropdownMenu';
22
+
23
+ const styles = stylex.create({
24
+ destructiveAction: {
25
+ '--_item-label-color': colorVars['--color-error'],
26
+ '--_item-description-color': colorVars['--color-error'],
27
+ color: colorVars['--color-error'],
28
+ },
29
+ structuralItem: {
30
+ listStyleType: 'none',
31
+ },
32
+ divider: {
33
+ marginBlock: spacingVars['--spacing-1'],
34
+ },
35
+ section: {
36
+ display: 'flex',
37
+ flexDirection: 'column',
38
+ gap: spacingVars['--spacing-1'],
39
+ },
40
+ });
41
+
42
+ function getItemKey(item: DropdownMenuItemData, index: number): string {
43
+ return `item-${item.id ?? index}`;
44
+ }
45
+
46
+ export function MenuBottomSheetActionList({
47
+ items,
48
+ onSelect,
49
+ onOpenSubmenu,
50
+ }: {
51
+ items: DropdownMenuOption[];
52
+ onSelect: (item: DropdownMenuItemData) => void;
53
+ onOpenSubmenu: (item: DropdownMenuItemData) => void;
54
+ }) {
55
+ const renderItem = (
56
+ item: DropdownMenuItemData,
57
+ index: number,
58
+ ): ReactElement => {
59
+ const isSubmenu = item.items != null && item.items.length > 0;
60
+ const isDestructive = item.variant === 'destructive';
61
+
62
+ return (
63
+ <ListItem
64
+ key={getItemKey(item, index)}
65
+ label={item.label}
66
+ description={item.description}
67
+ startContent={
68
+ item.icon
69
+ ? renderIconSlot(item.icon, {
70
+ size: 'sm',
71
+ color: isDestructive ? 'error' : 'secondary',
72
+ })
73
+ : undefined
74
+ }
75
+ endContent={
76
+ isSubmenu ? (
77
+ <Icon
78
+ icon="chevronRight"
79
+ size="sm"
80
+ color="secondary"
81
+ xstyle={rtlStyles.mirror}
82
+ />
83
+ ) : (
84
+ item.endContent
85
+ )
86
+ }
87
+ isDisabled={item.isDisabled}
88
+ onClick={event => {
89
+ if (getInteractionModality() === 'pointer') {
90
+ (event.currentTarget as HTMLElement).blur();
91
+ }
92
+ if (isSubmenu) {
93
+ onOpenSubmenu(item);
94
+ } else {
95
+ onSelect(item);
96
+ }
97
+ }}
98
+ xstyle={isDestructive && styles.destructiveAction}
99
+ />
100
+ );
101
+ };
102
+
103
+ return (
104
+ <List density="spacious">
105
+ {items.map((option, index) => {
106
+ if ('type' in option && option.type === 'divider') {
107
+ return (
108
+ <li
109
+ // eslint-disable-next-line @eslint-react/no-array-index-key
110
+ key={`divider-${index}`}
111
+ role="presentation"
112
+ {...stylex.props(styles.structuralItem)}>
113
+ <Divider xstyle={styles.divider} />
114
+ </li>
115
+ );
116
+ }
117
+
118
+ if ('type' in option && option.type === 'section') {
119
+ return (
120
+ <li
121
+ key={`section-${option.id ?? index}`}
122
+ role="presentation"
123
+ {...stylex.props(styles.structuralItem)}>
124
+ <div
125
+ role="group"
126
+ aria-label={option.title}
127
+ {...stylex.props(styles.section)}>
128
+ {option.title && <Heading level={4}>{option.title}</Heading>}
129
+ <List density="spacious">{option.items.map(renderItem)}</List>
130
+ </div>
131
+ </li>
132
+ );
133
+ }
134
+
135
+ return renderItem(option, index);
136
+ })}
137
+ </List>
138
+ );
139
+ }
140
+
141
+ MenuBottomSheetActionList.displayName = 'MenuBottomSheetActionList';
@@ -20,12 +20,14 @@
20
20
  export {
21
21
  DropdownMenu,
22
22
  type DropdownMenuProps,
23
+ type DropdownMenuPresentation,
23
24
  type DropdownMenuButtonProps,
24
25
  type DropdownMenuItemData,
25
26
  type DropdownMenuDividerData,
26
27
  type DropdownMenuSection,
27
28
  type DropdownMenuOption,
28
29
  } from './DropdownMenu';
30
+ export type {AdaptivePresentation as MenuPresentation} from '../hooks/useAdaptivePresentation';
29
31
 
30
32
  export {DropdownMenuItem, type DropdownMenuItemProps} from './DropdownMenuItem';
31
33
 
@@ -0,0 +1,51 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file menuWidth.ts
5
+ * @input Accepts the public DropdownMenu menuWidth value and a viewport limit
6
+ * @output Resolves whether the value belongs on min-width or inline-size
7
+ * @position Shared width compatibility logic for root menus and submenu flyouts
8
+ */
9
+
10
+ const INTRINSIC_AND_CSS_WIDE_WIDTHS = new Set([
11
+ 'auto',
12
+ 'contain',
13
+ 'fit-content',
14
+ 'inherit',
15
+ 'initial',
16
+ 'max-content',
17
+ 'min-content',
18
+ 'revert',
19
+ 'revert-layer',
20
+ 'stretch',
21
+ 'unset',
22
+ ]);
23
+
24
+ export type ResolvedMenuWidth =
25
+ | {property: 'inlineSize'; value: string}
26
+ | {property: 'minWidth'; value: string};
27
+
28
+ /**
29
+ * CSS intrinsic and CSS-wide keywords cannot be arguments to `min()`. Apply
30
+ * those values as the preferred inline size so max-inline-size can still cap
31
+ * the menu. Lengths retain the existing minimum-width growth contract.
32
+ */
33
+ export function resolveMenuWidth(
34
+ menuWidth: number | string,
35
+ maximum: string,
36
+ ): ResolvedMenuWidth {
37
+ if (typeof menuWidth === 'number') {
38
+ return {property: 'minWidth', value: `min(${menuWidth}px, ${maximum})`};
39
+ }
40
+
41
+ const width = menuWidth.trim();
42
+ const normalizedWidth = width.toLowerCase();
43
+ if (
44
+ INTRINSIC_AND_CSS_WIDE_WIDTHS.has(normalizedWidth) ||
45
+ normalizedWidth.startsWith('fit-content(')
46
+ ) {
47
+ return {property: 'inlineSize', value: width};
48
+ }
49
+
50
+ return {property: 'minWidth', value: `min(${width}, ${maximum})`};
51
+ }
@@ -0,0 +1,77 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file useMenuOverflow.ts
7
+ * @input Menu element ref, rendered content, and open state
8
+ * @output Returns whether the open menu overflows its block-size constraint
9
+ * @position Shared responsive helper for DropdownMenu and DropdownMenuSubMenu
10
+ */
11
+
12
+ import {useCallback, useState, type ReactNode, type RefObject} from 'react';
13
+ import {useIsomorphicLayoutEffect} from '../hooks/useIsomorphicLayoutEffect';
14
+
15
+ /** Detect block-axis overflow without making a fitting menu a scroll container. */
16
+ export function useMenuOverflow(
17
+ menuRef: RefObject<HTMLElement | null>,
18
+ content: ReactNode,
19
+ isOpen: boolean,
20
+ ): boolean {
21
+ const [hasOverflow, setHasOverflow] = useState(false);
22
+
23
+ const measureOverflow = useCallback(() => {
24
+ const menu = menuRef.current;
25
+ if (!menu) {
26
+ return;
27
+ }
28
+ const nextHasOverflow = menu.scrollHeight > menu.clientHeight + 1;
29
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- DOM overflow measurement controls whether this menu becomes a scroll container
30
+ setHasOverflow(current =>
31
+ current === nextHasOverflow ? current : nextHasOverflow,
32
+ );
33
+ }, [menuRef]);
34
+
35
+ useIsomorphicLayoutEffect(() => {
36
+ if (!isOpen) {
37
+ return;
38
+ }
39
+ const menu = menuRef.current;
40
+ if (!menu) {
41
+ return;
42
+ }
43
+ measureOverflow();
44
+ const resizeObserver =
45
+ typeof ResizeObserver === 'undefined'
46
+ ? null
47
+ : new ResizeObserver(measureOverflow);
48
+ const mutationObserver =
49
+ typeof MutationObserver === 'undefined'
50
+ ? null
51
+ : new MutationObserver(measureOverflow);
52
+ resizeObserver?.observe(menu);
53
+ mutationObserver?.observe(menu, {
54
+ childList: true,
55
+ characterData: true,
56
+ subtree: true,
57
+ });
58
+ menu.addEventListener('load', measureOverflow, true);
59
+ window.addEventListener('resize', measureOverflow);
60
+ window.visualViewport?.addEventListener('resize', measureOverflow);
61
+ return () => {
62
+ resizeObserver?.disconnect();
63
+ mutationObserver?.disconnect();
64
+ menu.removeEventListener('load', measureOverflow, true);
65
+ window.removeEventListener('resize', measureOverflow);
66
+ window.visualViewport?.removeEventListener('resize', measureOverflow);
67
+ };
68
+ }, [isOpen, measureOverflow, menuRef]);
69
+
70
+ useIsomorphicLayoutEffect(() => {
71
+ if (isOpen) {
72
+ measureOverflow();
73
+ }
74
+ }, [content, isOpen, measureOverflow]);
75
+
76
+ return hasOverflow;
77
+ }