@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
@@ -4,8 +4,10 @@
4
4
 
5
5
  /**
6
6
  * @file DropdownMenu.tsx
7
- * @input Uses React, StyleX, usePopover, Button, Icon, useListFocus
8
- * @output Exports DropdownMenu component
7
+ * @input Uses React, StyleX, usePopover, MenuBottomSheet, Button, List,
8
+ * useListFocus, and the shared viewport-safe menu-width resolver
9
+ * @output Exports DropdownMenu with caller-selected popover or bottom-sheet
10
+ * presentation
9
11
  * @position Core implementation; consumed by index.ts
10
12
  *
11
13
  * Supports two modes with a single keyboard/focus path:
@@ -39,6 +41,7 @@ import React, {
39
41
  import * as stylex from '@stylexjs/stylex';
40
42
  import {usePopoverInternal} from '../Popover/usePopover';
41
43
  import {Button, type ButtonProps} from '../Button';
44
+ import {Heading} from '../Heading';
42
45
  import {Icon} from '../Icon';
43
46
 
44
47
  import {renderDropdownItems} from './renderDropdownItems';
@@ -54,27 +57,65 @@ import {
54
57
  } from './DropdownMenuContext';
55
58
  import {useListFocus} from '../hooks/useListFocus';
56
59
  import {useTypeahead} from '../hooks/useTypeahead';
60
+ import {useFocusReturnVisibility} from '../hooks/useFocusReturnVisibility';
61
+ import {useMenuOverflow} from './useMenuOverflow';
62
+ import {resolveMenuWidth} from './menuWidth';
63
+ import {
64
+ useAdaptivePresentation,
65
+ type AdaptivePresentation,
66
+ } from '../hooks/useAdaptivePresentation';
67
+ import {MenuBottomSheet} from './MenuBottomSheet';
68
+ import {MenuBottomSheetActionList} from './MenuBottomSheetActionList';
57
69
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
58
70
  import type {LayerAlignment, LayerPlacement} from '../Layer/useLayer';
59
71
  import {
60
72
  spacingVars,
73
+ colorVars,
61
74
  radiusVars,
62
75
  durationVars,
63
76
  easeVars,
64
77
  } from '../theme/tokens.stylex';
65
- import {mergeProps} from '../utils';
78
+ import {mergeProps, rtlStyles} from '../utils';
66
79
  import type {BaseProps} from '../BaseProps';
67
80
  import {themeProps} from '../utils/themeProps';
81
+ import {
82
+ getInteractionModality,
83
+ trackInteractionModality,
84
+ } from '../utils/interactionModality';
68
85
  import {useTranslator} from '../i18n';
86
+ import {focusOutlineStyles} from '../utils/focusOutline.stylex';
87
+
88
+ const MENU_VIEWPORT_GUTTER = spacingVars['--spacing-4'];
89
+ 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)))`;
90
+ const MENU_MAX_INLINE_SIZE_FALLBACK = `calc(100vw - ${MENU_VIEWPORT_GUTTER} - ${MENU_VIEWPORT_GUTTER})`;
91
+ 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))))`;
92
+ const MENU_MAX_BLOCK_SIZE_FALLBACK = `min(300px, calc(100vh - ${MENU_VIEWPORT_GUTTER} - ${MENU_VIEWPORT_GUTTER}))`;
93
+ const MENU_POSITION_AREA_MAX_INLINE_SIZE = `calc(100% - max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px)))`;
94
+ const MENU_POSITION_AREA_MAX_INLINE_SIZE_FALLBACK = `calc(100% - ${MENU_VIEWPORT_GUTTER})`;
95
+ const MENU_INLINE_EDGE_GUTTER = `max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px))`;
96
+ const MENU_TRIGGER_OPEN_BACKGROUND = `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`;
69
97
 
70
98
  const styles = stylex.create({
99
+ triggerOpen: {
100
+ backgroundImage: MENU_TRIGGER_OPEN_BACKGROUND,
101
+ },
71
102
  dropdown: {
72
103
  boxSizing: 'border-box',
73
104
  display: 'flex',
74
105
  flexDirection: 'column',
75
106
  gap: spacingVars['--spacing-0-5'],
76
- maxHeight: '300px',
77
- overflowY: 'auto',
107
+ // Pointer opens focus this container so subsequent arrow keys and Escape
108
+ // stay owned by the menu. It is an internal focus target, not a control,
109
+ // so suppress the browser ring; keyboard focus moves to an item instead.
110
+ outline: 'none',
111
+ maxInlineSize: stylex.firstThatWorks(
112
+ MENU_MAX_INLINE_SIZE,
113
+ MENU_MAX_INLINE_SIZE_FALLBACK,
114
+ ),
115
+ maxHeight: stylex.firstThatWorks(
116
+ MENU_MAX_BLOCK_SIZE,
117
+ MENU_MAX_BLOCK_SIZE_FALLBACK,
118
+ ),
78
119
  '--_dropdown-menu-radius': radiusVars['--radius-container'],
79
120
  '--_dropdown-menu-padding': spacingVars['--spacing-1'],
80
121
  padding: spacingVars['--spacing-1'],
@@ -84,14 +125,95 @@ const styles = stylex.create({
84
125
  transitionDuration: durationVars['--duration-fast'],
85
126
  transitionTimingFunction: easeVars['--ease-standard'],
86
127
  },
87
- popover: {
88
- minWidth: 'anchor-size(width)',
128
+ scrollable: {
129
+ overflowY: 'auto',
130
+ overflowX: 'hidden',
131
+ overscrollBehavior: 'contain',
132
+ },
133
+ popoverViewport: {
134
+ boxSizing: 'border-box',
135
+ maxBlockSize: stylex.firstThatWorks(
136
+ MENU_MAX_BLOCK_SIZE,
137
+ MENU_MAX_BLOCK_SIZE_FALLBACK,
138
+ ),
139
+ },
140
+ popoverViewportAligned: {
141
+ maxInlineSize: stylex.firstThatWorks(
142
+ MENU_POSITION_AREA_MAX_INLINE_SIZE,
143
+ MENU_POSITION_AREA_MAX_INLINE_SIZE_FALLBACK,
144
+ ),
145
+ },
146
+ popoverViewportStart: {
147
+ marginInlineEnd: MENU_INLINE_EDGE_GUTTER,
148
+ },
149
+ popoverViewportEnd: {
150
+ marginInlineStart: MENU_INLINE_EDGE_GUTTER,
151
+ },
152
+ popoverViewportBlockStart: {
153
+ marginBlockEnd: `max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-bottom, 0px))`,
154
+ },
155
+ popoverViewportBlockEnd: {
156
+ marginBlockStart: `max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-top, 0px))`,
157
+ },
158
+ popoverViewportCentered: {
159
+ marginInlineStart: MENU_INLINE_EDGE_GUTTER,
160
+ marginInlineEnd: MENU_INLINE_EDGE_GUTTER,
161
+ maxInlineSize: stylex.firstThatWorks(
162
+ MENU_MAX_INLINE_SIZE,
163
+ MENU_MAX_INLINE_SIZE_FALLBACK,
164
+ ),
165
+ },
166
+ popoverViewportBlockCentered: {
167
+ marginBlockStart: `max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-top, 0px))`,
168
+ marginBlockEnd: `max(${MENU_VIEWPORT_GUTTER}, env(safe-area-inset-bottom, 0px))`,
169
+ maxInlineSize: stylex.firstThatWorks(
170
+ MENU_MAX_INLINE_SIZE,
171
+ MENU_MAX_INLINE_SIZE_FALLBACK,
172
+ ),
173
+ },
174
+ popoverAligned: {
175
+ minWidth: stylex.firstThatWorks(
176
+ `min(anchor-size(width), ${MENU_POSITION_AREA_MAX_INLINE_SIZE})`,
177
+ `min(anchor-size(width), ${MENU_POSITION_AREA_MAX_INLINE_SIZE_FALLBACK})`,
178
+ 'anchor-size(width)',
179
+ ),
89
180
  },
90
- popoverCustomWidth: (width: string | number) => ({
91
- minWidth: typeof width === 'number' ? `${width}px` : width,
181
+ popoverCentered: {
182
+ minWidth: stylex.firstThatWorks(
183
+ `min(anchor-size(width), ${MENU_MAX_INLINE_SIZE})`,
184
+ `min(anchor-size(width), ${MENU_MAX_INLINE_SIZE_FALLBACK})`,
185
+ 'anchor-size(width)',
186
+ ),
187
+ },
188
+ popoverCustomWidth: (width: string) => ({
189
+ minWidth: width,
190
+ }),
191
+ popoverCustomIntrinsicWidth: (width: string) => ({
192
+ inlineSize: width,
92
193
  }),
93
194
  });
94
195
 
196
+ const bottomSheetStyles = stylex.create({
197
+ content: {
198
+ width: '100%',
199
+ },
200
+ header: {
201
+ display: 'flex',
202
+ flexDirection: 'row',
203
+ alignItems: 'center',
204
+ gap: spacingVars['--spacing-1'],
205
+ marginBottom: spacingVars['--spacing-2'],
206
+ },
207
+ rootHeading: {
208
+ // Match the content edge of a spacious ListItem. Icon-bearing rows place
209
+ // their icon here; rows without an icon place their label here.
210
+ marginInlineStart: spacingVars['--spacing-3'],
211
+ },
212
+ viewHeading: {
213
+ outline: 'none',
214
+ },
215
+ });
216
+
95
217
  // =============================================================================
96
218
  // Types
97
219
  // =============================================================================
@@ -157,22 +279,31 @@ export type DropdownMenuOption =
157
279
 
158
280
  export type DropdownMenuButtonProps = Omit<ButtonProps, 'onClick'>;
159
281
 
282
+ export type DropdownMenuPresentation = AdaptivePresentation;
283
+
160
284
  interface DropdownMenuBaseProps extends BaseProps {
161
285
  button?: DropdownMenuButtonProps;
162
286
  isMenuOpen?: boolean;
163
287
  onOpenChange?: (isOpen: boolean) => void;
288
+ /**
289
+ * Minimum popover width. The menu may grow for its content but is capped to
290
+ * the available viewport space. Ignored by bottom-sheet presentation.
291
+ * Defaults to the trigger width.
292
+ */
164
293
  menuWidth?: number | string;
