@tamagui/create-menu 2.0.0-rc.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) 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 +1150 -941
  5. package/dist/cjs/createBaseMenu.native.js +1280 -1108
  6. package/dist/cjs/createBaseMenu.native.js.map +1 -1
  7. package/dist/cjs/createNativeMenu/createNativeMenu.cjs +318 -159
  8. package/dist/cjs/createNativeMenu/createNativeMenu.native.js +430 -267
  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 +1110 -903
  27. package/dist/esm/createBaseMenu.mjs.map +1 -1
  28. package/dist/esm/createBaseMenu.native.js +1240 -1070
  29. package/dist/esm/createBaseMenu.native.js.map +1 -1
  30. package/dist/esm/createNativeMenu/createNativeMenu.mjs +291 -134
  31. package/dist/esm/createNativeMenu/createNativeMenu.mjs.map +1 -1
  32. package/dist/esm/createNativeMenu/createNativeMenu.native.js +377 -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 +1110 -903
  53. package/dist/jsx/createBaseMenu.mjs.map +1 -1
  54. package/dist/jsx/createBaseMenu.native.js +1280 -1108
  55. package/dist/jsx/createBaseMenu.native.js.map +1 -1
  56. package/dist/jsx/createNativeMenu/createNativeMenu.mjs +291 -134
  57. package/dist/jsx/createNativeMenu/createNativeMenu.mjs.map +1 -1
  58. package/dist/jsx/createNativeMenu/createNativeMenu.native.js +430 -267
  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 +26 -29
  76. package/src/createBaseMenu.tsx +367 -266
  77. package/src/createNativeMenu/createNativeMenu.tsx +448 -220
  78. package/src/createNativeMenu/createNativeMenuTypes.ts +20 -20
  79. package/src/createNativeMenu/withNativeMenu.tsx +5 -3
  80. package/src/index.tsx +3 -5
  81. package/types/createBaseMenu.d.ts +117 -31
  82. package/types/createBaseMenu.d.ts.map +1 -1
  83. package/types/createNativeMenu/createNativeMenu.d.ts +21 -21
  84. package/types/createNativeMenu/createNativeMenu.d.ts.map +1 -1
  85. package/types/createNativeMenu/createNativeMenuTypes.d.ts +20 -20
  86. package/types/createNativeMenu/createNativeMenuTypes.d.ts.map +1 -1
  87. package/types/createNativeMenu/withNativeMenu.d.ts +3 -3
  88. package/types/createNativeMenu/withNativeMenu.d.ts.map +1 -1
  89. package/types/index.d.ts +3 -2
  90. package/types/index.d.ts.map +1 -1
  91. package/dist/cjs/MenuPredefined.js +0 -168
  92. package/dist/cjs/MenuPredefined.js.map +0 -6
  93. package/dist/cjs/createBaseMenu.js +0 -843
  94. package/dist/cjs/createBaseMenu.js.map +0 -6
  95. package/dist/cjs/createNativeMenu/createNativeMenu.js +0 -177
  96. package/dist/cjs/createNativeMenu/createNativeMenu.js.map +0 -6
  97. package/dist/cjs/createNativeMenu/createNativeMenuTypes.js +0 -14
  98. package/dist/cjs/createNativeMenu/createNativeMenuTypes.js.map +0 -6
  99. package/dist/cjs/createNativeMenu/index.cjs +0 -19
  100. package/dist/cjs/createNativeMenu/index.js +0 -16
  101. package/dist/cjs/createNativeMenu/index.js.map +0 -6
  102. package/dist/cjs/createNativeMenu/index.native.js +0 -22
  103. package/dist/cjs/createNativeMenu/index.native.js.map +0 -1
  104. package/dist/cjs/createNativeMenu/utils.js +0 -66
  105. package/dist/cjs/createNativeMenu/utils.js.map +0 -6
  106. package/dist/cjs/createNativeMenu/withNativeMenu.js +0 -30
  107. package/dist/cjs/createNativeMenu/withNativeMenu.js.map +0 -6
  108. package/dist/cjs/index.js +0 -23
  109. package/dist/cjs/index.js.map +0 -6
  110. package/dist/esm/MenuPredefined.js +0 -154
  111. package/dist/esm/MenuPredefined.js.map +0 -6
  112. package/dist/esm/createBaseMenu.js +0 -849
  113. package/dist/esm/createBaseMenu.js.map +0 -6
  114. package/dist/esm/createNativeMenu/createNativeMenu.js +0 -156
  115. package/dist/esm/createNativeMenu/createNativeMenu.js.map +0 -6
  116. package/dist/esm/createNativeMenu/createNativeMenuTypes.js +0 -1
  117. package/dist/esm/createNativeMenu/createNativeMenuTypes.js.map +0 -6
  118. package/dist/esm/createNativeMenu/index.js +0 -3
  119. package/dist/esm/createNativeMenu/index.js.map +0 -6
  120. package/dist/esm/createNativeMenu/index.mjs +0 -3
  121. package/dist/esm/createNativeMenu/index.mjs.map +0 -1
  122. package/dist/esm/createNativeMenu/index.native.js +0 -3
  123. package/dist/esm/createNativeMenu/index.native.js.map +0 -1
  124. package/dist/esm/createNativeMenu/utils.js +0 -47
  125. package/dist/esm/createNativeMenu/utils.js.map +0 -6
  126. package/dist/esm/createNativeMenu/withNativeMenu.js +0 -15
  127. package/dist/esm/createNativeMenu/withNativeMenu.js.map +0 -6
  128. package/dist/jsx/MenuPredefined.js +0 -154
  129. package/dist/jsx/MenuPredefined.js.map +0 -6
  130. package/dist/jsx/createBaseMenu.js +0 -849
  131. package/dist/jsx/createBaseMenu.js.map +0 -6
  132. package/dist/jsx/createNativeMenu/createNativeMenu.js +0 -156
  133. package/dist/jsx/createNativeMenu/createNativeMenu.js.map +0 -6
  134. package/dist/jsx/createNativeMenu/createNativeMenuTypes.js +0 -1
  135. package/dist/jsx/createNativeMenu/createNativeMenuTypes.js.map +0 -6
  136. package/dist/jsx/createNativeMenu/index.js +0 -3
  137. package/dist/jsx/createNativeMenu/index.js.map +0 -6
  138. package/dist/jsx/createNativeMenu/index.mjs +0 -3
  139. package/dist/jsx/createNativeMenu/index.mjs.map +0 -1
  140. package/dist/jsx/createNativeMenu/index.native.js +0 -22
  141. package/dist/jsx/createNativeMenu/index.native.js.map +0 -1
  142. package/dist/jsx/createNativeMenu/utils.js +0 -47
  143. package/dist/jsx/createNativeMenu/utils.js.map +0 -6
  144. package/dist/jsx/createNativeMenu/withNativeMenu.js +0 -15
  145. package/dist/jsx/createNativeMenu/withNativeMenu.js.map +0 -6
  146. package/src/createNativeMenu/index.tsx +0 -7
  147. package/types/createNativeMenu/index.d.ts +0 -4
  148. 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,
