@tamagui/create-menu 2.0.0-rc.4 → 2.0.0-rc.40

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 (149) hide show
  1. package/dist/cjs/MenuPredefined.cjs +159 -157
  2. package/dist/cjs/MenuPredefined.native.js +159 -157
  3. package/dist/cjs/MenuPredefined.native.js.map +1 -1
  4. package/dist/cjs/createBaseMenu.cjs +1144 -933
  5. package/dist/cjs/createBaseMenu.native.js +1266 -1100
  6. package/dist/cjs/createBaseMenu.native.js.map +1 -1
  7. package/dist/cjs/createNativeMenu/createNativeMenu.cjs +282 -159
  8. package/dist/cjs/createNativeMenu/createNativeMenu.native.js +390 -268
  9. package/dist/cjs/createNativeMenu/createNativeMenu.native.js.map +1 -1
  10. package/dist/cjs/createNativeMenu/createNativeMenuTypes.cjs +7 -5
  11. package/dist/cjs/createNativeMenu/createNativeMenuTypes.native.js +7 -5
  12. package/dist/cjs/createNativeMenu/createNativeMenuTypes.native.js.map +1 -1
  13. package/dist/cjs/createNativeMenu/utils.cjs +85 -42
  14. package/dist/cjs/createNativeMenu/utils.native.js +83 -58
  15. package/dist/cjs/createNativeMenu/utils.native.js.map +1 -1
  16. package/dist/cjs/createNativeMenu/withNativeMenu.cjs +27 -17
  17. package/dist/cjs/createNativeMenu/withNativeMenu.native.js +22 -14
  18. package/dist/cjs/createNativeMenu/withNativeMenu.native.js.map +1 -1
  19. package/dist/cjs/index.cjs +15 -12
  20. package/dist/cjs/index.native.js +15 -12
  21. package/dist/cjs/index.native.js.map +1 -1
  22. package/dist/esm/MenuPredefined.mjs +144 -144
  23. package/dist/esm/MenuPredefined.mjs.map +1 -1
  24. package/dist/esm/MenuPredefined.native.js +144 -144
  25. package/dist/esm/MenuPredefined.native.js.map +1 -1
  26. package/dist/esm/createBaseMenu.mjs +1104 -895
  27. package/dist/esm/createBaseMenu.mjs.map +1 -1
  28. package/dist/esm/createBaseMenu.native.js +1226 -1062
  29. package/dist/esm/createBaseMenu.native.js.map +1 -1
  30. package/dist/esm/createNativeMenu/createNativeMenu.mjs +255 -134
  31. package/dist/esm/createNativeMenu/createNativeMenu.mjs.map +1 -1
  32. package/dist/esm/createNativeMenu/createNativeMenu.native.js +336 -216
  33. package/dist/esm/createNativeMenu/createNativeMenu.native.js.map +1 -1
  34. package/dist/esm/createNativeMenu/utils.mjs +58 -17
  35. package/dist/esm/createNativeMenu/utils.mjs.map +1 -1
  36. package/dist/esm/createNativeMenu/utils.native.js +57 -34
  37. package/dist/esm/createNativeMenu/utils.native.js.map +1 -1
  38. package/dist/esm/createNativeMenu/withNativeMenu.mjs +13 -5
  39. package/dist/esm/createNativeMenu/withNativeMenu.mjs.map +1 -1
  40. package/dist/esm/createNativeMenu/withNativeMenu.native.js +8 -2
  41. package/dist/esm/createNativeMenu/withNativeMenu.native.js.map +1 -1
  42. package/dist/esm/index.js +5 -6
  43. package/dist/esm/index.js.map +1 -6
  44. package/dist/esm/index.mjs +2 -1
  45. package/dist/esm/index.mjs.map +1 -1
  46. package/dist/esm/index.native.js +2 -1
  47. package/dist/esm/index.native.js.map +1 -1
  48. package/dist/jsx/MenuPredefined.mjs +144 -144
  49. package/dist/jsx/MenuPredefined.mjs.map +1 -1
  50. package/dist/jsx/MenuPredefined.native.js +159 -157
  51. package/dist/jsx/MenuPredefined.native.js.map +1 -1
  52. package/dist/jsx/createBaseMenu.mjs +1104 -895
  53. package/dist/jsx/createBaseMenu.mjs.map +1 -1
  54. package/dist/jsx/createBaseMenu.native.js +1266 -1100
  55. package/dist/jsx/createBaseMenu.native.js.map +1 -1
  56. package/dist/jsx/createNativeMenu/createNativeMenu.mjs +255 -134
  57. package/dist/jsx/createNativeMenu/createNativeMenu.mjs.map +1 -1
  58. package/dist/jsx/createNativeMenu/createNativeMenu.native.js +390 -268
  59. package/dist/jsx/createNativeMenu/createNativeMenu.native.js.map +1 -1
  60. package/dist/jsx/createNativeMenu/createNativeMenuTypes.native.js +7 -5
  61. package/dist/jsx/createNativeMenu/utils.mjs +58 -17
  62. package/dist/jsx/createNativeMenu/utils.mjs.map +1 -1
  63. package/dist/jsx/createNativeMenu/utils.native.js +83 -58
  64. package/dist/jsx/createNativeMenu/utils.native.js.map +1 -1
  65. package/dist/jsx/createNativeMenu/withNativeMenu.mjs +13 -5
  66. package/dist/jsx/createNativeMenu/withNativeMenu.mjs.map +1 -1
  67. package/dist/jsx/createNativeMenu/withNativeMenu.native.js +22 -14
  68. package/dist/jsx/createNativeMenu/withNativeMenu.native.js.map +1 -1
  69. package/dist/jsx/index.js +5 -6
  70. package/dist/jsx/index.js.map +1 -6
  71. package/dist/jsx/index.mjs +2 -1
  72. package/dist/jsx/index.mjs.map +1 -1
  73. package/dist/jsx/index.native.js +15 -12
  74. package/dist/jsx/index.native.js.map +1 -1
  75. package/package.json +25 -27
  76. package/src/MenuPredefined.tsx +1 -1
  77. package/src/createBaseMenu.tsx +359 -271
  78. package/src/createNativeMenu/createNativeMenu.tsx +383 -222
  79. package/src/createNativeMenu/createNativeMenuTypes.ts +20 -20
  80. package/src/createNativeMenu/withNativeMenu.tsx +5 -3
  81. package/src/index.tsx +3 -5
  82. package/types/createBaseMenu.d.ts +121 -35
  83. package/types/createBaseMenu.d.ts.map +1 -1
  84. package/types/createNativeMenu/createNativeMenu.d.ts +21 -21
  85. package/types/createNativeMenu/createNativeMenu.d.ts.map +1 -1
  86. package/types/createNativeMenu/createNativeMenuTypes.d.ts +20 -20
  87. package/types/createNativeMenu/createNativeMenuTypes.d.ts.map +1 -1
  88. package/types/createNativeMenu/withNativeMenu.d.ts +3 -3
  89. package/types/createNativeMenu/withNativeMenu.d.ts.map +1 -1
  90. package/types/index.d.ts +3 -2
  91. package/types/index.d.ts.map +1 -1
  92. package/dist/cjs/MenuPredefined.js +0 -168
  93. package/dist/cjs/MenuPredefined.js.map +0 -6
  94. package/dist/cjs/createBaseMenu.js +0 -832
  95. package/dist/cjs/createBaseMenu.js.map +0 -6
  96. package/dist/cjs/createNativeMenu/createNativeMenu.js +0 -177
  97. package/dist/cjs/createNativeMenu/createNativeMenu.js.map +0 -6
  98. package/dist/cjs/createNativeMenu/createNativeMenuTypes.js +0 -14
  99. package/dist/cjs/createNativeMenu/createNativeMenuTypes.js.map +0 -6
  100. package/dist/cjs/createNativeMenu/index.cjs +0 -19
  101. package/dist/cjs/createNativeMenu/index.js +0 -16
  102. package/dist/cjs/createNativeMenu/index.js.map +0 -6
  103. package/dist/cjs/createNativeMenu/index.native.js +0 -22
  104. package/dist/cjs/createNativeMenu/index.native.js.map +0 -1
  105. package/dist/cjs/createNativeMenu/utils.js +0 -66
  106. package/dist/cjs/createNativeMenu/utils.js.map +0 -6
  107. package/dist/cjs/createNativeMenu/withNativeMenu.js +0 -30
  108. package/dist/cjs/createNativeMenu/withNativeMenu.js.map +0 -6
  109. package/dist/cjs/index.js +0 -23
  110. package/dist/cjs/index.js.map +0 -6
  111. package/dist/esm/MenuPredefined.js +0 -154
  112. package/dist/esm/MenuPredefined.js.map +0 -6
  113. package/dist/esm/createBaseMenu.js +0 -838
  114. package/dist/esm/createBaseMenu.js.map +0 -6
  115. package/dist/esm/createNativeMenu/createNativeMenu.js +0 -156
  116. package/dist/esm/createNativeMenu/createNativeMenu.js.map +0 -6
  117. package/dist/esm/createNativeMenu/createNativeMenuTypes.js +0 -1
  118. package/dist/esm/createNativeMenu/createNativeMenuTypes.js.map +0 -6
  119. package/dist/esm/createNativeMenu/index.js +0 -3
  120. package/dist/esm/createNativeMenu/index.js.map +0 -6
  121. package/dist/esm/createNativeMenu/index.mjs +0 -3
  122. package/dist/esm/createNativeMenu/index.mjs.map +0 -1
  123. package/dist/esm/createNativeMenu/index.native.js +0 -3
  124. package/dist/esm/createNativeMenu/index.native.js.map +0 -1
  125. package/dist/esm/createNativeMenu/utils.js +0 -47
  126. package/dist/esm/createNativeMenu/utils.js.map +0 -6
  127. package/dist/esm/createNativeMenu/withNativeMenu.js +0 -15
  128. package/dist/esm/createNativeMenu/withNativeMenu.js.map +0 -6
  129. package/dist/jsx/MenuPredefined.js +0 -154
  130. package/dist/jsx/MenuPredefined.js.map +0 -6
  131. package/dist/jsx/createBaseMenu.js +0 -838
  132. package/dist/jsx/createBaseMenu.js.map +0 -6
  133. package/dist/jsx/createNativeMenu/createNativeMenu.js +0 -156
  134. package/dist/jsx/createNativeMenu/createNativeMenu.js.map +0 -6
  135. package/dist/jsx/createNativeMenu/createNativeMenuTypes.js +0 -1
  136. package/dist/jsx/createNativeMenu/createNativeMenuTypes.js.map +0 -6
  137. package/dist/jsx/createNativeMenu/index.js +0 -3
  138. package/dist/jsx/createNativeMenu/index.js.map +0 -6
  139. package/dist/jsx/createNativeMenu/index.mjs +0 -3
  140. package/dist/jsx/createNativeMenu/index.mjs.map +0 -1
  141. package/dist/jsx/createNativeMenu/index.native.js +0 -22
  142. package/dist/jsx/createNativeMenu/index.native.js.map +0 -1
  143. package/dist/jsx/createNativeMenu/utils.js +0 -47
  144. package/dist/jsx/createNativeMenu/utils.js.map +0 -6
  145. package/dist/jsx/createNativeMenu/withNativeMenu.js +0 -15
  146. package/dist/jsx/createNativeMenu/withNativeMenu.js.map +0 -6
  147. package/src/createNativeMenu/index.tsx +0 -7
  148. package/types/createNativeMenu/index.d.ts +0 -4
  149. package/types/createNativeMenu/index.d.ts.map +0 -1