165
294
  onClick?: () => void;
166
295
  hasChevron?: boolean;
167
296
  /**
168
- * Position placement relative to the trigger.
297
+ * Popover position relative to the trigger. Ignored by bottom-sheet
298
+ * presentation.
169
299
  * Uses the same placement values as other Astryx layer-based components.
170
300
  * @default 'below'
171
301
  */
172
302
  placement?: LayerPlacement;
173
303
 
174
304
  /**
175
- * Alignment along the placement axis.
305
+ * Popover alignment along the placement axis. Ignored by bottom-sheet
306
+ * presentation.
176
307
  * Uses the same alignment values as other Astryx layer-based components.
177
308
  * @default 'start'
178
309
  */
@@ -182,11 +313,19 @@ interface DropdownMenuBaseProps extends BaseProps {
182
313
  }
183
314
 
184
315
  interface DropdownMenuDataProps extends DropdownMenuBaseProps {
316
+ /**
317
+ * Surface used to present the actions. Pass a value selected by product
318
+ * policy (for example, a compact-touch media query) when the presentation
319
+ * should adapt. Bottom-sheet presentation is intended for short action sets.
320
+ * @default 'popover'
321
+ */
322
+ presentation?: DropdownMenuPresentation;
185
323
  items: DropdownMenuOption[];
186
324
  children?: undefined;
187
325
  }