@@ -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
@@ -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,
@@ -401,12 +411,14 @@ export function createBaseMenu({
401
411
  modal = true,
402
412
  allowFlip = { padding: 10 },
403
413
  stayInFrame = { padding: 10 },
414
+ placement = defaultPlacement,
415
+ resize = true,
416
+ offset = 10,
404
417
  ...rest
405
418
  } = props
406
419
  const [content, setContent] = React.useState<MenuContentElement | null>(null)
407
420
  const isUsingKeyboardRef = React.useRef(false)
408
421
  const handleOpenChange = useCallbackRef(onOpenChange)
409
- const direction = useDirection(dir)
410
422
 
411
423
  if (isWeb) {
412
424
  React.useEffect(() => {
@@ -437,8 +449,12 @@ export function createBaseMenu({
437
449
  return (
438
450
  <PopperPrimitive.Popper
439
451
  scope={scope}
452
+ open={open}
453
+ placement={placement}
440
454
  allowFlip={allowFlip}
441
455
  stayInFrame={stayInFrame}
456
+ resize={resize}
457
+ offset={offset}
442
458
  {...rest}
443
459
  >
444
460
  <MenuProvider
@@ -556,7 +572,7 @@ export function createBaseMenu({
556
572
 
557
573
  return (
558
574
  <Animate type="presence" present={isPresent}>
559
- <PortalPrimitive>
575
+ <PortalPrimitive stackZIndex>
560
576
  <>
561
577
  <PortalProvider scope={scope} forceMount={forceMount}>
562
578
  <View zIndex={zIndex || 100} inset={0} position="absolute">
@@ -587,7 +603,11 @@ export function createBaseMenu({
587
603
  const { Provider: MenuContentProvider, useStyledContext: useMenuContentContext } =
588
604
  createStyledContext<MenuContentContextValue>()
589
605
 
590
- 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>>(
591
611
  (props, forwardedRef) => {
592
612
  const scope = props.scope || MENU_CONTEXT
593
613
  const portalContext = usePortalContext(scope)
@@ -694,9 +714,13 @@ export function createBaseMenu({
694
714
 
695
715
  const context = useMenuContext(scope)
696
716
  const rootContext = useMenuRootContext(scope)
717
+ const popperContext = PopperPrimitive.usePopperContext(scope)
697
718
  const getItems = useCollection(scope)
698
719
  const [currentItemId, setCurrentItemId] = React.useState<string | null>(null)
699
720
  const contentRef = React.useRef<TamaguiElement>(null)
721
+ // the actual focusable content element (PopperContentFrame) differs from contentRef
722
+ // due to PopperContent's wrapper structure, so we capture it on mount
723
+ const focusableContentRef = React.useRef<HTMLElement | null>(null)
700
724
  const composedRefs = useComposedRefs(
701
725
  forwardedRef,
702
726
  contentRef,
@@ -740,17 +764,54 @@ export function createBaseMenu({
740
764
  return () => clearTimeout(timerRef.current)
741
765
  }, [])
742
766
 
743
- // dismiss on scroll (web only)
767
+ // capture the actual focusable content element on mount
768
+ // PopperContent has a wrapper structure where the ref points to the outer
769
+ // TamaguiView but props like tabIndex go to the inner PopperContentFrame
770
+ React.useEffect(() => {
771
+ if (!isWeb || !context.open) return
772
+ // use requestAnimationFrame to ensure DOM is ready
773
+ const frame = requestAnimationFrame(() => {
774
+ // scope the query to within this menu's content to avoid grabbing a submenu's element
775
+ const container = contentRef.current as unknown as HTMLElement
776
+ const el = container?.querySelector('[data-tamagui-menu-content]') as HTMLElement
777
+ if (el) focusableContentRef.current = el
778
+ })
779
+ return () => cancelAnimationFrame(frame)
780
+ }, [context.open])
781
+
782
+ // dismiss on scroll (web only) - only when the scroll actually moves the
783
+ // menu's anchor. a scroll in an unrelated subtree leaves the menu's
784
+ // position unchanged, so it should not dismiss.
744
785
  React.useEffect(() => {
745
786
  if (!isWeb || disableDismissOnScroll || !context.open) return
746
- const handleScroll = () => {
787
+ const handleScroll = (event: Event) => {
788
+ const scrolled = event.target as Node | null
789
+ if (!scrolled) return
790
+ // never dismiss when scrolling within the menu's own content
791
+ const content = contentRef.current as unknown as HTMLElement | null
792
+ if (content?.contains(scrolled)) return
793
+ // resolve the menu's anchor element (the trigger). a virtual reference
794
+ // (e.g. a context menu pinned to a point) may expose its DOM origin
795
+ // via `contextElement`.
796
+ const reference = popperContext.refs?.reference?.current as
797
+ | Element
798
+ | { contextElement?: Element | null }
799
+ | null
800
+ | undefined
801
+ const anchor =
802
+ reference instanceof Element ? reference : (reference?.contextElement ?? null)
803
+ // when we can see the anchor element, only dismiss if the scrolled
804
+ // container actually contains it - i.e. this scroll moved the menu
805
+ // relative to the viewport. window/document scrolls have `document`
806
+ // as the target, which contains the anchor, so those still dismiss.
807
+ if (anchor && !scrolled.contains(anchor)) return
747
808
  onDismiss?.()
748
809
  }
749
810
  window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
750
811
  return () => {
751
812
  window.removeEventListener('scroll', handleScroll, { capture: true })
752
813
  }
753
- }, [disableDismissOnScroll, context.open, onDismiss])
814
+ }, [disableDismissOnScroll, context.open, onDismiss, popperContext.refs])
754
815
 
755
816
  // Make sure the whole tree has focus guards as our `MenuContent` may be
756
817
  // the last element in the DOM (beacuse of the `Portal`)
@@ -768,8 +829,11 @@ export function createBaseMenu({
768
829
  const content = (
769
830
  <PopperPrimitive.PopperContent
770
831
  role="menu"
832
+ // tabIndex allows the content to be focusable so that onItemLeave can
833
+ // focus the content frame and properly blur the previously focused item
834
+ tabIndex={-1}
835
+ unstyled={unstyled}
771
836
  {...(!unstyled && {
772
- padding: 4,
773
837
  backgroundColor: '$background',
774
838
  borderWidth: 1,
775
839
  borderColor: '$borderColor',
@@ -858,7 +922,7 @@ export function createBaseMenu({
858
922
  onItemLeave={React.useCallback(
859
923
  (event) => {
860
924
  if (isPointerMovingToSubmenu(event)) return
861
- contentRef.current?.focus()
925
+ focusableContentRef.current?.focus()
862
926
  setCurrentItemId(null)
863
927
  },
864
928
  [isPointerMovingToSubmenu]
@@ -888,7 +952,7 @@ export function createBaseMenu({
888
952
  const content = document.querySelector(
889
953
  '[data-tamagui-menu-content]'
890
954
  ) as HTMLElement | null
891
- content?.focus()
955
+ content?.focus({ preventScroll: true })
892
956
  })}
893
957
  onUnmountAutoFocus={onCloseAutoFocus}
894
958
  >
@@ -935,106 +999,109 @@ export function createBaseMenu({
935
999
  const ITEM_NAME = 'MenuItem'
936
1000
  const ITEM_SELECT = 'menu.itemSelect'
937
1001
 
938
- const MenuItem = React.forwardRef<TamaguiElement, ScopedProps<MenuItemProps>>(
939
- (props, forwardedRef) => {
940
- const {
941
- disabled = false,
942
- onSelect,
943
- children,
944
- scope = MENU_CONTEXT,
945
- // filter out native-only props that shouldn't reach the DOM
946
- // @ts-ignore
947
- destructive,
948
- // @ts-ignore
949
- hidden,
950
- // @ts-ignore
951
- androidIconName,
952
- // @ts-ignore
953
- iosIconName,
954
- ...itemProps
955
- } = props
956
- const ref = React.useRef<TamaguiElement>(null)
957
- const rootContext = useMenuRootContext(scope)
958
- const contentContext = useMenuContentContext(scope)
959
- const composedRefs = useComposedRefs(forwardedRef, ref)
960
- const isPointerDownRef = React.useRef(false)
961
-
962
- const handleSelect = () => {
963
- const menuItem = ref.current
964
- if (!disabled && menuItem) {
965
- if (isWeb) {
966
- const menuItemEl = menuItem as HTMLElement
967
- const itemSelectEvent = new CustomEvent(ITEM_SELECT, {
968
- bubbles: true,
969
- cancelable: true,
970
- })
971
- menuItemEl.addEventListener(ITEM_SELECT, (event) => onSelect?.(event), {
972
- once: true,
973
- })
974
- dispatchDiscreteCustomEvent(menuItemEl, itemSelectEvent)
975
- if (itemSelectEvent.defaultPrevented) {
976
- isPointerDownRef.current = false
977
- } else {
978
- rootContext.onClose()
979
- }
980
- } else {
981
- // TODO: find a better way to handle this on native
982
- onSelect?.({ target: menuItem } as unknown as Event)
1002
+ // use styleable so styled(Menu.Item, { focusStyle }) passes pseudo styles through correctly
1003
+ const MenuItem = _Item.styleable<ScopedProps<MenuItemProps>>((props, forwardedRef) => {
1004
+ const {
1005
+ disabled = false,
1006
+ onSelect,
1007
+ preventCloseOnSelect,
1008
+ children,
1009
+ scope = MENU_CONTEXT,
1010
+ // filter out native-only props that shouldn't reach the DOM
1011
+ // @ts-ignore
1012
+ destructive,
1013
+ // @ts-ignore
1014
+ hidden,
1015
+ // @ts-ignore
1016
+ androidIconName,
1017
+ // @ts-ignore
1018
+ iosIconName,
1019
+ ...itemProps
1020
+ } = props
1021
+ const ref = React.useRef<TamaguiElement>(null)
1022
+ const rootContext = useMenuRootContext(scope)
1023
+ const contentContext = useMenuContentContext(scope)
1024
+ const composedRefs = useComposedRefs(forwardedRef, ref)
1025
+ const isPointerDownRef = React.useRef(false)
1026
+
1027
+ const handleSelect = () => {
1028
+ const menuItem = ref.current
1029
+ if (!disabled && menuItem) {
1030
+ if (isWeb) {
1031
+ const menuItemEl = menuItem as HTMLElement
1032
+ const itemSelectEvent = new CustomEvent(ITEM_SELECT, {
1033
+ bubbles: true,
1034
+ cancelable: true,
1035
+ })
1036
+ menuItemEl.addEventListener(ITEM_SELECT, (event) => onSelect?.(event), {
1037
+ once: true,
1038
+ })
1039
+ dispatchDiscreteCustomEvent(menuItemEl, itemSelectEvent)
1040
+ // close the menu unless preventCloseOnSelect is set or event.preventDefault() was called
1041
+ if (itemSelectEvent.defaultPrevented || preventCloseOnSelect) {
983
1042
  isPointerDownRef.current = false
1043
+ } else {
1044
+ rootContext.onClose()
1045
+ }
1046
+ } else {
1047
+ // TODO: find a better way to handle this on native
1048
+ onSelect?.({ target: menuItem } as unknown as Event)
1049
+ isPointerDownRef.current = false
1050
+ if (!preventCloseOnSelect) {
984
1051
  rootContext.onClose()
985
1052
  }
986
1053
  }
987
1054
  }
1055
+ }
988
1056
 
989
- const content = typeof children === 'string' ? <Text>{children}</Text> : children
1057
+ const content = typeof children === 'string' ? <Text>{children}</Text> : children
990
1058
 
991
- return (
992
- <MenuItemImpl
993
- outlineStyle="none"
994
- {...itemProps}
995
- scope={scope}
996
- // @ts-ignore
997
- ref={composedRefs}
998
- disabled={disabled}
999
- onPress={composeEventHandlers(props.onPress, handleSelect)}
1000
- onPointerDown={(event) => {
1001
- props.onPointerDown?.(event)
1002
- isPointerDownRef.current = true
1003
- }}
1004
- onPointerUp={composeEventHandlers(props.onPointerUp, (event) => {
1005
- // Pointer down can move to a different menu item which should activate it on pointer up.
1006
- // We dispatch a click for selection to allow composition with click based triggers and to
1007
- // prevent Firefox from getting stuck in text selection mode when the menu closes.
1008
- if (isWeb) {
1009
- // @ts-ignore
1010
- if (!isPointerDownRef.current) event.currentTarget?.click()
1059
+ return (
1060
+ <MenuItemImpl
1061
+ outlineStyle="none"
1062
+ {...itemProps}
1063
+ scope={scope}
1064
+ // @ts-ignore
1065
+ ref={composedRefs}
1066
+ disabled={disabled}
1067
+ onPress={composeEventHandlers(props.onPress, handleSelect)}
1068
+ onPointerDown={(event) => {
1069
+ props.onPointerDown?.(event)
1070
+ isPointerDownRef.current = true
1071
+ }}
1072
+ onPointerUp={composeEventHandlers(props.onPointerUp, (event) => {
1073
+ // Pointer down can move to a different menu item which should activate it on pointer up.
1074
+ // We dispatch a click for selection to allow composition with click based triggers and to
1075
+ // prevent Firefox from getting stuck in text selection mode when the menu closes.
1076
+ if (isWeb) {
1077
+ // @ts-ignore
1078
+ if (!isPointerDownRef.current) event.currentTarget?.click()
1079
+ }
1080
+ })}
1081
+ {...(isWeb
1082
+ ? {
1083
+ onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
1084
+ const isTypingAhead = contentContext.searchRef.current !== ''
1085
+ if (disabled || (isTypingAhead && event.key === ' ')) return
1086
+ if (SELECTION_KEYS.includes(event.key)) {
1087
+ // @ts-ignore
1088
+ event.currentTarget?.click()
1089
+ /**
1090
+ * We prevent default browser behaviour for selection keys as they should trigger
1091
+ * a selection only:
1092
+ * - prevents space from scrolling the page.
1093
+ * - if keydown causes focus to move, prevents keydown from firing on the new target.
1094
+ */
1095
+ event.preventDefault()
1096
+ }
1097
+ }),
1011
1098
  }
1012
- })}
1013
- {...(isWeb
1014
- ? {
1015
- onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
1016
- const isTypingAhead = contentContext.searchRef.current !== ''
1017
- if (disabled || (isTypingAhead && event.key === ' ')) return
1018
- if (SELECTION_KEYS.includes(event.key)) {
1019
- // @ts-ignore
1020
- event.currentTarget?.click()
1021
- /**
1022
- * We prevent default browser behaviour for selection keys as they should trigger
1023
- * a selection only:
1024
- * - prevents space from scrolling the page.
1025
- * - if keydown causes focus to move, prevents keydown from firing on the new target.
1026
- */
1027
- event.preventDefault()
1028
- }
1029
- }),
1030
- }
1031
- : {})}
1032
- >
1033
- {content}
1034
- </MenuItemImpl>
1035
- )
1036
- }
1037
- )
1099
+ : {})}
1100
+ >
1101
+ {content}
1102
+ </MenuItemImpl>
1103
+ )
1104
+ })
1038
1105
 
1039
1106
  const MenuItemImpl = React.forwardRef<
1040
1107
  MenuItemImplElement,
@@ -1074,13 +1141,9 @@ export function createBaseMenu({
1074
1141
  asChild
1075
1142
  __scopeRovingFocusGroup={scope}
1076
1143
  focusable={!disabled}
1077
- {...(!unstyled && {
1078
- flexDirection: 'row',
1079
- alignItems: 'center',
1080
- })}
1081
- {...itemProps}
1082
1144
  >
1083
1145
  <_Item
1146
+ unstyled={unstyled}
1084
1147
  componentName={ITEM_NAME}
1085
1148
  role="menuitem"
1086
1149
  data-highlighted={isFocused ? '' : undefined}
@@ -1206,40 +1269,39 @@ export function createBaseMenu({
1206
1269
 
1207
1270
  const CHECKBOX_ITEM_NAME = 'MenuCheckboxItem'
1208
1271
 
1209
- const MenuCheckboxItem = React.forwardRef<
1210
- TamaguiElement,
1211
- ScopedProps<MenuCheckboxItemProps>
1212
- >((props, forwardedRef) => {
1213
- const {
1214
- checked = false,
1215
- onCheckedChange,
1216
- scope = MENU_CONTEXT,
1217
- // filter out native-only props
1218
- // @ts-ignore - native menu value state
1219
- value,
1220
- // @ts-ignore - native menu value change handler
1221
- onValueChange,
1222
- ...checkboxItemProps
1223
- } = props
1224
- return (
1225
- <ItemIndicatorProvider scope={scope} checked={checked}>
1226
- <MenuItem
1227
- componentName={CHECKBOX_ITEM_NAME}
1228
- role={(isWeb ? 'menuitemcheckbox' : 'menuitem') as 'menuitem'}
1229
- aria-checked={isIndeterminate(checked) ? 'mixed' : checked}
1230
- {...checkboxItemProps}
1231
- scope={scope}
1232
- ref={forwardedRef}
1233
- data-state={getCheckedState(checked)}
1234
- onSelect={composeEventHandlers(
1235
- checkboxItemProps.onSelect,
1236
- () => onCheckedChange?.(isIndeterminate(checked) ? true : !checked),
1237
- { checkDefaultPrevented: false }
1238
- )}
1239
- />
1240
- </ItemIndicatorProvider>
1241
- )
1242
- })
1272
+ const MenuCheckboxItem = _Item.styleable<ScopedProps<MenuCheckboxItemProps>>(
1273
+ (props, forwardedRef) => {
1274
+ const {
1275
+ checked = false,
1276
+ onCheckedChange,
1277
+ scope = MENU_CONTEXT,
1278
+ // filter out native-only props
1279
+ // @ts-ignore - native menu value state
1280
+ value,
1281
+ // @ts-ignore - native menu value change handler
1282
+ onValueChange,
1283
+ ...checkboxItemProps
1284
+ } = props
1285
+ return (
1286
+ <ItemIndicatorProvider scope={scope} checked={checked}>
1287
+ <MenuItem
1288
+ componentName={CHECKBOX_ITEM_NAME}
1289
+ role={(isWeb ? 'menuitemcheckbox' : 'menuitem') as 'menuitem'}
1290
+ aria-checked={isIndeterminate(checked) ? 'mixed' : checked}
1291
+ {...checkboxItemProps}
1292
+ scope={scope}
1293
+ ref={forwardedRef}
1294
+ data-state={getCheckedState(checked)}
1295
+ onSelect={composeEventHandlers(
1296
+ checkboxItemProps.onSelect,
1297
+ () => onCheckedChange?.(isIndeterminate(checked) ? true : !checked),
1298
+ { checkDefaultPrevented: false }
1299
+ )}
1300
+ />
1301
+ </ItemIndicatorProvider>
1302
+ )
1303
+ }
1304
+ )
1243
1305
 
1244
1306
  MenuCheckboxItem.displayName = CHECKBOX_ITEM_NAME
1245
1307
  /* -------------------------------------------------------------------------------------------------
@@ -1274,7 +1336,7 @@ export function createBaseMenu({
1274
1336
 
1275
1337
  const RADIO_ITEM_NAME = 'MenuRadioItem'
1276
1338
 
1277
- const MenuRadioItem = React.forwardRef<TamaguiElement, ScopedProps<MenuRadioItemProps>>(
1339
+ const MenuRadioItem = _Item.styleable<ScopedProps<MenuRadioItemProps>>(
1278
1340
  (props, forwardedRef) => {
1279
1341
  const { value, scope = MENU_CONTEXT, ...radioItemProps } = props
1280
1342
  const context = useRadioGroupContext(scope)
@@ -1351,6 +1413,7 @@ export function createBaseMenu({
1351
1413
  <PopperPrimitive.PopperArrow
1352
1414
  scope={scope}
1353
1415
  componentName="PopperArrow"
1416
+ unstyled={unstyled}
1354
1417
  {...(!unstyled && {
1355
1418
  backgroundColor: '$background',
1356
1419
  })}
@@ -1371,15 +1434,58 @@ export function createBaseMenu({
1371
1434
  createStyledContext<MenuSubContextValue>()
1372
1435
 
1373
1436
  const MenuSub: React.FC<ScopedProps<MenuSubProps>> = (props) => {
1437
+ const isTouchDevice = useIsTouchDevice()
1438
+ const { scope = MENU_CONTEXT } = props
1439
+ const rootContext = useMenuRootContext(scope)
1440
+
1441
+ // detect if this sub is nested inside another sub that opened to a side
1442
+ const parentPopperContext = PopperPrimitive.usePopperContext(scope)
1443
+ const parentSide = parentPopperContext.placement?.split('-')[0]
1444
+ const isNestedSubmenu = parentSide === 'left' || parentSide === 'right'
1445
+
1446
+ // nested submenus inherit the parent's direction so they cascade
1447
+ // rather than flipping back on top of the grandparent
1448
+ const defaultPlacement = isTouchDevice
1449
+ ? 'bottom'
1450
+ : isNestedSubmenu
1451
+ ? (`${parentSide}-start` as any)
1452
+ : rootContext.dir === 'rtl'
1453
+ ? 'left-start'
1454
+ : 'right-start'
1374
1455
  const {
1375
- scope = MENU_CONTEXT,
1376
1456
  children,
1377
1457
  open = false,
1378
1458
  onOpenChange,
1379
- allowFlip = { padding: 10 },
1459
+ allowFlip: allowFlipProp = { padding: 10 },
1380
1460
  stayInFrame = { padding: 10 },
1461
+ placement = defaultPlacement,
1381
1462
  ...rest
1382
1463
  } = props
1464
+
1465
+ // for nested submenus, flip to opposite side (never top/bottom which overlap parent)
1466
+ const allowFlip = React.useMemo(() => {
1467
+ if (!isNestedSubmenu || typeof allowFlipProp === 'boolean') return allowFlipProp
1468
+ if ((allowFlipProp as any).fallbackPlacements) return allowFlipProp
1469
+
1470
+ const side = placement.split('-')[0]
1471
+ const align = placement.split('-')[1] || 'start'
1472
+ const otherAlign = align === 'start' ? 'end' : 'start'
1473
+
1474
+ if (side === 'left' || side === 'right') {
1475
+ const oppositeSide = side === 'right' ? 'left' : 'right'
1476
+ return {
1477
+ ...(typeof allowFlipProp === 'object' ? allowFlipProp : {}),
1478
+ fallbackPlacements: [
1479
+ `${side}-${otherAlign}`,
1480
+ `${oppositeSide}-${align}`,
1481
+ `${oppositeSide}-${otherAlign}`,
1482
+ ] as any,
1483
+ }
1484
+ }
1485
+
1486
+ return allowFlipProp
1487
+ }, [isNestedSubmenu, allowFlipProp, placement])
1488
+
1383
1489
  const parentMenuContext = useMenuContext(scope)
1384
1490
  const [trigger, setTrigger] = React.useState<MenuSubTriggerElement | null>(null)
1385
1491
  const [content, setContent] = React.useState<MenuContentElement | null>(null)
@@ -1393,6 +1499,8 @@ export function createBaseMenu({
1393
1499
 
1394
1500
  return (
1395
1501
  <PopperPrimitive.Popper
1502
+ open={open}
1503
+ placement={placement}
1396
1504
  allowFlip={allowFlip}
1397
1505
  stayInFrame={stayInFrame}
1398
1506
  {...rest}
@@ -1439,15 +1547,9 @@ export function createBaseMenu({
1439
1547
  const openTimerRef = React.useRef<number | null>(null)
1440
1548
  const { pointerGraceTimerRef, onPointerGraceIntentChange } = contentContext
1441
1549
 
1442
- // determine effective direction for keyboard navigation based on placement
1443
- // if submenu opens to the left, arrow keys should be flipped
1444
- const placementSide = popperContext.placement?.split('-')[0]
1445
- const effectiveDir: Direction =
1446
- placementSide === 'left'
1447
- ? 'rtl'
1448
- : placementSide === 'right'
1449
- ? 'ltr'
1450
- : rootContext.dir
1550
+ // keyboard navigation follows text direction, not placement side
1551
+ // ArrowRight always opens, ArrowLeft always closes (flipped only for RTL)
1552
+ const effectiveDir: Direction = rootContext.dir
1451
1553
 
1452
1554
  const clearOpenTimer = React.useCallback(() => {
1453
1555
  if (openTimerRef.current) window.clearTimeout(openTimerRef.current)
@@ -1673,114 +1775,113 @@ export function createBaseMenu({
1673
1775
 
1674
1776
  const SUB_CONTENT_NAME = 'MenuSubContent'
1675
1777
 
1676
- const MenuSubContent = React.forwardRef<
1677
- MenuSubContentElement,
1678
- ScopedProps<MenuSubContentProps>
1679
- >((props, forwardedRef) => {
1680
- const scope = props.scope || MENU_CONTEXT
1681
- const portalContext = usePortalContext(scope)
1682
- const { forceMount = portalContext.forceMount, ...subContentProps } = props
1683
- const context = useMenuContext(scope)
1684
- const rootContext = useMenuRootContext(scope)
1685
- const subContext = useMenuSubContext(scope)
1686
- const popperContext = PopperPrimitive.usePopperContext(scope)
1687
- const ref = React.useRef<MenuSubContentElement>(null)
1688
- const composedRefs = useComposedRefs(forwardedRef, ref)
1778
+ const MenuSubContentFrame = styled(PopperPrimitive.PopperContentFrame, {
1779
+ name: SUB_CONTENT_NAME,
1780
+ })
1689
1781
 
1690
- // determine side from actual placement, not just RTL direction
1691
- // placement like "left-start" or "right-end" - extract the side
1692
- const placementSide = popperContext.placement?.split('-')[0] as
1693
- | 'left'
1694
- | 'right'
1695
- | 'top'
1696
- | 'bottom'
1697
- | undefined
1698
- // for submenus, we care about horizontal placement (left/right)
1699
- // default to 'right' for LTR, 'left' for RTL
1700
- const dataSide: Side =
1701
- placementSide === 'left' || placementSide === 'right'
1702
- ? placementSide
1703
- : rootContext.dir === 'rtl'
1704
- ? 'left'
1705
- : 'right'
1782
+ const MenuSubContent = MenuSubContentFrame.styleable<ScopedProps<MenuSubContentProps>>(
1783
+ (props, forwardedRef) => {
1784
+ const scope = props.scope || MENU_CONTEXT
1785
+ const portalContext = usePortalContext(scope)
1786
+ const { forceMount = portalContext.forceMount, ...subContentProps } = props
1787
+ const context = useMenuContext(scope)
1788
+ const rootContext = useMenuRootContext(scope)
1789
+ const subContext = useMenuSubContext(scope)
1790
+ const popperContext = PopperPrimitive.usePopperContext(scope)
1791
+ const ref = React.useRef<MenuSubContentElement>(null)
1792
+ const composedRefs = useComposedRefs(forwardedRef, ref)
1706
1793
 
1707
- // effective direction for keyboard navigation - if submenu is on left, flip arrow keys
1708
- const effectiveDir: Direction =
1709
- placementSide === 'left'
1710
- ? 'rtl'
1711
- : placementSide === 'right'
1712
- ? 'ltr'
1713
- : rootContext.dir
1794
+ // determine side from actual placement, not just RTL direction
1795
+ // placement like "left-start" or "right-end" - extract the side
1796
+ const placementSide = popperContext.placement?.split('-')[0] as
1797
+ | 'left'
1798
+ | 'right'
1799
+ | 'top'
1800
+ | 'bottom'
1801
+ | undefined
1802
+ // for submenus, we care about horizontal placement (left/right)
1803
+ // default to 'right' for LTR, 'left' for RTL
1804
+ const dataSide: Side =
1805
+ placementSide === 'left' || placementSide === 'right'
1806
+ ? placementSide
1807
+ : rootContext.dir === 'rtl'
1808
+ ? 'left'
1809
+ : 'right'
1810
+
1811
+ // keyboard navigation follows text direction, not placement side
1812
+ const effectiveDir: Direction = rootContext.dir
1714
1813
 
1715
- return (
1716
- <Collection.Provider scope={scope}>
1717
- <Collection.Slot scope={scope}>
1718
- <MenuContentImpl
1719
- id={subContext.contentId}
1720
- aria-labelledby={subContext.triggerId}
1721
- {...subContentProps}
1722
- ref={composedRefs}
1723
- data-side={dataSide}
1724
- disableOutsidePointerEvents={false}
1725
- disableOutsideScroll={false}
1726
- trapFocus={false}
1727
- onOpenAutoFocus={(event) => {
1728
- // when opening a submenu, focus content for keyboard users only
1729
- if (rootContext.isUsingKeyboardRef.current) {
1730
- // ref.current doesn't reliably point to the focusable DOM element,
1731
- // so we query for the submenu content directly
1732
- const content = document.querySelector(
1733
- '[data-tamagui-menu-content][data-side]'
1734
- ) as HTMLElement | null
1735
- content?.focus()
1736
- }
1737
- event.preventDefault()
1738
- }}
1739
- // The menu might close because of focusing another menu item in the parent menu. We
1740
- // don't want it to refocus the trigger in that case so we handle trigger focus ourselves.
1741
- onCloseAutoFocus={(event) => event.preventDefault()}
1742
- onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) => {
1743
- // We prevent closing when the trigger is focused to avoid triggering a re-open animation
1744
- // on pointer interaction.
1745
- if (event.target !== subContext.trigger) context.onOpenChange(false)
1746
- })}
1747
- onEscapeKeyDown={composeEventHandlers(props.onEscapeKeyDown, (event) => {
1748
- // close only this submenu, not the root menu
1749
- context.onOpenChange(false)
1750
- // return focus to the submenu trigger with focusVisible since this is keyboard navigation
1751
- // @ts-ignore focusVisible is a newer API
1752
- subContext.trigger?.focus({ focusVisible: true })
1753
- // ensure pressing escape in submenu doesn't escape full screen mode
1754
- event.preventDefault()
1755
- })}
1756
- {...(isWeb
1757
- ? {
1758
- onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
1759
- // Submenu key events bubble through portals. We only care about keys in this menu.
1760
- // @ts-ignore
1761
- const isKeyDownInside = event.currentTarget.contains(
1762
- event.target as HTMLElement
1763
- )
1764
- // use effectiveDir so arrow keys match the submenu's actual position
1765
- // (e.g., ArrowRight closes a left-side submenu)
1766
- const isCloseKey = SUB_CLOSE_KEYS[effectiveDir].includes(event.key)
1767
- if (isKeyDownInside && isCloseKey) {
1768
- context.onOpenChange(false)
1769
- // We focus manually because we prevented it in `onCloseAutoFocus`
1770
- // use focusVisible: true since this is keyboard navigation
1771
- // @ts-ignore focusVisible is a newer API
1772
- subContext.trigger?.focus({ focusVisible: true })
1773
- // prevent window from scrolling
1774
- event.preventDefault()
1775
- }
1776
- }),
1814
+ return (
1815
+ <Collection.Provider scope={scope}>
1816
+ <Collection.Slot scope={scope}>
1817
+ <MenuContentImpl
1818
+ id={subContext.contentId}
1819
+ aria-labelledby={subContext.triggerId}
1820
+ {...subContentProps}
1821
+ ref={composedRefs}
1822
+ data-side={dataSide}
1823
+ disableOutsidePointerEvents={false}
1824
+ disableOutsideScroll={false}
1825
+ trapFocus={false}
1826
+ onOpenAutoFocus={(event) => {
1827
+ // when opening a submenu, focus content for keyboard users only
1828
+ if (rootContext.isUsingKeyboardRef.current) {
1829
+ // scope query to this submenu's subtree so nested submenus
1830
+ // don't accidentally focus a sibling/parent submenu content
1831
+ const root = ref.current as unknown as HTMLElement
1832
+ const content = root?.querySelector?.(
1833
+ '[data-tamagui-menu-content]'
1834
+ ) as HTMLElement | null
1835
+ ;(content || root)?.focus({ preventScroll: true })
1777
1836
  }
1778
- : null)}
1779
- />
1780
- </Collection.Slot>
1781
- </Collection.Provider>
1782
- )
1783
- })
1837
+ event.preventDefault()
1838
+ }}
1839
+ // The menu might close because of focusing another menu item in the parent menu. We
1840
+ // don't want it to refocus the trigger in that case so we handle trigger focus ourselves.
1841
+ onCloseAutoFocus={(event) => event.preventDefault()}
1842
+ onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) => {
1843
+ // We prevent closing when the trigger is focused to avoid triggering a re-open animation
1844
+ // on pointer interaction.
1845
+ if (event.target !== subContext.trigger) context.onOpenChange(false)
1846
+ })}
1847
+ onEscapeKeyDown={composeEventHandlers(props.onEscapeKeyDown, (event) => {
1848
+ // close only this submenu, not the root menu
1849
+ context.onOpenChange(false)
1850
+ // return focus to the submenu trigger with focusVisible since this is keyboard navigation
1851
+ // @ts-ignore focusVisible is a newer API
1852
+ subContext.trigger?.focus({ focusVisible: true })
1853
+ // ensure pressing escape in submenu doesn't escape full screen mode
1854
+ event.preventDefault()
1855
+ })}
1856
+ {...(isWeb
1857
+ ? {
1858
+ onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
1859
+ // Submenu key events bubble through portals. We only care about keys in this menu.
1860
+ // @ts-ignore
1861
+ const isKeyDownInside = event.currentTarget.contains(
1862
+ event.target as HTMLElement
1863
+ )
1864
+ // use effectiveDir so arrow keys match the submenu's actual position
1865
+ // (e.g., ArrowRight closes a left-side submenu)
1866
+ const isCloseKey = SUB_CLOSE_KEYS[effectiveDir].includes(event.key)
1867
+ if (isKeyDownInside && isCloseKey) {
1868
+ context.onOpenChange(false)
1869
+ // We focus manually because we prevented it in `onCloseAutoFocus`
1870
+ // use focusVisible: true since this is keyboard navigation
1871
+ // @ts-ignore focusVisible is a newer API
1872
+ subContext.trigger?.focus({ focusVisible: true })
1873
+ // prevent window from scrolling
1874
+ event.preventDefault()
1875
+ }
1876
+ }),
1877
+ }
1878
+ : null)}
1879
+ />
1880
+ </Collection.Slot>
1881
+ </Collection.Provider>
1882
+ )
1883
+ }
1884
+ )
1784
1885
 
1785
1886
  MenuSubContent.displayName = SUB_CONTENT_NAME
1786
1887
 
@@ -1860,7 +1961,7 @@ function focusFirst(candidates: HTMLElement[], options?: { focusVisible?: boolea
1860
1961
  // if focus is already where we want to go, we don't want to keep going through the candidates
1861
1962
  if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return
1862
1963
  // @ts-ignore focusVisible is a newer API not yet in all TS libs
1863
- candidate.focus({ focusVisible: options?.focusVisible })
1964
+ candidate.focus({ preventScroll: true, focusVisible: options?.focusVisible })
1864
1965
  if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return
1865
1966
  }
1866
1967
  }