@@ -15,16 +15,18 @@ import type { RovingFocusGroupProps } from '@tamagui/roving-focus'
15
15
  import { RovingFocusGroup } from '@tamagui/roving-focus'
16
16
  import { useCallbackRef } from '@tamagui/use-callback-ref'
17
17
  import { useDirection } from '@tamagui/use-direction'
18
- import type { TamaguiComponent, TextProps } from '@tamagui/web'
18
+ import type { TextProps } from '@tamagui/web'
19
19
  import {
20
20
  type ViewProps,
21
21
  composeEventHandlers,
22
22
  composeRefs,
23
23
  createStyledContext,
24
24
  isWeb,
25
+ styled,
25
26
  Text,
26
27
  Theme,
27
28
  useComposedRefs,
29
+ useIsTouchDevice,
28
30
  useThemeName,
29
31
  View,
30
32
  withStaticProperties,
@@ -105,7 +107,7 @@ interface MenuAnchorProps extends PopperAnchorProps {}
105
107
  * MenuPortal
106
108
  * -----------------------------------------------------------------------------------------------*/
107
109
 
108
- type PortalContextValue = { forceMount?: true }
110
+ type PortalContextValue = { forceMount?: boolean }
109
111
 
110
112
  interface MenuPortalProps {
111
113
  children?: React.ReactNode
@@ -113,7 +115,7 @@ interface MenuPortalProps {
113
115
  * Used to force mounting when more control is needed. Useful when
114
116
  * controlling animation with React animation libraries.
115
117
  */
116
- forceMount?: true
118
+ forceMount?: boolean
117
119
  zIndex?: number
118
120
  }
119
121
 
@@ -141,7 +143,7 @@ interface MenuContentProps extends MenuRootContentTypeProps {
141
143
  * Used to force mounting when more control is needed. Useful when
142
144
  * controlling animation with React animation libraries.
143
145
  */
144
- forceMount?: true
146
+ forceMount?: boolean
145
147
  }
146
148
 
147
149
  /* ---------------------------------------------------------------------------------------------- */
@@ -215,6 +217,11 @@ type MenuItemElement = MenuItemImplElement
215
217
  interface MenuItemProps extends Omit<MenuItemImplProps, 'onSelect'> {
216
218
  onSelect?: (event: Event) => void
217
219
  unstyled?: boolean
220
+ /**
221
+ * Prevents the menu from closing when this item is selected.
222
+ * Useful for toggle items or multi-select scenarios.
223
+ */
224
+ preventCloseOnSelect?: boolean
218
225
  }
219
226
 
220
227
  type MenuItemImplElement = TamaguiElement
@@ -285,7 +292,7 @@ interface MenuItemIndicatorProps extends PrimitiveSpanProps {
285
292
  * Used to force mounting when more control is needed. Useful when
286
293
  * controlling animation with React animation libraries.
287
294
  */
288
- forceMount?: true
295
+ forceMount?: boolean
289
296
  }
290
297
 
291
298
  /* -------------------------------------------------------------------------------------------------
@@ -346,7 +353,7 @@ export interface MenuSubContentProps extends Omit<
346
353
  * Used to force mounting when more control is needed. Useful when
347
354
  * controlling animation with React animation libraries.
348
355
  */
349
- forceMount?: true
356
+ forceMount?: boolean
350
357
  }
351
358
 
352
359
  type Point = { x: number; y: number }
@@ -369,15 +376,15 @@ const { Provider: MenuRootProvider, useStyledContext: useMenuRootContext } =
369
376
  const MENU_CONTEXT = 'MenuContext'
370
377
 
371
378
  export type CreateBaseMenuProps = {
372
- Item?: TamaguiComponent
373
- MenuGroup?: TamaguiComponent
374
- Title?: TamaguiComponent
375
- SubTitle?: TamaguiComponent
379
+ Item?: typeof MenuPredefined.MenuItem
380
+ MenuGroup?: typeof MenuPredefined.MenuGroup
381
+ Title?: typeof MenuPredefined.Title
382
+ SubTitle?: typeof MenuPredefined.SubTitle
376
383
  Image?: React.ElementType
377
- Icon?: TamaguiComponent
378
- Indicator?: TamaguiComponent
379
- Separator?: TamaguiComponent
380
- Label?: TamaguiComponent
384
+ Icon?: typeof MenuPredefined.MenuIcon
385
+ Indicator?: typeof MenuPredefined.MenuIndicator
386
+ Separator?: typeof MenuPredefined.MenuSeparator
387
+ Label?: typeof MenuPredefined.MenuLabel
381
388
  }
382
389
 
383
390
  export function createBaseMenu({
@@ -392,6 +399,9 @@ export function createBaseMenu({
392
399
  Label: _Label = MenuPredefined.MenuLabel,
393
400
  }: CreateBaseMenuProps) {
394
401
  const MenuComp = (props: ScopedProps<MenuBaseProps>) => {
402
+ const direction = useDirection(props.dir)
403
+ // default placement: bottom-start for LTR, bottom-end for RTL
404
+ const defaultPlacement = direction === 'rtl' ? 'bottom-end' : 'bottom-start'
395
405
  const {
396
406
  scope = MENU_CONTEXT,
397
407
  open = false,
@@ -399,12 +409,16 @@ export function createBaseMenu({
399
409
  dir,
400
410
  onOpenChange,
401
411
  modal = true,
412
+ allowFlip = { padding: 10 },
413
+ stayInFrame = { padding: 10 },
414
+ placement = defaultPlacement,
415
+ resize = true,
416
+ offset = 10,
402
417
  ...rest
403
418
  } = props
404
419
  const [content, setContent] = React.useState<MenuContentElement | null>(null)
405
420
  const isUsingKeyboardRef = React.useRef(false)
406
421
  const handleOpenChange = useCallbackRef(onOpenChange)
407
- const direction = useDirection(dir)
408
422
 
409
423
  if (isWeb) {
410
424
  React.useEffect(() => {
@@ -433,7 +447,16 @@ export function createBaseMenu({
433
447
  }
434
448
 
435
449
  return (
436
- <PopperPrimitive.Popper scope={scope} {...rest}>
450
+ <PopperPrimitive.Popper
451
+ scope={scope}
452
+ open={open}
453
+ placement={placement}
454
+ allowFlip={allowFlip}
455
+ stayInFrame={stayInFrame}
456
+ resize={resize}
457
+ offset={offset}
458
+ {...rest}
459
+ >
437
460
  <MenuProvider
438
461
  scope={scope}
439
462
  open={open}
@@ -549,7 +572,7 @@ export function createBaseMenu({
549
572
 
550
573
  return (
551
574
  <Animate type="presence" present={isPresent}>
552
- <PortalPrimitive>
575
+ <PortalPrimitive stackZIndex>
553
576
  <>
554
577
  <PortalProvider scope={scope} forceMount={forceMount}>
555
578
  <View zIndex={zIndex || 100} inset={0} position="absolute">
@@ -580,7 +603,11 @@ export function createBaseMenu({
580
603
  const { Provider: MenuContentProvider, useStyledContext: useMenuContentContext } =
581
604
  createStyledContext<MenuContentContextValue>()
582
605
 
583
- const MenuContent = React.forwardRef<MenuContentElement, ScopedProps<MenuContentProps>>(
606
+ const MenuContentFrame = styled(PopperPrimitive.PopperContentFrame, {
607
+ name: CONTENT_NAME,
608
+ })
609
+
610
+ const MenuContent = MenuContentFrame.styleable<ScopedProps<MenuContentProps>>(
584
611
  (props, forwardedRef) => {
585
612
  const scope = props.scope || MENU_CONTEXT
586
613
  const portalContext = usePortalContext(scope)
@@ -690,6 +717,9 @@ export function createBaseMenu({
690
717
  const getItems = useCollection(scope)
691
718
  const [currentItemId, setCurrentItemId] = React.useState<string | null>(null)
692
719
  const contentRef = React.useRef<TamaguiElement>(null)
720
+ // the actual focusable content element (PopperContentFrame) differs from contentRef
721
+ // due to PopperContent's wrapper structure, so we capture it on mount
722
+ const focusableContentRef = React.useRef<HTMLElement | null>(null)
693
723
  const composedRefs = useComposedRefs(
694
724
  forwardedRef,
695
725
  contentRef,
@@ -733,10 +763,28 @@ export function createBaseMenu({
733
763
  return () => clearTimeout(timerRef.current)
734
764
  }, [])
735
765
 
736
- // dismiss on scroll (web only)
766
+ // capture the actual focusable content element on mount
767
+ // PopperContent has a wrapper structure where the ref points to the outer
768
+ // TamaguiView but props like tabIndex go to the inner PopperContentFrame
769
+ React.useEffect(() => {
770
+ if (!isWeb || !context.open) return
771
+ // use requestAnimationFrame to ensure DOM is ready
772
+ const frame = requestAnimationFrame(() => {
773
+ // scope the query to within this menu's content to avoid grabbing a submenu's element
774
+ const container = contentRef.current as unknown as HTMLElement
775
+ const el = container?.querySelector('[data-tamagui-menu-content]') as HTMLElement
776
+ if (el) focusableContentRef.current = el
777
+ })
778
+ return () => cancelAnimationFrame(frame)
779
+ }, [context.open])
780
+
781
+ // dismiss on scroll (web only) - but not when scrolling inside the menu
737
782
  React.useEffect(() => {
738
783
  if (!isWeb || disableDismissOnScroll || !context.open) return
739
- const handleScroll = () => {
784
+ const handleScroll = (event: Event) => {
785
+ // don't dismiss if scrolling inside the menu content
786
+ const target = event.target as HTMLElement
787
+ if ((contentRef.current as unknown as HTMLElement)?.contains(target)) return
740
788
  onDismiss?.()
741
789
  }
742
790
  window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
@@ -761,8 +809,11 @@ export function createBaseMenu({
761
809
  const content = (
762
810
  <PopperPrimitive.PopperContent
763
811
  role="menu"
812
+ // tabIndex allows the content to be focusable so that onItemLeave can
813
+ // focus the content frame and properly blur the previously focused item
814
+ tabIndex={-1}
815
+ unstyled={unstyled}
764
816
  {...(!unstyled && {
765
- padding: 4,
766
817
  backgroundColor: '$background',
767
818
  borderWidth: 1,
768
819
  borderColor: '$borderColor',
@@ -851,7 +902,7 @@ export function createBaseMenu({
851
902
  onItemLeave={React.useCallback(
852
903
  (event) => {
853
904
  if (isPointerMovingToSubmenu(event)) return
854
- contentRef.current?.focus()
905
+ focusableContentRef.current?.focus()
855
906
  setCurrentItemId(null)
856
907
  },
857
908
  [isPointerMovingToSubmenu]
@@ -881,7 +932,7 @@ export function createBaseMenu({
881
932
  const content = document.querySelector(
882
933
  '[data-tamagui-menu-content]'
883
934
  ) as HTMLElement | null
884
- content?.focus()
935
+ content?.focus({ preventScroll: true })
885
936
  })}
886
937
  onUnmountAutoFocus={onCloseAutoFocus}
887
938
  >
@@ -928,106 +979,109 @@ export function createBaseMenu({
928
979
  const ITEM_NAME = 'MenuItem'
929
980
  const ITEM_SELECT = 'menu.itemSelect'
930
981
 
931
- const MenuItem = React.forwardRef<TamaguiElement, ScopedProps<MenuItemProps>>(
932
- (props, forwardedRef) => {
933
- const {
934
- disabled = false,
935
- onSelect,
936
- children,
937
- scope = MENU_CONTEXT,
938
- // filter out native-only props that shouldn't reach the DOM
939
- // @ts-ignore
940
- destructive,
941
- // @ts-ignore
942
- hidden,
943
- // @ts-ignore
944
- androidIconName,
945
- // @ts-ignore
946
- iosIconName,
947
- ...itemProps
948
- } = props
949
- const ref = React.useRef<TamaguiElement>(null)
950
- const rootContext = useMenuRootContext(scope)
951
- const contentContext = useMenuContentContext(scope)
952
- const composedRefs = useComposedRefs(forwardedRef, ref)
953
- const isPointerDownRef = React.useRef(false)
954
-
955
- const handleSelect = () => {
956
- const menuItem = ref.current
957
- if (!disabled && menuItem) {
958
- if (isWeb) {
959
- const menuItemEl = menuItem as HTMLElement
960
- const itemSelectEvent = new CustomEvent(ITEM_SELECT, {
961
- bubbles: true,
962
- cancelable: true,
963
- })
964
- menuItemEl.addEventListener(ITEM_SELECT, (event) => onSelect?.(event), {
965
- once: true,
966
- })
967
- dispatchDiscreteCustomEvent(menuItemEl, itemSelectEvent)
968
- if (itemSelectEvent.defaultPrevented) {
969
- isPointerDownRef.current = false
970
- } else {
971
- rootContext.onClose()
972
- }
973
- } else {
974
- // TODO: find a better way to handle this on native
975
- onSelect?.({ target: menuItem } as unknown as Event)
982
+ // use styleable so styled(Menu.Item, { focusStyle }) passes pseudo styles through correctly
983
+ const MenuItem = _Item.styleable<ScopedProps<MenuItemProps>>((props, forwardedRef) => {
984
+ const {
985
+ disabled = false,
986
+ onSelect,
987
+ preventCloseOnSelect,
988
+ children,
989
+ scope = MENU_CONTEXT,
990
+ // filter out native-only props that shouldn't reach the DOM
991
+ // @ts-ignore
992
+ destructive,
993
+ // @ts-ignore
994
+ hidden,
995
+ // @ts-ignore
996
+ androidIconName,
997
+ // @ts-ignore
998
+ iosIconName,
999
+ ...itemProps
1000
+ } = props
1001
+ const ref = React.useRef<TamaguiElement>(null)
1002
+ const rootContext = useMenuRootContext(scope)
1003
+ const contentContext = useMenuContentContext(scope)
1004
+ const composedRefs = useComposedRefs(forwardedRef, ref)
1005
+ const isPointerDownRef = React.useRef(false)
1006
+
1007
+ const handleSelect = () => {
1008
+ const menuItem = ref.current
1009
+ if (!disabled && menuItem) {
1010
+ if (isWeb) {
1011
+ const menuItemEl = menuItem as HTMLElement
1012
+ const itemSelectEvent = new CustomEvent(ITEM_SELECT, {
1013
+ bubbles: true,
1014
+ cancelable: true,
1015
+ })
1016
+ menuItemEl.addEventListener(ITEM_SELECT, (event) => onSelect?.(event), {
1017
+ once: true,
1018
+ })
1019
+ dispatchDiscreteCustomEvent(menuItemEl, itemSelectEvent)
1020
+ // close the menu unless preventCloseOnSelect is set or event.preventDefault() was called
1021
+ if (itemSelectEvent.defaultPrevented || preventCloseOnSelect) {
976
1022
  isPointerDownRef.current = false
1023
+ } else {
1024
+ rootContext.onClose()
1025
+ }
1026
+ } else {
1027
+ // TODO: find a better way to handle this on native
1028
+ onSelect?.({ target: menuItem } as unknown as Event)
1029
+ isPointerDownRef.current = false
1030
+ if (!preventCloseOnSelect) {
977
1031
  rootContext.onClose()
978
1032
  }
979
1033
  }
980
1034
  }
1035
+ }
981
1036
 
982
- const content = typeof children === 'string' ? <Text>{children}</Text> : children
1037
+ const content = typeof children === 'string' ? <Text>{children}</Text> : children
983
1038
 
984
- return (
985
- <MenuItemImpl
986
- outlineStyle="none"
987
- {...itemProps}
988
- scope={scope}
989
- // @ts-ignore
990
- ref={composedRefs}
991
- disabled={disabled}
992
- onPress={composeEventHandlers(props.onPress, handleSelect)}
993
- onPointerDown={(event) => {
994
- props.onPointerDown?.(event)
995
- isPointerDownRef.current = true
996
- }}
997
- onPointerUp={composeEventHandlers(props.onPointerUp, (event) => {
998
- // Pointer down can move to a different menu item which should activate it on pointer up.
999
- // We dispatch a click for selection to allow composition with click based triggers and to
1000
- // prevent Firefox from getting stuck in text selection mode when the menu closes.
1001
- if (isWeb) {
1002
- // @ts-ignore
1003
- if (!isPointerDownRef.current) event.currentTarget?.click()
1039
+ return (
1040
+ <MenuItemImpl
1041
+ outlineStyle="none"
1042
+ {...itemProps}
1043
+ scope={scope}
1044
+ // @ts-ignore
1045
+ ref={composedRefs}
1046
+ disabled={disabled}
1047
+ onPress={composeEventHandlers(props.onPress, handleSelect)}
1048
+ onPointerDown={(event) => {
1049
+ props.onPointerDown?.(event)
1050
+ isPointerDownRef.current = true
1051
+ }}
1052
+ onPointerUp={composeEventHandlers(props.onPointerUp, (event) => {
1053
+ // Pointer down can move to a different menu item which should activate it on pointer up.
1054
+ // We dispatch a click for selection to allow composition with click based triggers and to
1055
+ // prevent Firefox from getting stuck in text selection mode when the menu closes.
1056
+ if (isWeb) {
1057
+ // @ts-ignore
1058
+ if (!isPointerDownRef.current) event.currentTarget?.click()
1059
+ }
1060
+ })}
1061
+ {...(isWeb
1062
+ ? {
1063
+ onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
1064
+ const isTypingAhead = contentContext.searchRef.current !== ''
1065
+ if (disabled || (isTypingAhead && event.key === ' ')) return
1066
+ if (SELECTION_KEYS.includes(event.key)) {
1067
+ // @ts-ignore
1068
+ event.currentTarget?.click()
1069
+ /**
1070
+ * We prevent default browser behaviour for selection keys as they should trigger
1071
+ * a selection only:
1072
+ * - prevents space from scrolling the page.
1073
+ * - if keydown causes focus to move, prevents keydown from firing on the new target.
1074
+ */
1075
+ event.preventDefault()
1076
+ }
1077
+ }),
1004
1078
  }
1005
- })}
1006
- {...(isWeb
1007
- ? {
1008
- onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
1009
- const isTypingAhead = contentContext.searchRef.current !== ''
1010
- if (disabled || (isTypingAhead && event.key === ' ')) return
1011
- if (SELECTION_KEYS.includes(event.key)) {
1012
- // @ts-ignore
1013
- event.currentTarget?.click()
1014
- /**
1015
- * We prevent default browser behaviour for selection keys as they should trigger
1016
- * a selection only:
1017
- * - prevents space from scrolling the page.
1018
- * - if keydown causes focus to move, prevents keydown from firing on the new target.
1019
- */
1020
- event.preventDefault()
1021
- }
1022
- }),
1023
- }
1024
- : {})}
1025
- >
1026
- {content}
1027
- </MenuItemImpl>
1028
- )
1029
- }
1030
- )
1079
+ : {})}
1080
+ >
1081
+ {content}
1082
+ </MenuItemImpl>
1083
+ )
1084
+ })
1031
1085
 
1032
1086
  const MenuItemImpl = React.forwardRef<
1033
1087
  MenuItemImplElement,
@@ -1067,13 +1121,9 @@ export function createBaseMenu({
1067
1121
  asChild
1068
1122
  __scopeRovingFocusGroup={scope}
1069
1123
  focusable={!disabled}
1070
- {...(!unstyled && {
1071
- flexDirection: 'row',
1072
- alignItems: 'center',
1073
- })}
1074
- {...itemProps}
1075
1124
  >
1076
1125
  <_Item
1126
+ unstyled={unstyled}
1077
1127
  componentName={ITEM_NAME}
1078
1128
  role="menuitem"
1079
1129
  data-highlighted={isFocused ? '' : undefined}
@@ -1199,40 +1249,39 @@ export function createBaseMenu({
1199
1249
 
1200
1250
  const CHECKBOX_ITEM_NAME = 'MenuCheckboxItem'
1201
1251
 
1202
- const MenuCheckboxItem = React.forwardRef<
1203
- TamaguiElement,
1204
- ScopedProps<MenuCheckboxItemProps>
1205
- >((props, forwardedRef) => {
1206
- const {
1207
- checked = false,
1208
- onCheckedChange,
1209
- scope = MENU_CONTEXT,
1210
- // filter out native-only props
1211
- // @ts-ignore - native menu value state
1212
- value,
1213
- // @ts-ignore - native menu value change handler
1214
- onValueChange,
1215
- ...checkboxItemProps
1216
- } = props
1217
- return (
1218
- <ItemIndicatorProvider scope={scope} checked={checked}>
1219
- <MenuItem
1220
- componentName={CHECKBOX_ITEM_NAME}
1221
- role={(isWeb ? 'menuitemcheckbox' : 'menuitem') as 'menuitem'}
1222
- aria-checked={isIndeterminate(checked) ? 'mixed' : checked}
1223
- {...checkboxItemProps}
1224
- scope={scope}
1225
- ref={forwardedRef}
1226
- data-state={getCheckedState(checked)}
1227
- onSelect={composeEventHandlers(
1228
- checkboxItemProps.onSelect,
1229
- () => onCheckedChange?.(isIndeterminate(checked) ? true : !checked),
1230
- { checkDefaultPrevented: false }
1231
- )}
1232
- />
1233
- </ItemIndicatorProvider>
1234
- )
1235
- })
1252
+ const MenuCheckboxItem = _Item.styleable<ScopedProps<MenuCheckboxItemProps>>(
1253
+ (props, forwardedRef) => {
1254
+ const {
1255
+ checked = false,
1256
+ onCheckedChange,
1257
+ scope = MENU_CONTEXT,
1258
+ // filter out native-only props
1259
+ // @ts-ignore - native menu value state
1260
+ value,
1261
+ // @ts-ignore - native menu value change handler
1262
+ onValueChange,
1263
+ ...checkboxItemProps
1264
+ } = props
1265
+ return (
1266
+ <ItemIndicatorProvider scope={scope} checked={checked}>
1267
+ <MenuItem
1268
+ componentName={CHECKBOX_ITEM_NAME}
1269
+ role={(isWeb ? 'menuitemcheckbox' : 'menuitem') as 'menuitem'}
1270
+ aria-checked={isIndeterminate(checked) ? 'mixed' : checked}
1271
+ {...checkboxItemProps}
1272
+ scope={scope}
1273
+ ref={forwardedRef}
1274
+ data-state={getCheckedState(checked)}
1275
+ onSelect={composeEventHandlers(
1276
+ checkboxItemProps.onSelect,
1277
+ () => onCheckedChange?.(isIndeterminate(checked) ? true : !checked),
1278
+ { checkDefaultPrevented: false }
1279
+ )}
1280
+ />
1281
+ </ItemIndicatorProvider>
1282
+ )
1283
+ }
1284
+ )
1236
1285
 
1237
1286
  MenuCheckboxItem.displayName = CHECKBOX_ITEM_NAME
1238
1287
  /* -------------------------------------------------------------------------------------------------
@@ -1267,7 +1316,7 @@ export function createBaseMenu({
1267
1316
 
1268
1317
  const RADIO_ITEM_NAME = 'MenuRadioItem'
1269
1318
 
1270
- const MenuRadioItem = React.forwardRef<TamaguiElement, ScopedProps<MenuRadioItemProps>>(
1319
+ const MenuRadioItem = _Item.styleable<ScopedProps<MenuRadioItemProps>>(
1271
1320
  (props, forwardedRef) => {
1272
1321
  const { value, scope = MENU_CONTEXT, ...radioItemProps } = props
1273
1322
  const context = useRadioGroupContext(scope)
@@ -1344,6 +1393,7 @@ export function createBaseMenu({
1344
1393
  <PopperPrimitive.PopperArrow
1345
1394
  scope={scope}
1346
1395
  componentName="PopperArrow"
1396
+ unstyled={unstyled}
1347
1397
  {...(!unstyled && {
1348
1398
  backgroundColor: '$background',
1349
1399
  })}
@@ -1364,15 +1414,58 @@ export function createBaseMenu({
1364
1414
  createStyledContext<MenuSubContextValue>()
1365
1415
 
1366
1416
  const MenuSub: React.FC<ScopedProps<MenuSubProps>> = (props) => {
1417
+ const isTouchDevice = useIsTouchDevice()
1418
+ const { scope = MENU_CONTEXT } = props
1419
+ const rootContext = useMenuRootContext(scope)
1420
+
1421
+ // detect if this sub is nested inside another sub that opened to a side
1422
+ const parentPopperContext = PopperPrimitive.usePopperContext(scope)
1423
+ const parentSide = parentPopperContext.placement?.split('-')[0]
1424
+ const isNestedSubmenu = parentSide === 'left' || parentSide === 'right'
1425
+
1426
+ // nested submenus inherit the parent's direction so they cascade
1427
+ // rather than flipping back on top of the grandparent
1428
+ const defaultPlacement = isTouchDevice
1429
+ ? 'bottom'
1430
+ : isNestedSubmenu
1431
+ ? (`${parentSide}-start` as any)
1432
+ : rootContext.dir === 'rtl'
1433
+ ? 'left-start'
1434
+ : 'right-start'
1367
1435
  const {
1368
- scope = MENU_CONTEXT,
1369
1436
  children,
1370
1437
  open = false,
1371
1438
  onOpenChange,
1372
- allowFlip = { padding: 10 },
1439
+ allowFlip: allowFlipProp = { padding: 10 },
1373
1440
  stayInFrame = { padding: 10 },
1441
+ placement = defaultPlacement,
1374
1442
  ...rest
1375
1443
  } = props
1444
+
1445
+ // for nested submenus, flip to opposite side (never top/bottom which overlap parent)
1446
+ const allowFlip = React.useMemo(() => {
1447
+ if (!isNestedSubmenu || typeof allowFlipProp === 'boolean') return allowFlipProp
1448
+ if ((allowFlipProp as any).fallbackPlacements) return allowFlipProp
1449
+
1450
+ const side = placement.split('-')[0]
1451
+ const align = placement.split('-')[1] || 'start'
1452
+ const otherAlign = align === 'start' ? 'end' : 'start'
1453
+
1454
+ if (side === 'left' || side === 'right') {
1455
+ const oppositeSide = side === 'right' ? 'left' : 'right'
1456
+ return {
1457
+ ...(typeof allowFlipProp === 'object' ? allowFlipProp : {}),
1458
+ fallbackPlacements: [
1459
+ `${side}-${otherAlign}`,
1460
+ `${oppositeSide}-${align}`,
1461
+ `${oppositeSide}-${otherAlign}`,
1462
+ ] as any,
1463
+ }
1464
+ }
1465
+
1466
+ return allowFlipProp
1467
+ }, [isNestedSubmenu, allowFlipProp, placement])
1468
+
1376
1469
  const parentMenuContext = useMenuContext(scope)
1377
1470
  const [trigger, setTrigger] = React.useState<MenuSubTriggerElement | null>(null)
1378
1471
  const [content, setContent] = React.useState<MenuContentElement | null>(null)
@@ -1386,6 +1479,8 @@ export function createBaseMenu({
1386
1479
 
1387
1480
  return (
1388
1481
  <PopperPrimitive.Popper
1482
+ open={open}
1483
+ placement={placement}
1389
1484
  allowFlip={allowFlip}
1390
1485
  stayInFrame={stayInFrame}
1391
1486
  {...rest}
@@ -1432,15 +1527,9 @@ export function createBaseMenu({
1432
1527
  const openTimerRef = React.useRef<number | null>(null)
1433
1528
  const { pointerGraceTimerRef, onPointerGraceIntentChange } = contentContext
1434
1529
 
1435
- // determine effective direction for keyboard navigation based on placement
1436
- // if submenu opens to the left, arrow keys should be flipped
1437
- const placementSide = popperContext.placement?.split('-')[0]
1438
- const effectiveDir: Direction =
1439
- placementSide === 'left'
1440
- ? 'rtl'
1441
- : placementSide === 'right'
1442
- ? 'ltr'
1443
- : rootContext.dir
1530
+ // keyboard navigation follows text direction, not placement side
1531
+ // ArrowRight always opens, ArrowLeft always closes (flipped only for RTL)
1532
+ const effectiveDir: Direction = rootContext.dir
1444
1533
 
1445
1534
  const clearOpenTimer = React.useCallback(() => {
1446
1535
  if (openTimerRef.current) window.clearTimeout(openTimerRef.current)
@@ -1666,114 +1755,113 @@ export function createBaseMenu({
1666
1755
 
1667
1756
  const SUB_CONTENT_NAME = 'MenuSubContent'
1668
1757
 
1669
- const MenuSubContent = React.forwardRef<
1670
- MenuSubContentElement,
1671
- ScopedProps<MenuSubContentProps>
1672
- >((props, forwardedRef) => {
1673
- const scope = props.scope || MENU_CONTEXT
1674
- const portalContext = usePortalContext(scope)
1675
- const { forceMount = portalContext.forceMount, ...subContentProps } = props
1676
- const context = useMenuContext(scope)
1677
- const rootContext = useMenuRootContext(scope)
1678
- const subContext = useMenuSubContext(scope)
1679
- const popperContext = PopperPrimitive.usePopperContext(scope)
1680
- const ref = React.useRef<MenuSubContentElement>(null)
1681
- const composedRefs = useComposedRefs(forwardedRef, ref)
1758
+ const MenuSubContentFrame = styled(PopperPrimitive.PopperContentFrame, {
1759
+ name: SUB_CONTENT_NAME,
1760
+ })
1682
1761
 
1683
- // determine side from actual placement, not just RTL direction
1684
- // placement like "left-start" or "right-end" - extract the side
1685
- const placementSide = popperContext.placement?.split('-')[0] as
1686
- | 'left'
1687
- | 'right'
1688
- | 'top'
1689
- | 'bottom'
1690
- | undefined
1691
- // for submenus, we care about horizontal placement (left/right)
1692
- // default to 'right' for LTR, 'left' for RTL
1693
- const dataSide: Side =
1694
- placementSide === 'left' || placementSide === 'right'
1695
- ? placementSide
1696
- : rootContext.dir === 'rtl'
1697
- ? 'left'
1698
- : 'right'
1762
+ const MenuSubContent = MenuSubContentFrame.styleable<ScopedProps<MenuSubContentProps>>(
1763
+ (props, forwardedRef) => {
1764
+ const scope = props.scope || MENU_CONTEXT
1765
+ const portalContext = usePortalContext(scope)
1766
+ const { forceMount = portalContext.forceMount, ...subContentProps } = props
1767
+ const context = useMenuContext(scope)
1768
+ const rootContext = useMenuRootContext(scope)
1769
+ const subContext = useMenuSubContext(scope)
1770
+ const popperContext = PopperPrimitive.usePopperContext(scope)
1771
+ const ref = React.useRef<MenuSubContentElement>(null)
1772
+ const composedRefs = useComposedRefs(forwardedRef, ref)
1699
1773
 
1700
- // effective direction for keyboard navigation - if submenu is on left, flip arrow keys
1701
- const effectiveDir: Direction =
1702
- placementSide === 'left'
1703
- ? 'rtl'
1704
- : placementSide === 'right'
1705
- ? 'ltr'
1706
- : rootContext.dir
1774
+ // determine side from actual placement, not just RTL direction
1775
+ // placement like "left-start" or "right-end" - extract the side
1776
+ const placementSide = popperContext.placement?.split('-')[0] as
1777
+ | 'left'
1778
+ | 'right'
1779
+ | 'top'
1780
+ | 'bottom'
1781
+ | undefined
1782
+ // for submenus, we care about horizontal placement (left/right)
1783
+ // default to 'right' for LTR, 'left' for RTL
1784
+ const dataSide: Side =
1785
+ placementSide === 'left' || placementSide === 'right'
1786
+ ? placementSide
1787
+ : rootContext.dir === 'rtl'
1788
+ ? 'left'
1789
+ : 'right'
1790
+
1791
+ // keyboard navigation follows text direction, not placement side
1792
+ const effectiveDir: Direction = rootContext.dir
1707
1793
 
1708
- return (
1709
- <Collection.Provider scope={scope}>
1710
- <Collection.Slot scope={scope}>
1711
- <MenuContentImpl
1712
- id={subContext.contentId}
1713
- aria-labelledby={subContext.triggerId}
1714
- {...subContentProps}
1715
- ref={composedRefs}
1716
- data-side={dataSide}
1717
- disableOutsidePointerEvents={false}
1718
- disableOutsideScroll={false}
1719
- trapFocus={false}
1720
- onOpenAutoFocus={(event) => {
1721
- // when opening a submenu, focus content for keyboard users only
1722
- if (rootContext.isUsingKeyboardRef.current) {
1723
- // ref.current doesn't reliably point to the focusable DOM element,
1724
- // so we query for the submenu content directly
1725
- const content = document.querySelector(
1726
- '[data-tamagui-menu-content][data-side]'
1727
- ) as HTMLElement | null
1728
- content?.focus()
1729
- }
1730
- event.preventDefault()
1731
- }}
1732
- // The menu might close because of focusing another menu item in the parent menu. We
1733
- // don't want it to refocus the trigger in that case so we handle trigger focus ourselves.
1734
- onCloseAutoFocus={(event) => event.preventDefault()}
1735
- onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) => {
1736
- // We prevent closing when the trigger is focused to avoid triggering a re-open animation
1737
- // on pointer interaction.
1738
- if (event.target !== subContext.trigger) context.onOpenChange(false)
1739
- })}
1740
- onEscapeKeyDown={composeEventHandlers(props.onEscapeKeyDown, (event) => {
1741
- // close only this submenu, not the root menu
1742
- context.onOpenChange(false)
1743
- // return focus to the submenu trigger with focusVisible since this is keyboard navigation
1744
- // @ts-ignore focusVisible is a newer API
1745
- subContext.trigger?.focus({ focusVisible: true })
1746
- // ensure pressing escape in submenu doesn't escape full screen mode
1747
- event.preventDefault()
1748
- })}
1749
- {...(isWeb
1750
- ? {
1751
- onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
1752
- // Submenu key events bubble through portals. We only care about keys in this menu.
1753
- // @ts-ignore
1754
- const isKeyDownInside = event.currentTarget.contains(
1755
- event.target as HTMLElement
1756
- )
1757
- // use effectiveDir so arrow keys match the submenu's actual position
1758
- // (e.g., ArrowRight closes a left-side submenu)
1759
- const isCloseKey = SUB_CLOSE_KEYS[effectiveDir].includes(event.key)
1760
- if (isKeyDownInside && isCloseKey) {
1761
- context.onOpenChange(false)
1762
- // We focus manually because we prevented it in `onCloseAutoFocus`
1763
- // use focusVisible: true since this is keyboard navigation
1764
- // @ts-ignore focusVisible is a newer API
1765
- subContext.trigger?.focus({ focusVisible: true })
1766
- // prevent window from scrolling
1767
- event.preventDefault()
1768
- }
1769
- }),
1794
+ return (
1795
+ <Collection.Provider scope={scope}>
1796
+ <Collection.Slot scope={scope}>
1797
+ <MenuContentImpl
1798
+ id={subContext.contentId}
1799
+ aria-labelledby={subContext.triggerId}
1800
+ {...subContentProps}
1801
+ ref={composedRefs}
1802
+ data-side={dataSide}
1803
+ disableOutsidePointerEvents={false}
1804
+ disableOutsideScroll={false}
1805
+ trapFocus={false}
1806
+ onOpenAutoFocus={(event) => {
1807
+ // when opening a submenu, focus content for keyboard users only
1808
+ if (rootContext.isUsingKeyboardRef.current) {
1809
+ // scope query to this submenu's subtree so nested submenus
1810
+ // don't accidentally focus a sibling/parent submenu content
1811
+ const root = ref.current as unknown as HTMLElement
1812
+ const content = root?.querySelector?.(
1813
+ '[data-tamagui-menu-content]'
1814
+ ) as HTMLElement | null
1815
+ ;(content || root)?.focus({ preventScroll: true })
1770
1816
  }
1771
- : null)}
1772
- />
1773
- </Collection.Slot>
1774
- </Collection.Provider>
1775
- )
1776
- })
1817
+ event.preventDefault()
1818
+ }}
1819
+ // The menu might close because of focusing another menu item in the parent menu. We
1820
+ // don't want it to refocus the trigger in that case so we handle trigger focus ourselves.
1821
+ onCloseAutoFocus={(event) => event.preventDefault()}
1822
+ onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) => {
1823
+ // We prevent closing when the trigger is focused to avoid triggering a re-open animation
1824
+ // on pointer interaction.
1825
+ if (event.target !== subContext.trigger) context.onOpenChange(false)
1826
+ })}
1827
+ onEscapeKeyDown={composeEventHandlers(props.onEscapeKeyDown, (event) => {
1828
+ // close only this submenu, not the root menu
1829
+ context.onOpenChange(false)
1830
+ // return focus to the submenu trigger with focusVisible since this is keyboard navigation
1831
+ // @ts-ignore focusVisible is a newer API
1832
+ subContext.trigger?.focus({ focusVisible: true })
1833
+ // ensure pressing escape in submenu doesn't escape full screen mode
1834
+ event.preventDefault()
1835
+ })}
1836
+ {...(isWeb
1837
+ ? {
1838
+ onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
1839
+ // Submenu key events bubble through portals. We only care about keys in this menu.
1840
+ // @ts-ignore
1841
+ const isKeyDownInside = event.currentTarget.contains(
1842
+ event.target as HTMLElement
1843
+ )
1844
+ // use effectiveDir so arrow keys match the submenu's actual position
1845
+ // (e.g., ArrowRight closes a left-side submenu)
1846
+ const isCloseKey = SUB_CLOSE_KEYS[effectiveDir].includes(event.key)
1847
+ if (isKeyDownInside && isCloseKey) {
1848
+ context.onOpenChange(false)
1849
+ // We focus manually because we prevented it in `onCloseAutoFocus`
1850
+ // use focusVisible: true since this is keyboard navigation
1851
+ // @ts-ignore focusVisible is a newer API
1852
+ subContext.trigger?.focus({ focusVisible: true })
1853
+ // prevent window from scrolling
1854
+ event.preventDefault()
1855
+ }
1856
+ }),
1857
+ }
1858
+ : null)}
1859
+ />
1860
+ </Collection.Slot>
1861
+ </Collection.Provider>
1862
+ )
1863
+ }
1864
+ )
1777
1865
 
1778
1866
  MenuSubContent.displayName = SUB_CONTENT_NAME
1779
1867
 
@@ -1853,7 +1941,7 @@ function focusFirst(candidates: HTMLElement[], options?: { focusVisible?: boolea
1853
1941
  // if focus is already where we want to go, we don't want to keep going through the candidates
1854
1942
  if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return
1855
1943
  // @ts-ignore focusVisible is a newer API not yet in all TS libs
1856
- candidate.focus({ focusVisible: options?.focusVisible })
1944
+ candidate.focus({ preventScroll: true, focusVisible: options?.focusVisible })
1857
1945
  if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return
1858
1946
  }
1859
1947
  }