188
326
 
189
327
  interface DropdownMenuCompoundProps extends DropdownMenuBaseProps {
328
+ presentation?: 'popover';
190
329
  items?: undefined;
191
330
  children: ReactNode;
192
331
  }
@@ -202,15 +341,16 @@ export type DropdownMenuProps =
202
341
  * A dropdown menu component that displays a list of actionable items.
203
342
  *
204
343
  * Supports two modes:
205
- * - **Data-driven**: pass `items` for static menus with optional custom rendering
206
- * - **Compound-component**: pass JSX children for dynamic, stateful, or lazy-loaded menus
207
- *
208
- * Both modes share the same DOM-based keyboard navigation via useListFocus.
344
+ * - **Data-driven**: pass `items` and choose either the default anchored
345
+ * popover or the modal bottom-sheet presentation.
346
+ * - **Compound-component**: pass JSX children for dynamic, stateful, or
347
+ * lazy-loaded anchored popover menus.
209
348
  *
210
349
  * @example
211
350
  * ```
212
351
  * <DropdownMenu
213
352
  * button={{ label: 'Actions' }}
353
+ * presentation={useBottomSheet ? 'bottom-sheet' : 'popover'}
214
354
  * items={[
215
355
  * { label: 'Edit', onClick: () => handleEdit() },
216
356
  * { label: 'Delete', onClick: () => handleDelete() },
@@ -223,7 +363,210 @@ export type DropdownMenuProps =
223
363
  // locale.
224
364
  const DEFAULT_BUTTON_I18N_KEY = '@astryx.dropdownMenu.label' as const;
225
365
 
226
- export function DropdownMenu({
366
+ function DropdownMenuBottomSheet({
367
+ button: buttonFromProps,
368
+ isMenuOpen: controlledIsOpen,
369
+ onOpenChange,
370
+ onClick,
371
+ hasChevron = true,
372
+ items,
373
+ presentation: _presentation,
374
+ menuWidth: _menuWidth,
375
+ placement: _placement,
376
+ alignment: _alignment,
377
+ className,
378
+ style,
379
+ xstyle,
380
+ 'data-testid': testId,
381
+ ...rest
382
+ }: DropdownMenuDataProps & {presentation: 'bottom-sheet'}) {
383
+ const t = useTranslator();
384
+ const button = buttonFromProps ?? {label: t(DEFAULT_BUTTON_I18N_KEY)};
385
+ const backLabel = t('@astryx.dropdownMenu.back');
386
+ const buttonRef = useRef<HTMLButtonElement>(null);
387
+ const actionListRef = useRef<HTMLDivElement>(null);
388
+ const openModalityRef = useRef<'keyboard' | 'pointer'>('pointer');
389
+ const {
390
+ isFocusRingSuppressed,
391
+ onFocusReturnTargetFocus,
392
+ prepareFocusReturn,
393
+ resetFocusReturn,
394
+ } = useFocusReturnVisibility();
395
+ const viewHeadingRef = useRef<HTMLHeadingElement>(null);
396
+ const previousSubmenuDepthRef = useRef(0);
397
+ const [internalIsOpen, setInternalIsOpen] = useState(false);
398
+ const [submenuPath, setSubmenuPath] = useState<DropdownMenuItemData[]>([]);
399
+ const isControlled = controlledIsOpen !== undefined;
400
+ const isOpen = isControlled ? controlledIsOpen : internalIsOpen;
401
+ const [previousIsOpen, setPreviousIsOpen] = useState(isOpen);
402
+ if (previousIsOpen !== isOpen) {
403
+ setPreviousIsOpen(isOpen);
404
+ if (!isOpen && submenuPath.length > 0) {
405
+ setSubmenuPath([]);
406
+ }
407
+ }
408
+ const currentSubmenu = submenuPath.at(-1);
409
+ const currentItems = currentSubmenu?.items ?? items;
410
+ const currentTitle = currentSubmenu?.label ?? button.label;
411
+ const sheetLabel =
412
+ typeof currentTitle === 'string' ? currentTitle : button.label;
413
+
414
+ const setOpen = useCallback(
415
+ (nextIsOpen: boolean) => {
416
+ if (!nextIsOpen) {
417
+ setSubmenuPath([]);
418
+ prepareFocusReturn();
419
+ } else {
420
+ resetFocusReturn();
421
+ }
422
+ onOpenChange?.(nextIsOpen);
423
+ if (!isControlled) {
424
+ setInternalIsOpen(nextIsOpen);
425
+ }
426
+ },
427
+ [isControlled, onOpenChange, prepareFocusReturn, resetFocusReturn],
428
+ );
429
+
430
+ const handleSelect = useCallback(
431
+ (item: DropdownMenuItemData) => {
432
+ if (item.isDisabled) {
433
+ return;
434
+ }
435
+ item.onClick?.();
436
+ if (item.hasCloseOnSelect !== false) {
437
+ setOpen(false);
438
+ }
439
+ },
440
+ [setOpen],
441
+ );
442
+
443
+ const isIconOnly = button.isIconOnly === true;
444
+ const resolvedEndContent =
445
+ button.endContent ??
446
+ (hasChevron && !isIconOnly ? (
447
+ <Icon icon="chevronDown" size="sm" color="inherit" />
448
+ ) : undefined);
449
+
450
+ useEffect(() => {
451
+ if (!isOpen || openModalityRef.current !== 'keyboard') {
452
+ return;
453
+ }
454
+ const frame = requestAnimationFrame(() => {
455
+ actionListRef.current
456
+ ?.querySelector<HTMLElement>('button:not(:disabled), a[href]')
457
+ ?.focus({preventScroll: true});
458
+ });
459
+ return () => cancelAnimationFrame(frame);
460
+ }, [currentItems, isOpen]);
461
+
462
+ useEffect(() => {
463
+ if (!isOpen) {
464
+ previousSubmenuDepthRef.current = 0;
465
+ return;
466
+ }
467
+ if (submenuPath.length === previousSubmenuDepthRef.current) {
468
+ return;
469
+ }
470
+ previousSubmenuDepthRef.current = submenuPath.length;
471
+ const frame = requestAnimationFrame(() => {
472
+ viewHeadingRef.current?.focus({preventScroll: true});
473
+ });
474
+ return () => cancelAnimationFrame(frame);
475
+ }, [isOpen, submenuPath.length]);
476
+
477
+ return (
478
+ <>
479
+ <Button
480
+ {...button}
481
+ ref={buttonRef}
482
+ xstyle={[
483
+ isOpen && styles.triggerOpen,
484
+ button.xstyle,
485
+ isFocusRingSuppressed && focusOutlineStyles.suppressed,
486
+ ]}
487
+ tooltip={isOpen ? undefined : button.tooltip}
488
+ endContent={resolvedEndContent}
489
+ onPointerDown={event => {
490
+ button.onPointerDown?.(event);
491
+ openModalityRef.current = 'pointer';
492
+ }}
493
+ onKeyDown={event => {
494
+ button.onKeyDown?.(event);
495
+ if (
496
+ event.key === 'ArrowDown' ||
497
+ event.key === 'Enter' ||
498
+ event.key === ' '
499
+ ) {
500
+ openModalityRef.current = 'keyboard';
501
+ }
502
+ }}
503
+ onFocus={event => {
504
+ button.onFocus?.(event);
505
+ onFocusReturnTargetFocus();
506
+ }}
507
+ onClick={() => {
508
+ onClick?.();
509
+ setOpen(!isOpen);
510
+ }}
511
+ aria-haspopup="dialog"
512
+ aria-expanded={isOpen}
513
+ data-testid={testId}
514
+ />
515
+
516
+ <MenuBottomSheet
517
+ isOpen={isOpen}
518
+ onOpenChange={setOpen}
519
+ finalFocusRef={buttonRef}
520
+ label={sheetLabel}>
521
+ <div
522
+ ref={actionListRef}
523
+ {...rest}
524
+ {...mergeProps(
525
+ themeProps('dropdown-menu', {presentation: 'bottom-sheet'}),
526
+ stylex.props(bottomSheetStyles.content, xstyle),
527
+ className,
528
+ style,
529
+ )}>
530
+ <div {...stylex.props(bottomSheetStyles.header)}>
531
+ {submenuPath.length > 0 && (
532
+ <Button
533
+ label={backLabel}
534
+ variant="ghost"
535
+ size="sm"
536
+ icon={
537
+ <Icon
538
+ icon="chevronLeft"
539
+ size="sm"
540
+ xstyle={rtlStyles.mirror}
541
+ />
542
+ }
543
+ isIconOnly
544
+ onClick={() => setSubmenuPath(path => path.slice(0, -1))}
545
+ />
546
+ )}
547
+ <Heading
548
+ ref={viewHeadingRef}
549
+ level={3}
550
+ tabIndex={-1}
551
+ xstyle={[
552
+ bottomSheetStyles.viewHeading,
553
+ submenuPath.length === 0 && bottomSheetStyles.rootHeading,
554
+ ]}>
555
+ {currentTitle}
556
+ </Heading>
557
+ </div>
558
+ <MenuBottomSheetActionList
559
+ items={currentItems}
560
+ onSelect={handleSelect}
561
+ onOpenSubmenu={item => setSubmenuPath(path => [...path, item])}
562
+ />
563
+ </div>
564
+ </MenuBottomSheet>
565
+ </>
566
+ );
567
+ }
568
+
569
+ function DropdownMenuPopover({
227
570
  button: buttonFromProps,
228
571
  isMenuOpen: controlledIsOpen,
229
572
  onOpenChange,
@@ -232,6 +575,7 @@ export function DropdownMenu({
232
575
  hasChevron = true,
233
576
  placement = 'below',
234
577
  alignment = 'start',
578
+ presentation: _presentation,
235
579
  className,
236
580
  style,
237
581
  xstyle,
@@ -261,13 +605,25 @@ export function DropdownMenu({
261
605
  const isControlled = controlledIsOpen !== undefined;
262
606
  const isOpen = isControlled ? controlledIsOpen : internalIsOpen;
263
607
 
264
- // Close menu + return focus to trigger
608
+ useEffect(() => {
609
+ trackInteractionModality();
610
+ }, []);
611
+
612
+ // Keyboard dismissal returns focus to the trigger. Pointer dismissal leaves
613
+ // focus where the browser put it; Safari can otherwise paint a focus-visible
614
+ // ring on the trigger after a touch selection. Native popover restoration
615
+ // can happen before `toggle`, so explicitly blur that pointer-restored case.
265
616
  const handleLayerHide = useCallback(() => {
266
617
  onOpenChange?.(false);
267
618
  if (!isControlled) {
268
619
  setInternalIsOpen(false);
269
620
  }
270
- buttonRef.current?.focus();
621
+ const trigger = buttonRef.current;
622
+ if (getInteractionModality() === 'keyboard') {
623
+ trigger?.focus();
624
+ } else if (document.activeElement === trigger) {
625
+ trigger?.blur();
626
+ }
271
627
  }, [isControlled, onOpenChange]);
272
628
 
273
629
  // Defer item focus until the layer has committed open, so focus restore
@@ -469,9 +825,21 @@ export function DropdownMenu({
469
825
  <Icon icon="chevronDown" size="sm" color="inherit" />
470
826
  ) : undefined);
471
827
 
472
- const popoverXstyle = menuWidth
473
- ? styles.popoverCustomWidth(menuWidth)
474
- : styles.popover;
828
+ const requestedWidthLimit =
829
+ alignment === 'center'
830
+ ? MENU_MAX_INLINE_SIZE_FALLBACK
831
+ : MENU_POSITION_AREA_MAX_INLINE_SIZE_FALLBACK;
832
+ const resolvedMenuWidth = menuWidth
833
+ ? resolveMenuWidth(menuWidth, requestedWidthLimit)
834
+ : null;
835
+ const popoverXstyle = resolvedMenuWidth
836
+ ? resolvedMenuWidth.property === 'inlineSize'
837
+ ? styles.popoverCustomIntrinsicWidth(resolvedMenuWidth.value)
838
+ : styles.popoverCustomWidth(resolvedMenuWidth.value)
839
+ : alignment === 'center'
840
+ ? styles.popoverCentered
841
+ : styles.popoverAligned;
842
+ const isSidePlacement = placement === 'start' || placement === 'end';
475
843
  // Context for compound items
476
844
  const contextValue = useMemo<DropdownMenuContextValue>(
477
845
  () => ({closeMenu, menuSize}),
@@ -481,6 +849,7 @@ export function DropdownMenu({
481
849
  // Resolve menu content: data-driven items become components
482
850
  const menuContent =
483
851
  props.items !== undefined ? renderDropdownItems(items) : children;
852
+ const hasOverflow = useMenuOverflow(listRef, menuContent, popover.isOpen);
484
853
 
485
854
  return (
486
855
  <>
@@ -498,6 +867,7 @@ export function DropdownMenu({
498
867
  /* eslint-enable react-compiler/react-compiler */
499
868
  }
500
869
  }}
870
+ xstyle={[isOpen && styles.triggerOpen, button.xstyle]}
501
871
  tooltip={isOpen ? undefined : button.tooltip}
502
872
  endContent={resolvedEndContent}
503
873
  onClick={handleButtonClick}
@@ -514,11 +884,11 @@ export function DropdownMenu({
514
884
  ref={listRef}
515
885
  id={menuId}
516
886
  role="menu"
517
- // Focus target for pointer opens (not in the Tab order): holding
518
- // focus on the container keeps key events (arrows, typeahead,
519
- // Escape, Tab) inside the menu without highlighting any item.
520
- // Mirrors the DropdownMenuSubMenu flyout container.
521
- tabIndex={-1}
887
+ // Pointer opens focus the container so arrows, typeahead, Escape and
888
+ // Tab remain owned by the menu without pre-highlighting an item.
889
+ // An overflowing menu joins the Tab order so its scrollable region is
890
+ // keyboard-accessible; Tab still dismisses through listKeyDown.
891
+ tabIndex={hasOverflow ? 0 : -1}
522
892
  // Give the menu an accessible name from its trigger's label, so
523
893
  // screen readers announce e.g. "Actions menu" rather than an unnamed
524
894
  // menu (menus-13).
@@ -526,7 +896,11 @@ export function DropdownMenu({
526
896
  onKeyDown={listKeyDown}
527
897
  {...mergeProps(
528
898
  themeProps('dropdown-menu'),
529
- stylex.props(styles.dropdown, xstyle),
899
+ stylex.props(
900
+ styles.dropdown,
901
+ hasOverflow && styles.scrollable,
902
+ xstyle,
903
+ ),
530
904
  className,
531
905
  style,
532
906
  )}>
@@ -538,11 +912,68 @@ export function DropdownMenu({
538
912
  placement,
539
913
  alignment,
540
914
  offset: spacingVars['--spacing-1'],
541
- xstyle: [popoverXstyle, layerAnimations[placement]],
915
+ xstyle: [
916
+ styles.popoverViewport,
917
+ alignment === 'center'
918
+ ? isSidePlacement
919
+ ? styles.popoverViewportBlockCentered
920
+ : styles.popoverViewportCentered
921
+ : [
922
+ styles.popoverViewportAligned,
923
+ isSidePlacement
924
+ ? alignment === 'start'
925
+ ? styles.popoverViewportBlockStart
926
+ : styles.popoverViewportBlockEnd
927
+ : alignment === 'start'
928
+ ? styles.popoverViewportStart
929
+ : styles.popoverViewportEnd,
930
+ ],
931
+ popoverXstyle,
932
+ layerAnimations[placement],
933
+ ],
542
934
  },
543
935
  )}
544
936
  </>
545
937
  );
546
938
  }
547
939
 
940
+ export function DropdownMenu(props: DropdownMenuProps) {
941
+ const {onOpenChange} = props;
942
+ const requestedPresentation =
943
+ 'items' in props ? (props.presentation ?? 'popover') : 'popover';
944
+ const resolvedPresentation = useAdaptivePresentation(requestedPresentation);
945
+ const isControlled = props.isMenuOpen !== undefined;
946
+ const [internalIsOpen, setInternalIsOpen] = useState(false);
947
+ const isOpen = isControlled ? props.isMenuOpen : internalIsOpen;
948
+ const handleOpenChange = useCallback(
949
+ (nextIsOpen: boolean) => {
950
+ onOpenChange?.(nextIsOpen);
951
+ if (!isControlled) {
952
+ setInternalIsOpen(nextIsOpen);
953
+ }
954
+ },
955
+ [isControlled, onOpenChange],
956
+ );
957
+ const sharedProps = {
958
+ ...props,
959
+ isMenuOpen: isOpen,
960
+ onOpenChange: handleOpenChange,
961
+ };
962
+
963
+ if (
964
+ resolvedPresentation === 'bottom-sheet' &&
965
+ 'items' in props &&
966
+ props.items !== undefined
967
+ ) {
968
+ return (
969
+ <DropdownMenuBottomSheet
970
+ {...(sharedProps as DropdownMenuDataProps)}
971
+ presentation="bottom-sheet"
972
+ />
973
+ );
974
+ }
975
+
976
+ return <DropdownMenuPopover {...sharedProps} presentation="popover" />;
977
+ }
978
+
548
979
  DropdownMenu.displayName = 'DropdownMenu';
@@ -18,7 +18,7 @@ import {createContext, use} from 'react';
18
18
  export type DropdownMenuSize = 'sm' | 'md' | 'lg';
19
19
 
20
20
  export interface DropdownMenuContextValue {
21
- /** Close the menu and return focus to trigger */
21
+ /** Close the menu; keyboard dismissal returns focus to the trigger. */
22
22
  closeMenu: () => void;
23
23
  /** Menu size derived from button size */
24
24
  menuSize: DropdownMenuSize;
@@ -52,6 +52,8 @@ const menuItemStyles = stylex.create({
52
52
  backgroundColor: {
53
53
  default: 'transparent',
54
54
  ':focus': colorVars['--color-overlay-hover'],
55
+ ':active:where(:not(:disabled,[aria-disabled="true"]))':
56
+ colorVars['--color-overlay-pressed'],
55
57
  },
56
58
  border: 'none',
57
59
  cursor: {
@@ -52,7 +52,8 @@ export const docs = {
52
52
  {
53
53
  name: 'menuWidth',
54
54
  type: 'number | string',
55
- description: 'Fixed flyout width. Defaults to sizing to its content (min 160px).',
55
+ description:
56
+ 'Minimum flyout width. The flyout may grow for its content, but it is capped to the available viewport space. Defaults to intrinsic sizing (min 160px).',
56
57
  },
57
58
  {
58
59
  name: 'onOpenChange',
@@ -81,7 +82,8 @@ export const docsDense = {
81
82
  children: 'flyout menu items (same components as top level)',
82
83
  isDisabled: 'renders trigger but never opens',
83
84
  hasSpinner: 'spinner instead of caret for async children',
84
- menuWidth: 'fixed flyout width (default: content, min 160px)',
85
+ menuWidth:
86
+ 'minimum flyout width, capped to the viewport (default: content, min 160px)',
85
87
  onOpenChange: 'called when flyout opens/closes',
86
88
  xstyle: 'StyleX styles for the trigger row',
87
89
  },