@tamagui/create-menu 2.0.0-1

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 (162) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/MenuPredefined.cjs +182 -0
  3. package/dist/cjs/MenuPredefined.js +162 -0
  4. package/dist/cjs/MenuPredefined.js.map +6 -0
  5. package/dist/cjs/MenuPredefined.native.js +185 -0
  6. package/dist/cjs/MenuPredefined.native.js.map +1 -0
  7. package/dist/cjs/createBaseMenu.cjs +927 -0
  8. package/dist/cjs/createBaseMenu.js +724 -0
  9. package/dist/cjs/createBaseMenu.js.map +6 -0
  10. package/dist/cjs/createBaseMenu.native.js +1105 -0
  11. package/dist/cjs/createBaseMenu.native.js.map +1 -0
  12. package/dist/cjs/createNativeMenu/createNativeMenu.cjs +224 -0
  13. package/dist/cjs/createNativeMenu/createNativeMenu.js +172 -0
  14. package/dist/cjs/createNativeMenu/createNativeMenu.js.map +6 -0
  15. package/dist/cjs/createNativeMenu/createNativeMenu.native.js +287 -0
  16. package/dist/cjs/createNativeMenu/createNativeMenu.native.js.map +1 -0
  17. package/dist/cjs/createNativeMenu/createNativeMenuTypes.cjs +16 -0
  18. package/dist/cjs/createNativeMenu/createNativeMenuTypes.js +14 -0
  19. package/dist/cjs/createNativeMenu/createNativeMenuTypes.js.map +6 -0
  20. package/dist/cjs/createNativeMenu/createNativeMenuTypes.native.js +19 -0
  21. package/dist/cjs/createNativeMenu/createNativeMenuTypes.native.js.map +1 -0
  22. package/dist/cjs/createNativeMenu/index.cjs +19 -0
  23. package/dist/cjs/createNativeMenu/index.js +16 -0
  24. package/dist/cjs/createNativeMenu/index.js.map +6 -0
  25. package/dist/cjs/createNativeMenu/index.native.js +22 -0
  26. package/dist/cjs/createNativeMenu/index.native.js.map +1 -0
  27. package/dist/cjs/createNativeMenu/utils.cjs +68 -0
  28. package/dist/cjs/createNativeMenu/utils.js +66 -0
  29. package/dist/cjs/createNativeMenu/utils.js.map +6 -0
  30. package/dist/cjs/createNativeMenu/utils.native.js +94 -0
  31. package/dist/cjs/createNativeMenu/utils.native.js.map +1 -0
  32. package/dist/cjs/createNativeMenu/withNativeMenu.cjs +37 -0
  33. package/dist/cjs/createNativeMenu/withNativeMenu.js +30 -0
  34. package/dist/cjs/createNativeMenu/withNativeMenu.js.map +6 -0
  35. package/dist/cjs/createNativeMenu/withNativeMenu.native.js +43 -0
  36. package/dist/cjs/createNativeMenu/withNativeMenu.native.js.map +1 -0
  37. package/dist/cjs/index.cjs +30 -0
  38. package/dist/cjs/index.js +24 -0
  39. package/dist/cjs/index.js.map +6 -0
  40. package/dist/cjs/index.native.js +33 -0
  41. package/dist/cjs/index.native.js.map +1 -0
  42. package/dist/esm/MenuPredefined.js +147 -0
  43. package/dist/esm/MenuPredefined.js.map +6 -0
  44. package/dist/esm/MenuPredefined.mjs +159 -0
  45. package/dist/esm/MenuPredefined.mjs.map +1 -0
  46. package/dist/esm/MenuPredefined.native.js +159 -0
  47. package/dist/esm/MenuPredefined.native.js.map +1 -0
  48. package/dist/esm/createBaseMenu.js +729 -0
  49. package/dist/esm/createBaseMenu.js.map +6 -0
  50. package/dist/esm/createBaseMenu.mjs +893 -0
  51. package/dist/esm/createBaseMenu.mjs.map +1 -0
  52. package/dist/esm/createBaseMenu.native.js +1068 -0
  53. package/dist/esm/createBaseMenu.native.js.map +1 -0
  54. package/dist/esm/createNativeMenu/createNativeMenu.js +150 -0
  55. package/dist/esm/createNativeMenu/createNativeMenu.js.map +6 -0
  56. package/dist/esm/createNativeMenu/createNativeMenu.mjs +190 -0
  57. package/dist/esm/createNativeMenu/createNativeMenu.mjs.map +1 -0
  58. package/dist/esm/createNativeMenu/createNativeMenu.native.js +250 -0
  59. package/dist/esm/createNativeMenu/createNativeMenu.native.js.map +1 -0
  60. package/dist/esm/createNativeMenu/createNativeMenuTypes.js +1 -0
  61. package/dist/esm/createNativeMenu/createNativeMenuTypes.js.map +6 -0
  62. package/dist/esm/createNativeMenu/createNativeMenuTypes.mjs +2 -0
  63. package/dist/esm/createNativeMenu/createNativeMenuTypes.mjs.map +1 -0
  64. package/dist/esm/createNativeMenu/createNativeMenuTypes.native.js +2 -0
  65. package/dist/esm/createNativeMenu/createNativeMenuTypes.native.js.map +1 -0
  66. package/dist/esm/createNativeMenu/index.js +3 -0
  67. package/dist/esm/createNativeMenu/index.js.map +6 -0
  68. package/dist/esm/createNativeMenu/index.mjs +3 -0
  69. package/dist/esm/createNativeMenu/index.mjs.map +1 -0
  70. package/dist/esm/createNativeMenu/index.native.js +3 -0
  71. package/dist/esm/createNativeMenu/index.native.js.map +1 -0
  72. package/dist/esm/createNativeMenu/utils.js +47 -0
  73. package/dist/esm/createNativeMenu/utils.js.map +6 -0
  74. package/dist/esm/createNativeMenu/utils.mjs +29 -0
  75. package/dist/esm/createNativeMenu/utils.mjs.map +1 -0
  76. package/dist/esm/createNativeMenu/utils.native.js +52 -0
  77. package/dist/esm/createNativeMenu/utils.native.js.map +1 -0
  78. package/dist/esm/createNativeMenu/withNativeMenu.js +15 -0
  79. package/dist/esm/createNativeMenu/withNativeMenu.js.map +6 -0
  80. package/dist/esm/createNativeMenu/withNativeMenu.mjs +14 -0
  81. package/dist/esm/createNativeMenu/withNativeMenu.mjs.map +1 -0
  82. package/dist/esm/createNativeMenu/withNativeMenu.native.js +17 -0
  83. package/dist/esm/createNativeMenu/withNativeMenu.native.js.map +1 -0
  84. package/dist/esm/index.js +8 -0
  85. package/dist/esm/index.js.map +6 -0
  86. package/dist/esm/index.mjs +6 -0
  87. package/dist/esm/index.mjs.map +1 -0
  88. package/dist/esm/index.native.js +6 -0
  89. package/dist/esm/index.native.js.map +1 -0
  90. package/dist/jsx/MenuPredefined.js +147 -0
  91. package/dist/jsx/MenuPredefined.js.map +6 -0
  92. package/dist/jsx/MenuPredefined.mjs +159 -0
  93. package/dist/jsx/MenuPredefined.mjs.map +1 -0
  94. package/dist/jsx/MenuPredefined.native.js +185 -0
  95. package/dist/jsx/MenuPredefined.native.js.map +1 -0
  96. package/dist/jsx/createBaseMenu.js +729 -0
  97. package/dist/jsx/createBaseMenu.js.map +6 -0
  98. package/dist/jsx/createBaseMenu.mjs +893 -0
  99. package/dist/jsx/createBaseMenu.mjs.map +1 -0
  100. package/dist/jsx/createBaseMenu.native.js +1105 -0
  101. package/dist/jsx/createBaseMenu.native.js.map +1 -0
  102. package/dist/jsx/createNativeMenu/createNativeMenu.js +150 -0
  103. package/dist/jsx/createNativeMenu/createNativeMenu.js.map +6 -0
  104. package/dist/jsx/createNativeMenu/createNativeMenu.mjs +190 -0
  105. package/dist/jsx/createNativeMenu/createNativeMenu.mjs.map +1 -0
  106. package/dist/jsx/createNativeMenu/createNativeMenu.native.js +287 -0
  107. package/dist/jsx/createNativeMenu/createNativeMenu.native.js.map +1 -0
  108. package/dist/jsx/createNativeMenu/createNativeMenuTypes.js +1 -0
  109. package/dist/jsx/createNativeMenu/createNativeMenuTypes.js.map +6 -0
  110. package/dist/jsx/createNativeMenu/createNativeMenuTypes.mjs +2 -0
  111. package/dist/jsx/createNativeMenu/createNativeMenuTypes.mjs.map +1 -0
  112. package/dist/jsx/createNativeMenu/createNativeMenuTypes.native.js +19 -0
  113. package/dist/jsx/createNativeMenu/createNativeMenuTypes.native.js.map +1 -0
  114. package/dist/jsx/createNativeMenu/index.js +3 -0
  115. package/dist/jsx/createNativeMenu/index.js.map +6 -0
  116. package/dist/jsx/createNativeMenu/index.mjs +3 -0
  117. package/dist/jsx/createNativeMenu/index.mjs.map +1 -0
  118. package/dist/jsx/createNativeMenu/index.native.js +22 -0
  119. package/dist/jsx/createNativeMenu/index.native.js.map +1 -0
  120. package/dist/jsx/createNativeMenu/utils.js +47 -0
  121. package/dist/jsx/createNativeMenu/utils.js.map +6 -0
  122. package/dist/jsx/createNativeMenu/utils.mjs +29 -0
  123. package/dist/jsx/createNativeMenu/utils.mjs.map +1 -0
  124. package/dist/jsx/createNativeMenu/utils.native.js +94 -0
  125. package/dist/jsx/createNativeMenu/utils.native.js.map +1 -0
  126. package/dist/jsx/createNativeMenu/withNativeMenu.js +15 -0
  127. package/dist/jsx/createNativeMenu/withNativeMenu.js.map +6 -0
  128. package/dist/jsx/createNativeMenu/withNativeMenu.mjs +14 -0
  129. package/dist/jsx/createNativeMenu/withNativeMenu.mjs.map +1 -0
  130. package/dist/jsx/createNativeMenu/withNativeMenu.native.js +43 -0
  131. package/dist/jsx/createNativeMenu/withNativeMenu.native.js.map +1 -0
  132. package/dist/jsx/index.js +8 -0
  133. package/dist/jsx/index.js.map +6 -0
  134. package/dist/jsx/index.mjs +6 -0
  135. package/dist/jsx/index.mjs.map +1 -0
  136. package/dist/jsx/index.native.js +33 -0
  137. package/dist/jsx/index.native.js.map +1 -0
  138. package/package.json +80 -0
  139. package/src/MenuPredefined.tsx +195 -0
  140. package/src/createBaseMenu.tsx +1703 -0
  141. package/src/createNativeMenu/createNativeMenu.tsx +372 -0
  142. package/src/createNativeMenu/createNativeMenuTypes.ts +214 -0
  143. package/src/createNativeMenu/index.tsx +7 -0
  144. package/src/createNativeMenu/utils.tsx +150 -0
  145. package/src/createNativeMenu/withNativeMenu.tsx +38 -0
  146. package/src/index.tsx +9 -0
  147. package/types/MenuPredefined.d.ts +28 -0
  148. package/types/MenuPredefined.d.ts.map +1 -0
  149. package/types/createBaseMenu.d.ts +207 -0
  150. package/types/createBaseMenu.d.ts.map +1 -0
  151. package/types/createNativeMenu/createNativeMenu.d.ts +42 -0
  152. package/types/createNativeMenu/createNativeMenu.d.ts.map +1 -0
  153. package/types/createNativeMenu/createNativeMenuTypes.d.ts +188 -0
  154. package/types/createNativeMenu/createNativeMenuTypes.d.ts.map +1 -0
  155. package/types/createNativeMenu/index.d.ts +4 -0
  156. package/types/createNativeMenu/index.d.ts.map +1 -0
  157. package/types/createNativeMenu/utils.d.ts +38 -0
  158. package/types/createNativeMenu/utils.d.ts.map +1 -0
  159. package/types/createNativeMenu/withNativeMenu.d.ts +9 -0
  160. package/types/createNativeMenu/withNativeMenu.d.ts.map +1 -0
  161. package/types/index.d.ts +6 -0
  162. package/types/index.d.ts.map +1 -0
@@ -0,0 +1,1703 @@
1
+ import { Animate } from '@tamagui/animate'
2
+ import { AnimatePresence as Presence } from '@tamagui/animate-presence'
3
+ import { createCollection } from '@tamagui/collection'
4
+ import {
5
+ Dismissable as DismissableLayer,
6
+ dispatchDiscreteCustomEvent,
7
+ } from '@tamagui/dismissable'
8
+ import { useFocusGuards } from '@tamagui/focus-guard'
9
+ import { FocusScope } from '@tamagui/focus-scope'
10
+ import type { PopperContentProps } from '@tamagui/popper'
11
+ import * as PopperPrimitive from '@tamagui/popper'
12
+ import { Portal as PortalPrimitive } from '@tamagui/portal'
13
+ import { RemoveScroll } from '@tamagui/remove-scroll'
14
+ import type { RovingFocusGroupProps } from '@tamagui/roving-focus'
15
+ import { RovingFocusGroup } from '@tamagui/roving-focus'
16
+ import { useCallbackRef } from '@tamagui/use-callback-ref'
17
+ import { useDirection } from '@tamagui/use-direction'
18
+ import type { TamaguiComponent, TextProps } from '@tamagui/web'
19
+ import {
20
+ type Stack,
21
+ type ViewProps,
22
+ Text,
23
+ View,
24
+ composeEventHandlers,
25
+ composeRefs,
26
+ createStyledContext,
27
+ isAndroid,
28
+ isWeb,
29
+ useComposedRefs,
30
+ withStaticProperties,
31
+ } from '@tamagui/web'
32
+ import type { TamaguiElement } from '@tamagui/web/types'
33
+ import * as React from 'react'
34
+ import { useId } from 'react'
35
+ import type { Image, ImageProps } from 'react-native'
36
+
37
+ import { MenuPredefined } from './MenuPredefined'
38
+
39
+ type Direction = 'ltr' | 'rtl'
40
+
41
+ function whenMouse<E>(
42
+ handler: React.PointerEventHandler<E>
43
+ ): React.PointerEventHandler<E> {
44
+ return (event) => (event.pointerType === 'mouse' ? handler(event) : undefined)
45
+ }
46
+
47
+ const SELECTION_KEYS = ['Enter', ' ']
48
+ const FIRST_KEYS = ['ArrowDown', 'PageUp', 'Home']
49
+ const LAST_KEYS = ['ArrowUp', 'PageDown', 'End']
50
+ const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS]
51
+ const SUB_OPEN_KEYS: Record<Direction, string[]> = {
52
+ ltr: [...SELECTION_KEYS, 'ArrowRight'],
53
+ rtl: [...SELECTION_KEYS, 'ArrowLeft'],
54
+ }
55
+ const SUB_CLOSE_KEYS: Record<Direction, string[]> = {
56
+ ltr: ['ArrowLeft'],
57
+ rtl: ['ArrowRight'],
58
+ }
59
+
60
+ /* -------------------------------------------------------------------------------------------------
61
+ * Menu
62
+ * -----------------------------------------------------------------------------------------------*/
63
+
64
+ const MENU_NAME = 'Menu'
65
+
66
+ type ItemData = { disabled: boolean; textValue: string }
67
+
68
+ type ScopedProps<P> = P & { scope?: string }
69
+
70
+ type MenuContextValue = {
71
+ open: boolean
72
+ onOpenChange(open: boolean): void
73
+ content: MenuContentElement | null
74
+ onContentChange(content: MenuContentElement | null): void
75
+ }
76
+
77
+ type MenuRootContextValue = {
78
+ onClose(): void
79
+ isUsingKeyboardRef: React.RefObject<boolean>
80
+ dir: Direction
81
+ modal: boolean
82
+ }
83
+
84
+ interface MenuBaseProps extends PopperPrimitive.PopperProps {
85
+ children?: React.ReactNode
86
+ open?: boolean
87
+ onOpenChange?(open: boolean): void
88
+ dir?: Direction
89
+ modal?: boolean
90
+ native?: boolean
91
+ }
92
+
93
+ /* -------------------------------------------------------------------------------------------------
94
+ * MenuAnchor
95
+ * -----------------------------------------------------------------------------------------------*/
96
+
97
+ // type MenuAnchorElement = React.ElementRef<typeof PopperPrimitive.PopperAnchor>
98
+ type PopperAnchorProps = React.ComponentPropsWithoutRef<
99
+ typeof PopperPrimitive.PopperAnchor
100
+ >
101
+ interface MenuAnchorProps extends PopperAnchorProps {}
102
+
103
+ /* -------------------------------------------------------------------------------------------------
104
+ * MenuPortal
105
+ * -----------------------------------------------------------------------------------------------*/
106
+
107
+ type PortalContextValue = { forceMount?: true }
108
+
109
+ interface MenuPortalProps {
110
+ children?: React.ReactNode
111
+ /**
112
+ * Used to force mounting when more control is needed. Useful when
113
+ * controlling animation with React animation libraries.
114
+ */
115
+ forceMount?: true
116
+ zIndex?: number
117
+ }
118
+
119
+ /* -------------------------------------------------------------------------------------------------
120
+ * MenuContent
121
+ * -----------------------------------------------------------------------------------------------*/
122
+
123
+ type MenuContentContextValue = {
124
+ onItemEnter(event: React.PointerEvent): void
125
+ onItemLeave(event: React.PointerEvent): void
126
+ onTriggerLeave(event: React.PointerEvent): void
127
+ searchRef: React.RefObject<string>
128
+ pointerGraceTimerRef: React.MutableRefObject<number>
129
+ onPointerGraceIntentChange(intent: GraceIntent | null): void
130
+ }
131
+
132
+ type MenuContentElement = MenuRootContentTypeElement
133
+ /**
134
+ * We purposefully don't union MenuRootContent and MenuSubContent props here because
135
+ * they have conflicting prop types. We agreed that we would allow MenuSubContent to
136
+ * accept props that it would just ignore.
137
+ */
138
+ interface MenuContentProps extends MenuRootContentTypeProps {
139
+ /**
140
+ * Used to force mounting when more control is needed. Useful when
141
+ * controlling animation with React animation libraries.
142
+ */
143
+ forceMount?: true
144
+ }
145
+
146
+ /* ---------------------------------------------------------------------------------------------- */
147
+
148
+ type MenuRootContentTypeElement = MenuContentImplElement
149
+ interface MenuRootContentTypeProps
150
+ extends Omit<MenuContentImplProps, keyof MenuContentImplPrivateProps> {}
151
+
152
+ /* ---------------------------------------------------------------------------------------------- */
153
+
154
+ type MenuContentImplElement = React.ElementRef<typeof PopperPrimitive.PopperContent>
155
+ type FocusScopeProps = React.ComponentPropsWithoutRef<typeof FocusScope>
156
+ type DismissableLayerProps = React.ComponentPropsWithoutRef<typeof DismissableLayer>
157
+ type MenuContentImplPrivateProps = {
158
+ onOpenAutoFocus?: FocusScopeProps['onMountAutoFocus']
159
+ onDismiss?: DismissableLayerProps['onDismiss']
160
+ disableOutsidePointerEvents?: DismissableLayerProps['disableOutsidePointerEvents']
161
+
162
+ /**
163
+ * Whether scrolling outside the `MenuContent` should be prevented
164
+ * (default: `false`)
165
+ */
166
+ disableOutsideScroll?: boolean
167
+
168
+ /**
169
+ * Whether focus should be trapped within the `MenuContent`
170
+ * (default: false)
171
+ */
172
+ trapFocus?: FocusScopeProps['trapped']
173
+ }
174
+ interface MenuContentImplProps
175
+ extends MenuContentImplPrivateProps,
176
+ Omit<PopperContentProps, 'dir' | 'onPlaced'> {
177
+ /**
178
+ * Event handler called when auto-focusing on close.
179
+ * Can be prevented.
180
+ */
181
+ onCloseAutoFocus?: FocusScopeProps['onUnmountAutoFocus']
182
+
183
+ /**
184
+ * Whether keyboard navigation should loop around
185
+ * @defaultValue false
186
+ */
187
+ loop?: RovingFocusGroupProps['loop']
188
+
189
+ onEntryFocus?: RovingFocusGroupProps['onEntryFocus']
190
+ onEscapeKeyDown?: DismissableLayerProps['onEscapeKeyDown']
191
+ onPointerDownOutside?: DismissableLayerProps['onPointerDownOutside']
192
+ onFocusOutside?: DismissableLayerProps['onFocusOutside']
193
+ onInteractOutside?: DismissableLayerProps['onInteractOutside']
194
+ }
195
+
196
+ type StyleableMenuContentProps = MenuContentImplProps & ViewProps
197
+
198
+ interface MenuGroupProps extends ViewProps {}
199
+
200
+ /* -------------------------------------------------------------------------------------------------
201
+ * MenuLabel
202
+ * -----------------------------------------------------------------------------------------------*/
203
+
204
+ interface MenuLabelProps extends ViewProps {}
205
+
206
+ type MenuItemElement = MenuItemImplElement
207
+ interface MenuItemProps extends Omit<MenuItemImplProps, 'onSelect'> {
208
+ onSelect?: (event: Event) => void
209
+ unstyled?: boolean
210
+ }
211
+
212
+ type MenuItemImplElement = TamaguiElement
213
+
214
+ interface MenuItemImplProps extends ViewProps {
215
+ disabled?: boolean
216
+ textValue?: string
217
+ unstyled?: boolean
218
+ }
219
+
220
+ /* -------------------------------------------------------------------------------------------------
221
+ * MenuItemTitle
222
+ * -----------------------------------------------------------------------------------------------*/
223
+ interface MenuItemTitleProps extends TextProps {}
224
+ /* ---------------------------------------------------------------------------------------------- */
225
+
226
+ /* -------------------------------------------------------------------------------------------------
227
+ * MenuItemSubTitle
228
+ * -----------------------------------------------------------------------------------------------*/
229
+ interface MenuItemSubTitleProps extends TextProps {}
230
+
231
+ /* ---------------------------------------------------------------------------------------------- */
232
+
233
+ /* -------------------------------------------------------------------------------------------------
234
+ * MenuItemIcon
235
+ * -----------------------------------------------------------------------------------------------*/
236
+ type MenuItemIconProps = ViewProps
237
+
238
+ /* ---------------------------------------------------------------------------------------------- */
239
+
240
+ /* -------------------------------------------------------------------------------------------------
241
+ * MenuCheckboxItem
242
+ * -----------------------------------------------------------------------------------------------*/
243
+
244
+ type CheckedState = boolean | 'indeterminate'
245
+
246
+ interface MenuCheckboxItemProps extends MenuItemProps {
247
+ checked?: CheckedState
248
+ // `onCheckedChange` can never be called with `"indeterminate"` from the inside
249
+ onCheckedChange?: (checked: boolean) => void
250
+ }
251
+
252
+ /* -------------------------------------------------------------------------------------------------
253
+ * MenuRadioGroup
254
+ * -----------------------------------------------------------------------------------------------*/
255
+
256
+ // type MenuRadioGroupElement = React.ElementRef<typeof MenuGroup>
257
+ interface MenuRadioGroupProps extends MenuGroupProps {
258
+ value?: string
259
+ onValueChange?: (value: string) => void
260
+ }
261
+
262
+ /* -------------------------------------------------------------------------------------------------
263
+ * MenuRadioItem
264
+ * -----------------------------------------------------------------------------------------------*/
265
+
266
+ // type MenuRadioItemElement = React.ElementRef<typeof MenuItem>
267
+ interface MenuRadioItemProps extends MenuItemProps {
268
+ value: string
269
+ }
270
+
271
+ /* -------------------------------------------------------------------------------------------------
272
+ * MenuItemIndicator
273
+ * -----------------------------------------------------------------------------------------------*/
274
+
275
+ type CheckboxContextValue = { checked: CheckedState }
276
+
277
+ // type MenuItemIndicatorElement = React.ElementRef<typeof Stack>
278
+ type PrimitiveSpanProps = React.ComponentPropsWithoutRef<typeof Stack>
279
+ interface MenuItemIndicatorProps extends PrimitiveSpanProps {
280
+ /**
281
+ * Used to force mounting when more control is needed. Useful when
282
+ * controlling animation with React animation libraries.
283
+ */
284
+ forceMount?: true
285
+ }
286
+
287
+ /* -------------------------------------------------------------------------------------------------
288
+ * MenuSeparator
289
+ * -----------------------------------------------------------------------------------------------*/
290
+
291
+ // type MenuSeparatorElement = React.ElementRef<typeof Stack>
292
+ interface MenuSeparatorProps extends ViewProps {}
293
+
294
+ /* -------------------------------------------------------------------------------------------------
295
+ * MenuArrow
296
+ * -----------------------------------------------------------------------------------------------*/
297
+
298
+ // type MenuArrowElement = React.ElementRef<typeof PopperPrimitive.PopperArrow>
299
+ type PopperArrowProps = React.ComponentPropsWithoutRef<typeof PopperPrimitive.PopperArrow>
300
+ interface MenuArrowProps extends PopperArrowProps {
301
+ unstyled?: boolean
302
+ }
303
+
304
+ /* -------------------------------------------------------------------------------------------------
305
+ * MenuSub
306
+ * -----------------------------------------------------------------------------------------------*/
307
+
308
+ type MenuSubContextValue = {
309
+ contentId: string
310
+ triggerId: string
311
+ trigger: MenuSubTriggerElement | null
312
+ onTriggerChange(trigger: MenuSubTriggerElement | null): void
313
+ }
314
+
315
+ export interface MenuSubProps extends PopperPrimitive.PopperProps {
316
+ children?: React.ReactNode
317
+ open?: boolean
318
+ onOpenChange?(open: boolean): void
319
+ }
320
+
321
+ /* -------------------------------------------------------------------------------------------------
322
+ * MenuSubTrigger
323
+ * -----------------------------------------------------------------------------------------------*/
324
+
325
+ type MenuSubTriggerElement = MenuItemImplElement
326
+ interface MenuSubTriggerProps extends MenuItemImplProps {}
327
+
328
+ /* -------------------------------------------------------------------------------------------------
329
+ * MenuSubContent
330
+ * -----------------------------------------------------------------------------------------------*/
331
+
332
+ export type MenuSubContentElement = MenuContentImplElement
333
+ export interface MenuSubContentProps
334
+ extends Omit<
335
+ MenuContentImplProps,
336
+ | keyof MenuContentImplPrivateProps
337
+ | 'onCloseAutoFocus'
338
+ | 'onEntryFocus'
339
+ | 'side'
340
+ | 'align'
341
+ > {
342
+ /**
343
+ * Used to force mounting when more control is needed. Useful when
344
+ * controlling animation with React animation libraries.
345
+ */
346
+ forceMount?: true
347
+ }
348
+
349
+ type Point = { x: number; y: number }
350
+ type Polygon = Point[]
351
+ type Side = 'left' | 'right'
352
+ type GraceIntent = { area: Polygon; side: Side }
353
+
354
+ /* -------------------------------------------------------------------------------------------------
355
+ * Menu
356
+ * -----------------------------------------------------------------------------------------------*/
357
+
358
+ const [Collection, useCollection] = createCollection<MenuItemElement, ItemData>(MENU_NAME)
359
+
360
+ const { Provider: MenuProvider, useStyledContext: useMenuContext } =
361
+ createStyledContext<MenuContextValue>()
362
+
363
+ const { Provider: MenuRootProvider, useStyledContext: useMenuRootContext } =
364
+ createStyledContext<MenuRootContextValue>()
365
+
366
+ const MENU_CONTEXT = 'MenuContext'
367
+
368
+ export type CreateBaseMenuProps = {
369
+ Item?: TamaguiComponent
370
+ MenuGroup?: TamaguiComponent
371
+ Title?: TamaguiComponent
372
+ SubTitle?: TamaguiComponent
373
+ Image?: React.ElementType
374
+ Icon?: TamaguiComponent
375
+ Indicator?: TamaguiComponent
376
+ Separator?: TamaguiComponent
377
+ Label?: TamaguiComponent
378
+ }
379
+
380
+ export function createBaseMenu({
381
+ Item: _Item = MenuPredefined.MenuItem,
382
+ Title: _Title = MenuPredefined.Title,
383
+ SubTitle: _SubTitle = MenuPredefined.SubTitle,
384
+ Image: _Image = MenuPredefined.MenuImage,
385
+ Icon: _Icon = MenuPredefined.MenuIcon,
386
+ Indicator: _Indicator = MenuPredefined.MenuIndicator,
387
+ Separator: _Separator = MenuPredefined.MenuSeparator,
388
+ MenuGroup: _MenuGroup = MenuPredefined.MenuGroup,
389
+ Label: _Label = MenuPredefined.MenuLabel,
390
+ }: CreateBaseMenuProps) {
391
+ const MenuComp = (props: ScopedProps<MenuBaseProps>) => {
392
+ const {
393
+ scope = MENU_CONTEXT,
394
+ open = false,
395
+ children,
396
+ dir,
397
+ onOpenChange,
398
+ modal = true,
399
+ ...rest
400
+ } = props
401
+ const [content, setContent] = React.useState<MenuContentElement | null>(null)
402
+ const isUsingKeyboardRef = React.useRef(false)
403
+ const handleOpenChange = useCallbackRef(onOpenChange)
404
+ const direction = useDirection(dir)
405
+
406
+ if (isWeb) {
407
+ React.useEffect(() => {
408
+ // Capture phase ensures we set the boolean before any side effects execute
409
+ // in response to the key or pointer event as they might depend on this value.
410
+
411
+ const handleKeyDown = () => {
412
+ isUsingKeyboardRef.current = true
413
+ document.addEventListener('pointerdown', handlePointer, {
414
+ capture: true,
415
+ once: true,
416
+ })
417
+ document.addEventListener('pointermove', handlePointer, {
418
+ capture: true,
419
+ once: true,
420
+ })
421
+ }
422
+ const handlePointer = () => (isUsingKeyboardRef.current = false)
423
+ document.addEventListener('keydown', handleKeyDown, { capture: true })
424
+ return () => {
425
+ document.removeEventListener('keydown', handleKeyDown, { capture: true })
426
+ document.removeEventListener('pointerdown', handlePointer, { capture: true })
427
+ document.removeEventListener('pointermove', handlePointer, { capture: true })
428
+ }
429
+ }, [])
430
+ }
431
+
432
+ return (
433
+ <PopperPrimitive.Popper scope={scope} {...rest}>
434
+ <MenuProvider
435
+ scope={scope}
436
+ open={open}
437
+ onOpenChange={handleOpenChange}
438
+ content={content}
439
+ onContentChange={setContent}
440
+ >
441
+ <MenuRootProvider
442
+ scope={scope}
443
+ onClose={React.useCallback(() => handleOpenChange(false), [handleOpenChange])}
444
+ isUsingKeyboardRef={isUsingKeyboardRef}
445
+ dir={direction}
446
+ modal={modal}
447
+ >
448
+ {/** this provider is just to avoid crashing when using useSubMenuContext() inside MenuPortal */}
449
+ <MenuSubProvider scope={scope}>{children}</MenuSubProvider>
450
+ </MenuRootProvider>
451
+ </MenuProvider>
452
+ </PopperPrimitive.Popper>
453
+ )
454
+ }
455
+
456
+ const RepropagateMenuAndMenuRootProvider = (
457
+ props: ScopedProps<{
458
+ menuContext: any
459
+ rootContext: any
460
+ popperContext: any
461
+ menuSubContext: any
462
+ children: React.ReactNode
463
+ }>
464
+ ) => {
465
+ const {
466
+ scope = MENU_CONTEXT,
467
+ menuContext,
468
+ rootContext,
469
+ popperContext,
470
+ menuSubContext,
471
+ children,
472
+ } = props
473
+
474
+ return (
475
+ <PopperPrimitive.PopperProvider {...popperContext} scope={scope}>
476
+ <MenuProvider scope={scope} {...menuContext}>
477
+ <MenuRootProvider scope={scope} {...rootContext}>
478
+ {menuSubContext ? (
479
+ <MenuSubProvider scope={scope} {...menuSubContext}>
480
+ {children}
481
+ </MenuSubProvider>
482
+ ) : (
483
+ children
484
+ )}
485
+ </MenuRootProvider>
486
+ </MenuProvider>
487
+ </PopperPrimitive.PopperProvider>
488
+ )
489
+ }
490
+
491
+ MenuComp.displayName = MENU_NAME
492
+
493
+ /* -------------------------------------------------------------------------------------------------
494
+ * MenuAnchor
495
+ * -----------------------------------------------------------------------------------------------*/
496
+
497
+ const ANCHOR_NAME = 'MenuAnchor'
498
+
499
+ const MenuAnchor = (props: MenuAnchorProps) => {
500
+ return <PopperPrimitive.PopperAnchor scope={MENU_CONTEXT} {...props} />
501
+ }
502
+
503
+ MenuAnchor.displayName = ANCHOR_NAME
504
+
505
+ /* -------------------------------------------------------------------------------------------------
506
+ * MenuPortal
507
+ * -----------------------------------------------------------------------------------------------*/
508
+
509
+ const PORTAL_NAME = 'MenuPortal'
510
+
511
+ const { Provider: PortalProvider, useStyledContext: usePortalContext } =
512
+ createStyledContext<PortalContextValue>(undefined, 'Portal')
513
+
514
+ const MenuPortal = (props: ScopedProps<MenuPortalProps>) => {
515
+ const { scope = MENU_CONTEXT, forceMount, zIndex, children } = props
516
+ const menuContext = useMenuContext(scope)
517
+ const rootContext = useMenuRootContext(scope)
518
+ const popperContext = PopperPrimitive.usePopperContext(scope)
519
+ const menuSubContext = useMenuSubContext(scope)
520
+ const content = isAndroid ? (
521
+ <RepropagateMenuAndMenuRootProvider
522
+ menuContext={menuContext}
523
+ rootContext={rootContext}
524
+ popperContext={popperContext}
525
+ menuSubContext={menuSubContext}
526
+ scope={scope}
527
+ >
528
+ {children}
529
+ </RepropagateMenuAndMenuRootProvider>
530
+ ) : (
531
+ children
532
+ )
533
+
534
+ return (
535
+ <Animate type="presence" present={forceMount || menuContext.open}>
536
+ <PortalPrimitive>
537
+ <>
538
+ <PortalProvider scope={scope} forceMount={forceMount}>
539
+ <View zIndex={zIndex || 100} inset={0} position="absolute">
540
+ {!!menuContext.open && !isWeb && (
541
+ <View
542
+ inset={0}
543
+ position="absolute"
544
+ onPress={() => menuContext.onOpenChange(!menuContext.open)}
545
+ />
546
+ )}
547
+ {content}
548
+ </View>
549
+ </PortalProvider>
550
+ </>
551
+ </PortalPrimitive>
552
+ </Animate>
553
+ )
554
+ }
555
+
556
+ MenuPortal.displayName = PORTAL_NAME
557
+
558
+ /* -------------------------------------------------------------------------------------------------
559
+ * MenuContent
560
+ * -----------------------------------------------------------------------------------------------*/
561
+
562
+ const CONTENT_NAME = 'MenuContent'
563
+
564
+ const { Provider: MenuContentProvider, useStyledContext: useMenuContentContext } =
565
+ createStyledContext<MenuContentContextValue>()
566
+
567
+ const MenuContent = React.forwardRef<MenuContentElement, ScopedProps<MenuContentProps>>(
568
+ (props, forwardedRef) => {
569
+ const scope = props.scope || MENU_CONTEXT
570
+ const portalContext = usePortalContext(scope)
571
+ const { forceMount = portalContext.forceMount, ...contentProps } = props
572
+ const rootContext = useMenuRootContext(scope)
573
+
574
+ return (
575
+ <Collection.Provider scope={scope}>
576
+ <Collection.Slot scope={scope}>
577
+ {rootContext.modal ? (
578
+ <MenuRootContentModal {...contentProps} ref={forwardedRef} />
579
+ ) : (
580
+ <MenuRootContentNonModal {...contentProps} ref={forwardedRef} />
581
+ )}
582
+ </Collection.Slot>
583
+ </Collection.Provider>
584
+ )
585
+ }
586
+ )
587
+
588
+ /* ---------------------------------------------------------------------------------------------- */
589
+
590
+ const MenuRootContentModal = React.forwardRef<
591
+ MenuRootContentTypeElement,
592
+ ScopedProps<MenuRootContentTypeProps>
593
+ >((props, forwardedRef) => {
594
+ const scope = props.scope || MENU_CONTEXT
595
+ const context = useMenuContext(scope)
596
+ const ref = React.useRef<MenuRootContentTypeElement>(null)
597
+ const composedRefs = useComposedRefs(forwardedRef, ref)
598
+
599
+ // Hide everything from ARIA except the `MenuContent`
600
+ // React.useEffect(() => {
601
+ // const content = ref.current
602
+ // // FIXME: find a solution for native
603
+ // if (content && isWeb) return hideOthers(content as HTMLElement)
604
+ // }, [])
605
+
606
+ return (
607
+ <MenuContentImpl
608
+ {...props}
609
+ scope={scope}
610
+ ref={composedRefs}
611
+ // we make sure we're not trapping once it's been closed
612
+ // (closed !== unmounted when animating out)
613
+ trapFocus={context.open}
614
+ // make sure to only disable pointer events when open
615
+ // this avoids blocking interactions while animating out
616
+ disableOutsidePointerEvents={context.open}
617
+ disableOutsideScroll
618
+ // When focus is trapped, a `focusout` event may still happen.
619
+ // We make sure we don't trigger our `onDismiss` in such case.
620
+ onFocusOutside={composeEventHandlers(
621
+ props.onFocusOutside,
622
+ (event: Event) => event.preventDefault(),
623
+ { checkDefaultPrevented: false }
624
+ )}
625
+ onDismiss={() => context.onOpenChange(false)}
626
+ />
627
+ )
628
+ })
629
+
630
+ const MenuRootContentNonModal = React.forwardRef<
631
+ MenuRootContentTypeElement,
632
+ ScopedProps<MenuRootContentTypeProps>
633
+ >((props, forwardedRef) => {
634
+ const scope = props.scope || MENU_CONTEXT
635
+ const context = useMenuContext(scope)
636
+ return (
637
+ <MenuContentImpl
638
+ {...props}
639
+ scope={scope}
640
+ ref={forwardedRef}
641
+ trapFocus={false}
642
+ disableOutsidePointerEvents={false}
643
+ disableOutsideScroll={false}
644
+ onDismiss={() => context.onOpenChange(false)}
645
+ />
646
+ )
647
+ })
648
+
649
+ const MenuContentImpl = React.forwardRef<
650
+ MenuContentImplElement,
651
+ ScopedProps<StyleableMenuContentProps>
652
+ >((props, forwardedRef) => {
653
+ const {
654
+ scope = MENU_CONTEXT,
655
+ loop = false,
656
+ trapFocus,
657
+ onOpenAutoFocus,
658
+ onCloseAutoFocus,
659
+ disableOutsidePointerEvents,
660
+ onEntryFocus,
661
+ onEscapeKeyDown,
662
+ onPointerDownOutside,
663
+ onFocusOutside,
664
+ onInteractOutside,
665
+ onDismiss,
666
+ disableOutsideScroll,
667
+ unstyled = process.env.TAMAGUI_HEADLESS === '1',
668
+ ...contentProps
669
+ } = props
670
+
671
+ const context = useMenuContext(scope)
672
+ const rootContext = useMenuRootContext(scope)
673
+ const getItems = useCollection(scope)
674
+ const [currentItemId, setCurrentItemId] = React.useState<string | null>(null)
675
+ const contentRef = React.useRef<TamaguiElement>(null)
676
+ const composedRefs = useComposedRefs(
677
+ forwardedRef,
678
+ contentRef,
679
+ context.onContentChange
680
+ )
681
+ const timerRef = React.useRef<NodeJS.Timeout>(0 as unknown as NodeJS.Timeout)
682
+ const searchRef = React.useRef('')
683
+ const pointerGraceTimerRef = React.useRef(0)
684
+ const pointerGraceIntentRef = React.useRef<GraceIntent | null>(null)
685
+ const pointerDirRef = React.useRef<Side>('right')
686
+ const lastPointerXRef = React.useRef(0)
687
+
688
+ const handleTypeaheadSearch = (key: string) => {
689
+ const search = searchRef.current + key
690
+ const items = getItems().filter((item) => !item.disabled)
691
+ const currentItem = document.activeElement
692
+ const currentMatch = items.find(
693
+ (item) => item.ref.current === currentItem
694
+ )?.textValue
695
+ const values = items.map((item) => item.textValue)
696
+ const nextMatch = getNextMatch(values, search, currentMatch)
697
+ const newItem = items.find((item) => item.textValue === nextMatch)?.ref.current
698
+
699
+ // Reset `searchRef` 1 second after it was last updated
700
+ ;(function updateSearch(value: string) {
701
+ searchRef.current = value
702
+ clearTimeout(timerRef.current)
703
+ if (value !== '') timerRef.current = setTimeout(() => updateSearch(''), 1000)
704
+ })(search)
705
+
706
+ if (newItem) {
707
+ /**
708
+ * Imperative focus during keydown is risky so we prevent React's batching updates
709
+ * to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
710
+ */
711
+ setTimeout(() => (newItem as HTMLElement).focus())
712
+ }
713
+ }
714
+
715
+ React.useEffect(() => {
716
+ return () => clearTimeout(timerRef.current)
717
+ }, [])
718
+
719
+ // Make sure the whole tree has focus guards as our `MenuContent` may be
720
+ // the last element in the DOM (beacuse of the `Portal`)
721
+ if (isWeb) {
722
+ useFocusGuards()
723
+ }
724
+
725
+ const isPointerMovingToSubmenu = React.useCallback((event: React.PointerEvent) => {
726
+ const isMovingTowards =
727
+ pointerDirRef.current === pointerGraceIntentRef.current?.side
728
+ return (
729
+ isMovingTowards &&
730
+ isPointerInGraceArea(event, pointerGraceIntentRef.current?.area)
731
+ )
732
+ }, [])
733
+
734
+ const content = (
735
+ <PopperPrimitive.PopperContent
736
+ role="menu"
737
+ {...(!unstyled && {
738
+ elevation: 30,
739
+ paddingVertical: '$2',
740
+ backgroundColor: '$background',
741
+ borderColor: '$borderColor',
742
+ outlineWidth: 0,
743
+ })}
744
+ aria-orientation="vertical"
745
+ data-state={getOpenState(context.open)}
746
+ data-tamagui-menu-content=""
747
+ // TODO
748
+ // @ts-ignore
749
+ dir={rootContext.dir}
750
+ scope={scope || MENU_CONTEXT}
751
+ {...contentProps}
752
+ ref={composedRefs}
753
+ className={contentProps.animation ? undefined : contentProps.className}
754
+ {...(isWeb
755
+ ? {
756
+ onKeyDown: composeEventHandlers(contentProps.onKeyDown, (event) => {
757
+ // submenu key events bubble through portals. We only care about keys in this menu.
758
+ const target = event.target as HTMLElement
759
+ const isKeyDownInside =
760
+ target.closest('[data-tamagui-menu-content]') === event.currentTarget
761
+ const isModifierKey = event.ctrlKey || event.altKey || event.metaKey
762
+ const isCharacterKey = event.key.length === 1
763
+ if (isKeyDownInside) {
764
+ // menus should not be navigated using tab key so we prevent it
765
+ if (event.key === 'Tab') event.preventDefault()
766
+ if (!isModifierKey && isCharacterKey) handleTypeaheadSearch(event.key)
767
+ }
768
+ // focus first/last item based on key pressed
769
+ const content = contentRef.current
770
+ if (event.target !== content) return
771
+ if (!FIRST_LAST_KEYS.includes(event.key)) return
772
+ event.preventDefault()
773
+ const items = getItems().filter((item) => !item.disabled)
774
+ const candidateNodes = items.map((item) => item.ref.current!)
775
+ if (LAST_KEYS.includes(event.key)) candidateNodes.reverse()
776
+ focusFirst(candidateNodes as HTMLElement[])
777
+ }),
778
+ // TODO
779
+ // @ts-ignore
780
+ onBlur: composeEventHandlers(props.onBlur, (event: MouseEvent) => {
781
+ // clear search buffer when leaving the menu
782
+ // @ts-ignore
783
+ if (!event.currentTarget?.contains(event.target)) {
784
+ clearTimeout(timerRef.current)
785
+ searchRef.current = ''
786
+ }
787
+ }),
788
+ // TODO
789
+ onPointerMove: composeEventHandlers(props.onPointerMove, (event) => {
790
+ if (event.pointerType !== 'mouse') return
791
+ const target = event.target as HTMLElement
792
+ const pointerXHasChanged = lastPointerXRef.current !== event.clientX
793
+
794
+ // We don't use `event.movementX` for this check because Safari will
795
+ // always return `0` on a pointer event.
796
+ // @ts-ignore
797
+ if (event.currentTarget?.contains(target) && pointerXHasChanged) {
798
+ const newDir =
799
+ event.clientX > lastPointerXRef.current ? 'right' : 'left'
800
+ pointerDirRef.current = newDir
801
+ lastPointerXRef.current = event.clientX
802
+ }
803
+ }),
804
+ }
805
+ : {})}
806
+ />
807
+ )
808
+
809
+ return (
810
+ <MenuContentProvider
811
+ scope={scope}
812
+ searchRef={searchRef}
813
+ onItemEnter={React.useCallback(
814
+ (event) => {
815
+ if (isPointerMovingToSubmenu(event)) event.preventDefault()
816
+ },
817
+ [isPointerMovingToSubmenu]
818
+ )}
819
+ onItemLeave={React.useCallback(
820
+ (event) => {
821
+ if (isPointerMovingToSubmenu(event)) return
822
+ contentRef.current?.focus()
823
+ setCurrentItemId(null)
824
+ },
825
+ [isPointerMovingToSubmenu]
826
+ )}
827
+ onTriggerLeave={React.useCallback(
828
+ (event) => {
829
+ if (isPointerMovingToSubmenu(event)) event.preventDefault()
830
+ },
831
+ [isPointerMovingToSubmenu]
832
+ )}
833
+ pointerGraceTimerRef={pointerGraceTimerRef}
834
+ onPointerGraceIntentChange={React.useCallback((intent) => {
835
+ pointerGraceIntentRef.current = intent
836
+ }, [])}
837
+ >
838
+ <RemoveScroll enabled={disableOutsideScroll}>
839
+ <FocusScope
840
+ asChild={false}
841
+ trapped={trapFocus}
842
+ onMountAutoFocus={composeEventHandlers(onOpenAutoFocus, (event) => {
843
+ // when opening, explicitly focus the content area only and leave
844
+ // `onEntryFocus` in control of focusing first item
845
+ event.preventDefault()
846
+ contentRef.current?.focus()
847
+ })}
848
+ onUnmountAutoFocus={onCloseAutoFocus}
849
+ >
850
+ <DismissableLayer
851
+ disableOutsidePointerEvents={disableOutsidePointerEvents}
852
+ onEscapeKeyDown={onEscapeKeyDown}
853
+ onPointerDownOutside={onPointerDownOutside}
854
+ onFocusOutside={onFocusOutside}
855
+ onInteractOutside={onInteractOutside}
856
+ onDismiss={onDismiss}
857
+ asChild
858
+ >
859
+ <RovingFocusGroup
860
+ asChild
861
+ __scopeRovingFocusGroup={scope || MENU_CONTEXT}
862
+ dir={rootContext.dir}
863
+ orientation="vertical"
864
+ loop={loop}
865
+ currentTabStopId={currentItemId}
866
+ onCurrentTabStopIdChange={setCurrentItemId}
867
+ onEntryFocus={composeEventHandlers(onEntryFocus, (event) => {
868
+ // only focus first item when using keyboard
869
+ if (!rootContext.isUsingKeyboardRef.current) event.preventDefault()
870
+ })}
871
+ >
872
+ {content}
873
+ </RovingFocusGroup>
874
+ </DismissableLayer>
875
+ </FocusScope>
876
+ </RemoveScroll>
877
+ </MenuContentProvider>
878
+ )
879
+ })
880
+
881
+ MenuContent.displayName = CONTENT_NAME
882
+
883
+ /* -------------------------------------------------------------------------------------------------
884
+ * MenuItem
885
+ * -----------------------------------------------------------------------------------------------*/
886
+
887
+ const ITEM_NAME = 'MenuItem'
888
+ const ITEM_SELECT = 'menu.itemSelect'
889
+
890
+ const MenuItem = React.forwardRef<TamaguiElement, ScopedProps<MenuItemProps>>(
891
+ (props, forwardedRef) => {
892
+ const {
893
+ disabled = false,
894
+ onSelect,
895
+ children,
896
+ scope = MENU_CONTEXT,
897
+ ...itemProps
898
+ } = props
899
+ const ref = React.useRef<HTMLDivElement>(null)
900
+ const rootContext = useMenuRootContext(scope)
901
+ const contentContext = useMenuContentContext(scope)
902
+ const composedRefs = useComposedRefs(forwardedRef, ref)
903
+ const isPointerDownRef = React.useRef(false)
904
+
905
+ const handleSelect = () => {
906
+ const menuItem = ref.current
907
+ if (!disabled && menuItem) {
908
+ if (isWeb) {
909
+ const itemSelectEvent = new CustomEvent(ITEM_SELECT, {
910
+ bubbles: true,
911
+ cancelable: true,
912
+ })
913
+ menuItem.addEventListener(ITEM_SELECT, (event) => onSelect?.(event), {
914
+ once: true,
915
+ })
916
+ dispatchDiscreteCustomEvent(menuItem, itemSelectEvent)
917
+ if (itemSelectEvent.defaultPrevented) {
918
+ isPointerDownRef.current = false
919
+ } else {
920
+ rootContext.onClose()
921
+ }
922
+ } else {
923
+ // TODO: find a better way to handle this on native
924
+ onSelect?.({ target: menuItem } as unknown as Event)
925
+ isPointerDownRef.current = false
926
+ rootContext.onClose()
927
+ }
928
+ }
929
+ }
930
+
931
+ const content = typeof children === 'string' ? <Text>{children}</Text> : children
932
+
933
+ return (
934
+ <MenuItemImpl
935
+ outlineStyle="none"
936
+ {...itemProps}
937
+ scope={scope}
938
+ // @ts-ignore
939
+ ref={composedRefs}
940
+ disabled={disabled}
941
+ onPress={composeEventHandlers(props.onPress, handleSelect)}
942
+ onPointerDown={(event) => {
943
+ props.onPointerDown?.(event)
944
+ isPointerDownRef.current = true
945
+ }}
946
+ onPointerUp={composeEventHandlers(props.onPointerUp, (event) => {
947
+ // Pointer down can move to a different menu item which should activate it on pointer up.
948
+ // We dispatch a click for selection to allow composition with click based triggers and to
949
+ // prevent Firefox from getting stuck in text selection mode when the menu closes.
950
+ if (isWeb) {
951
+ // @ts-ignore
952
+ if (!isPointerDownRef.current) event.currentTarget?.click()
953
+ }
954
+ })}
955
+ {...(isWeb
956
+ ? {
957
+ onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
958
+ const isTypingAhead = contentContext.searchRef.current !== ''
959
+ if (disabled || (isTypingAhead && event.key === ' ')) return
960
+ if (SELECTION_KEYS.includes(event.key)) {
961
+ // @ts-ignore
962
+ event.currentTarget?.click()
963
+ /**
964
+ * We prevent default browser behaviour for selection keys as they should trigger
965
+ * a selection only:
966
+ * - prevents space from scrolling the page.
967
+ * - if keydown causes focus to move, prevents keydown from firing on the new target.
968
+ */
969
+ event.preventDefault()
970
+ }
971
+ }),
972
+ }
973
+ : {})}
974
+ >
975
+ {content}
976
+ </MenuItemImpl>
977
+ )
978
+ }
979
+ )
980
+
981
+ const MenuItemImpl = React.forwardRef<
982
+ MenuItemImplElement,
983
+ ScopedProps<MenuItemImplProps>
984
+ >((props, forwardedRef) => {
985
+ const {
986
+ scope = MENU_CONTEXT,
987
+ disabled = false,
988
+ textValue,
989
+ unstyled = process.env.TAMAGUI_HEADLESS === '1',
990
+ ...itemProps
991
+ } = props
992
+ const contentContext = useMenuContentContext(scope)
993
+ const ref = React.useRef<TamaguiElement>(null)
994
+ const composedRefs = useComposedRefs(forwardedRef, ref)
995
+ const [isFocused, setIsFocused] = React.useState(false)
996
+
997
+ // get the item's `.textContent` as default strategy for typeahead `textValue`
998
+ const [textContent, setTextContent] = React.useState('')
999
+ if (isWeb) {
1000
+ React.useEffect(() => {
1001
+ const menuItem = ref.current
1002
+ if (menuItem) {
1003
+ // @ts-ignore
1004
+ setTextContent((menuItem.textContent ?? '').trim())
1005
+ }
1006
+ }, [itemProps.children])
1007
+ }
1008
+
1009
+ return (
1010
+ <Collection.ItemSlot
1011
+ scope={scope}
1012
+ disabled={disabled}
1013
+ textValue={textValue ?? textContent}
1014
+ >
1015
+ <RovingFocusGroup.Item
1016
+ asChild
1017
+ __scopeRovingFocusGroup={scope}
1018
+ focusable={!disabled}
1019
+ {...(!unstyled && {
1020
+ flexDirection: 'row',
1021
+ alignItems: 'center',
1022
+ })}
1023
+ {...itemProps}
1024
+ >
1025
+ <_Item
1026
+ {...(!unstyled && {
1027
+ hoverTheme: true,
1028
+ pressTheme: true,
1029
+ focusTheme: true,
1030
+ paddingVertical: '$2',
1031
+ paddingHorizontal: '$4',
1032
+ width: '100%',
1033
+ })}
1034
+ componentName={ITEM_NAME}
1035
+ role="menuitem"
1036
+ data-highlighted={isFocused ? '' : undefined}
1037
+ aria-disabled={disabled || undefined}
1038
+ data-disabled={disabled ? '' : undefined}
1039
+ {...itemProps}
1040
+ ref={composedRefs}
1041
+ /**
1042
+ * We focus items on `pointerMove` to achieve the following:
1043
+ *
1044
+ * - Mouse over an item (it focuses)
1045
+ * - Leave mouse where it is and use keyboard to focus a different item
1046
+ * - Wiggle mouse without it leaving previously focused item
1047
+ * - Previously focused item should re-focus
1048
+ *
1049
+ * If we used `mouseOver`/`mouseEnter` it would not re-focus when the mouse
1050
+ * wiggles. This is to match native menu implementation.
1051
+ */
1052
+ onPointerMove={composeEventHandlers(props.onPointerMove, (event) => {
1053
+ if (event.pointerType !== 'mouse') return
1054
+
1055
+ if (disabled) {
1056
+ // @ts-ignore
1057
+ contentContext.onItemLeave(event)
1058
+ } else {
1059
+ // @ts-ignore
1060
+ contentContext.onItemEnter(event)
1061
+ if (!event.defaultPrevented) {
1062
+ const item = event.currentTarget
1063
+ // @ts-ignore
1064
+ item.focus()
1065
+ }
1066
+ }
1067
+ })}
1068
+ onPointerLeave={composeEventHandlers(props.onPointerLeave, (event) => {
1069
+ contentContext.onItemLeave(event as any)
1070
+ })}
1071
+ onFocus={composeEventHandlers(props.onFocus, () => setIsFocused(true))}
1072
+ onBlur={composeEventHandlers(props.onBlur, () => setIsFocused(false))}
1073
+ />
1074
+ </RovingFocusGroup.Item>
1075
+ </Collection.ItemSlot>
1076
+ )
1077
+ })
1078
+
1079
+ MenuItem.displayName = ITEM_NAME
1080
+
1081
+ /* -------------------------------------------------------------------------------------------------
1082
+ * MenuItemTitle
1083
+ * -----------------------------------------------------------------------------------------------*/
1084
+ const ITEM_TITLE_NAME = 'MenuItemTitle'
1085
+ const MenuItemTitle = _Title.styleable((props, forwardedRef) => {
1086
+ return <_Title {...props} ref={forwardedRef} />
1087
+ })
1088
+
1089
+ MenuItemTitle.displayName = ITEM_TITLE_NAME
1090
+ /* ---------------------------------------------------------------------------------------------- */
1091
+
1092
+ /* -------------------------------------------------------------------------------------------------
1093
+ * MenuItemSubTitle
1094
+ * -----------------------------------------------------------------------------------------------*/
1095
+ const ITEM_SUB_TITLE_NAME = 'MenuItemSubTitle'
1096
+ const MenuItemSubTitle = _SubTitle.styleable((props, forwardedRef) => {
1097
+ return <_SubTitle {...props} ref={forwardedRef} />
1098
+ })
1099
+
1100
+ MenuItemSubTitle.displayName = ITEM_SUB_TITLE_NAME
1101
+
1102
+ /* ---------------------------------------------------------------------------------------------- */
1103
+
1104
+ /* -------------------------------------------------------------------------------------------------
1105
+ * MenuItemImage
1106
+ * -----------------------------------------------------------------------------------------------*/
1107
+ const ITEM_IMAGE = 'MenuItemImage'
1108
+ const MenuItemImage = React.forwardRef<Image, ImageProps>((props, forwardedRef) => {
1109
+ return <_Image {...props} ref={forwardedRef} />
1110
+ })
1111
+
1112
+ MenuItemImage.displayName = ITEM_IMAGE
1113
+
1114
+ /* ---------------------------------------------------------------------------------------------- */
1115
+
1116
+ /* -------------------------------------------------------------------------------------------------
1117
+ * MenuItemIcon
1118
+ * -----------------------------------------------------------------------------------------------*/
1119
+
1120
+ // TODO review why styleable was here
1121
+ const ITEM_ICON = 'MenuItemIcon'
1122
+ type MenuItemIconProps = ViewProps
1123
+ const MenuItemIcon = _Icon
1124
+ // .styleable((props: ThemeableStackProps, forwardedRef) => {
1125
+ // return <_Icon {...props} ref={forwardedRef} />
1126
+ // })
1127
+
1128
+ MenuItemIcon.displayName = ITEM_ICON
1129
+
1130
+ /* ---------------------------------------------------------------------------------------------- */
1131
+
1132
+ /* -------------------------------------------------------------------------------------------------
1133
+ * MenuCheckboxItem
1134
+ * -----------------------------------------------------------------------------------------------*/
1135
+
1136
+ const CHECKBOX_ITEM_NAME = 'MenuCheckboxItem'
1137
+
1138
+ const MenuCheckboxItem = React.forwardRef<
1139
+ TamaguiElement,
1140
+ ScopedProps<MenuCheckboxItemProps>
1141
+ >((props, forwardedRef) => {
1142
+ const {
1143
+ checked = false,
1144
+ onCheckedChange,
1145
+ scope = MENU_CONTEXT,
1146
+ ...checkboxItemProps
1147
+ } = props
1148
+ return (
1149
+ <ItemIndicatorProvider scope={scope} checked={checked}>
1150
+ <MenuItem
1151
+ componentName={CHECKBOX_ITEM_NAME}
1152
+ role={(isWeb ? 'menuitemcheckbox' : 'menuitem') as 'menuitem'}
1153
+ aria-checked={isIndeterminate(checked) ? 'mixed' : checked}
1154
+ {...checkboxItemProps}
1155
+ scope={scope}
1156
+ ref={forwardedRef}
1157
+ data-state={getCheckedState(checked)}
1158
+ onSelect={composeEventHandlers(
1159
+ checkboxItemProps.onSelect,
1160
+ () => onCheckedChange?.(isIndeterminate(checked) ? true : !checked),
1161
+ { checkDefaultPrevented: false }
1162
+ )}
1163
+ />
1164
+ </ItemIndicatorProvider>
1165
+ )
1166
+ })
1167
+
1168
+ MenuCheckboxItem.displayName = CHECKBOX_ITEM_NAME
1169
+ /* -------------------------------------------------------------------------------------------------
1170
+ * MenuRadioGroup
1171
+ * -----------------------------------------------------------------------------------------------*/
1172
+
1173
+ const RADIO_GROUP_NAME = 'MenuRadioGroup'
1174
+
1175
+ const { Provider: RadioGroupProvider, useStyledContext: useRadioGroupContext } =
1176
+ createStyledContext<MenuRadioGroupProps>()
1177
+
1178
+ const MenuRadioGroup = _MenuGroup.styleable<ScopedProps<MenuRadioGroupProps>>(
1179
+ (props, forwardedRef) => {
1180
+ const { value, onValueChange, scope = MENU_CONTEXT, ...groupProps } = props
1181
+ const handleValueChange = useCallbackRef(onValueChange)
1182
+ return (
1183
+ <RadioGroupProvider scope={scope} value={value} onValueChange={handleValueChange}>
1184
+ <_MenuGroup
1185
+ componentName={RADIO_GROUP_NAME}
1186
+ {...groupProps}
1187
+ ref={forwardedRef}
1188
+ />
1189
+ </RadioGroupProvider>
1190
+ )
1191
+ }
1192
+ )
1193
+
1194
+ MenuRadioGroup.displayName = RADIO_GROUP_NAME
1195
+ /* -------------------------------------------------------------------------------------------------
1196
+ * MenuRadioItem
1197
+ * -----------------------------------------------------------------------------------------------*/
1198
+
1199
+ const RADIO_ITEM_NAME = 'MenuRadioItem'
1200
+
1201
+ const MenuRadioItem = React.forwardRef<TamaguiElement, ScopedProps<MenuRadioItemProps>>(
1202
+ (props, forwardedRef) => {
1203
+ const { value, scope = MENU_CONTEXT, ...radioItemProps } = props
1204
+ const context = useRadioGroupContext(scope)
1205
+ const checked = value === context.value
1206
+ return (
1207
+ <ItemIndicatorProvider scope={scope} checked={checked}>
1208
+ <MenuItem
1209
+ componentName={RADIO_ITEM_NAME}
1210
+ {...radioItemProps}
1211
+ scope={scope}
1212
+ aria-checked={checked}
1213
+ ref={forwardedRef}
1214
+ role={(isWeb ? 'menuitemradio' : 'menuitem') as 'menuitem'}
1215
+ data-state={getCheckedState(checked)}
1216
+ onSelect={composeEventHandlers(
1217
+ radioItemProps.onSelect,
1218
+ () => context.onValueChange?.(value),
1219
+ { checkDefaultPrevented: false }
1220
+ )}
1221
+ />
1222
+ </ItemIndicatorProvider>
1223
+ )
1224
+ }
1225
+ )
1226
+
1227
+ MenuRadioItem.displayName = RADIO_ITEM_NAME
1228
+
1229
+ /* -------------------------------------------------------------------------------------------------
1230
+ * MenuItemIndicator
1231
+ * -----------------------------------------------------------------------------------------------*/
1232
+
1233
+ const ITEM_INDICATOR_NAME = 'MenuItemIndicator'
1234
+
1235
+ const { Provider: ItemIndicatorProvider, useStyledContext: useItemIndicatorContext } =
1236
+ createStyledContext<CheckboxContextValue>()
1237
+
1238
+ const MenuItemIndicator = _Indicator.styleable<ScopedProps<MenuItemIndicatorProps>>(
1239
+ (props, forwardedRef) => {
1240
+ const { scope = MENU_CONTEXT, forceMount, ...itemIndicatorProps } = props
1241
+ const indicatorContext = useItemIndicatorContext(scope)
1242
+ return (
1243
+ <Presence>
1244
+ {forceMount ||
1245
+ isIndeterminate(indicatorContext.checked) ||
1246
+ indicatorContext.checked === true ? (
1247
+ <_Indicator
1248
+ componentName={ITEM_INDICATOR_NAME}
1249
+ tag="span"
1250
+ {...itemIndicatorProps}
1251
+ ref={forwardedRef}
1252
+ data-state={getCheckedState(indicatorContext.checked)}
1253
+ />
1254
+ ) : null}
1255
+ </Presence>
1256
+ )
1257
+ }
1258
+ )
1259
+
1260
+ MenuItemIndicator.displayName = ITEM_INDICATOR_NAME
1261
+
1262
+ /* -------------------------------------------------------------------------------------------------
1263
+ * MenuArrow
1264
+ * -----------------------------------------------------------------------------------------------*/
1265
+
1266
+ // TODO this was styleable but it cant flatten anyways so likely fine just need to check
1267
+ const MenuArrow = React.forwardRef<TamaguiElement, MenuArrowProps>(
1268
+ function MenuArrow(props, forwardedRef) {
1269
+ const {
1270
+ scope = MENU_CONTEXT,
1271
+ unstyled = process.env.TAMAGUI_HEADLESS === '1',
1272
+ ...rest
1273
+ } = props
1274
+ return (
1275
+ <PopperPrimitive.PopperArrow
1276
+ scope={scope}
1277
+ componentName="PopperArrow"
1278
+ {...(!unstyled && {
1279
+ backgroundColor: '$background',
1280
+ })}
1281
+ {...rest}
1282
+ ref={forwardedRef}
1283
+ />
1284
+ )
1285
+ }
1286
+ )
1287
+
1288
+ /* -------------------------------------------------------------------------------------------------
1289
+ * MenuSub
1290
+ * -----------------------------------------------------------------------------------------------*/
1291
+
1292
+ const SUB_NAME = 'MenuSub'
1293
+
1294
+ const { Provider: MenuSubProvider, useStyledContext: useMenuSubContext } =
1295
+ createStyledContext<MenuSubContextValue>()
1296
+
1297
+ const MenuSub: React.FC<ScopedProps<MenuSubProps>> = (props) => {
1298
+ const { scope = MENU_CONTEXT, children, open = false, onOpenChange, ...rest } = props
1299
+ const parentMenuContext = useMenuContext(scope)
1300
+ const [trigger, setTrigger] = React.useState<MenuSubTriggerElement | null>(null)
1301
+ const [content, setContent] = React.useState<MenuContentElement | null>(null)
1302
+ const handleOpenChange = useCallbackRef(onOpenChange)
1303
+
1304
+ // Prevent the parent menu from reopening with open submenus.
1305
+ React.useEffect(() => {
1306
+ if (parentMenuContext.open === false) handleOpenChange(false)
1307
+ return () => handleOpenChange(false)
1308
+ }, [parentMenuContext.open, handleOpenChange])
1309
+
1310
+ return (
1311
+ <PopperPrimitive.Popper {...rest} scope={scope}>
1312
+ <MenuProvider
1313
+ scope={scope}
1314
+ open={open}
1315
+ onOpenChange={handleOpenChange}
1316
+ content={content}
1317
+ onContentChange={setContent}
1318
+ >
1319
+ <MenuSubProvider
1320
+ scope={scope}
1321
+ contentId={useId()}
1322
+ triggerId={useId()}
1323
+ trigger={trigger}
1324
+ onTriggerChange={setTrigger}
1325
+ >
1326
+ {children}
1327
+ </MenuSubProvider>
1328
+ </MenuProvider>
1329
+ </PopperPrimitive.Popper>
1330
+ )
1331
+ }
1332
+
1333
+ MenuSub.displayName = SUB_NAME
1334
+ /* -------------------------------------------------------------------------------------------------
1335
+ * MenuSubTrigger
1336
+ * -----------------------------------------------------------------------------------------------*/
1337
+
1338
+ const SUB_TRIGGER_NAME = 'MenuSubTrigger'
1339
+
1340
+ const MenuSubTrigger = React.forwardRef<
1341
+ TamaguiElement,
1342
+ ScopedProps<MenuSubTriggerProps>
1343
+ >((props, forwardedRef) => {
1344
+ const scope = props.scope || MENU_CONTEXT
1345
+ const context = useMenuContext(scope)
1346
+ const rootContext = useMenuRootContext(scope)
1347
+ const subContext = useMenuSubContext(scope)
1348
+ const contentContext = useMenuContentContext(scope)
1349
+ const openTimerRef = React.useRef<number | null>(null)
1350
+ const { pointerGraceTimerRef, onPointerGraceIntentChange } = contentContext
1351
+
1352
+ const clearOpenTimer = React.useCallback(() => {
1353
+ if (openTimerRef.current) window.clearTimeout(openTimerRef.current)
1354
+ openTimerRef.current = null
1355
+ }, [])
1356
+
1357
+ React.useEffect(() => clearOpenTimer, [clearOpenTimer])
1358
+
1359
+ React.useEffect(() => {
1360
+ const pointerGraceTimer = pointerGraceTimerRef.current
1361
+ return () => {
1362
+ window.clearTimeout(pointerGraceTimer)
1363
+ onPointerGraceIntentChange(null)
1364
+ }
1365
+ }, [pointerGraceTimerRef, onPointerGraceIntentChange])
1366
+
1367
+ return (
1368
+ <MenuAnchor componentName={SUB_TRIGGER_NAME} asChild scope={scope}>
1369
+ <MenuItemImpl
1370
+ id={subContext.triggerId}
1371
+ aria-haspopup="menu"
1372
+ aria-expanded={context.open}
1373
+ aria-controls={subContext.contentId}
1374
+ data-state={getOpenState(context.open)}
1375
+ outlineStyle="none"
1376
+ {...props}
1377
+ ref={composeRefs(forwardedRef, subContext.onTriggerChange)}
1378
+ // This is redundant for mouse users but we cannot determine pointer type from
1379
+ // click event and we cannot use pointerup event (see git history for reasons why)
1380
+ onPress={(event) => {
1381
+ props.onPress?.(event)
1382
+ if (props.disabled || event.defaultPrevented) return
1383
+ /**
1384
+ * We manually focus because iOS Safari doesn't always focus on click (e.g. buttons)
1385
+ * and we rely heavily on `onFocusOutside` for submenus to close when switching
1386
+ * between separate submenus.
1387
+ */
1388
+ if (isWeb) {
1389
+ event.currentTarget.focus()
1390
+ }
1391
+ if (!context.open) context.onOpenChange(true)
1392
+ }}
1393
+ onPointerMove={composeEventHandlers(
1394
+ props.onPointerMove,
1395
+ // @ts-ignore
1396
+ whenMouse((event: PointerEvent<Element>) => {
1397
+ contentContext.onItemEnter(event)
1398
+ if (event.defaultPrevented) return
1399
+ if (!props.disabled && !context.open && !openTimerRef.current) {
1400
+ contentContext.onPointerGraceIntentChange(null)
1401
+ openTimerRef.current = window.setTimeout(() => {
1402
+ context.onOpenChange(true)
1403
+ clearOpenTimer()
1404
+ }, 100)
1405
+ }
1406
+ })
1407
+ )}
1408
+ onPointerLeave={composeEventHandlers(props.onPointerLeave, (eventIn) => {
1409
+ const event = eventIn as any as React.PointerEvent
1410
+
1411
+ clearOpenTimer()
1412
+
1413
+ // @ts-ignore
1414
+ const contentRect = context.content?.getBoundingClientRect()
1415
+ if (contentRect) {
1416
+ // TODO: make sure to update this when we change positioning logic
1417
+ // @ts-ignore
1418
+ const side = context.content?.dataset.side as Side
1419
+ const rightSide = side === 'right'
1420
+ const bleed = rightSide ? -5 : +5
1421
+ const contentNearEdge = contentRect[rightSide ? 'left' : 'right']
1422
+ const contentFarEdge = contentRect[rightSide ? 'right' : 'left']
1423
+
1424
+ contentContext.onPointerGraceIntentChange({
1425
+ area: [
1426
+ // Apply a bleed on clientX to ensure that our exit point is
1427
+ // consistently within polygon bounds
1428
+ { x: event.clientX + bleed, y: event.clientY },
1429
+ { x: contentNearEdge, y: contentRect.top },
1430
+ { x: contentFarEdge, y: contentRect.top },
1431
+ { x: contentFarEdge, y: contentRect.bottom },
1432
+ { x: contentNearEdge, y: contentRect.bottom },
1433
+ ],
1434
+ side,
1435
+ })
1436
+
1437
+ window.clearTimeout(pointerGraceTimerRef.current)
1438
+ pointerGraceTimerRef.current = window.setTimeout(
1439
+ () => contentContext.onPointerGraceIntentChange(null),
1440
+ 300
1441
+ )
1442
+ } else {
1443
+ contentContext.onTriggerLeave(event)
1444
+ if (event.defaultPrevented) return
1445
+
1446
+ // There's 100ms where the user may leave an item before the submenu was opened.
1447
+ contentContext.onPointerGraceIntentChange(null)
1448
+ }
1449
+ })}
1450
+ {...(isWeb
1451
+ ? {
1452
+ onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
1453
+ const isTypingAhead = contentContext.searchRef.current !== ''
1454
+ if (props.disabled || (isTypingAhead && event.key === ' ')) return
1455
+ if (SUB_OPEN_KEYS[rootContext.dir].includes(event.key)) {
1456
+ context.onOpenChange(true)
1457
+ // The trigger may hold focus if opened via pointer interaction
1458
+ // so we ensure content is given focus again when switching to keyboard.
1459
+ context.content?.focus()
1460
+ // prevent window from scrolling
1461
+ event.preventDefault()
1462
+ }
1463
+ }),
1464
+ }
1465
+ : null)}
1466
+ />
1467
+ </MenuAnchor>
1468
+ )
1469
+ })
1470
+
1471
+ MenuSubTrigger.displayName = SUB_TRIGGER_NAME
1472
+
1473
+ /* -------------------------------------------------------------------------------------------------
1474
+ * MenuSubContent
1475
+ * -----------------------------------------------------------------------------------------------*/
1476
+
1477
+ const SUB_CONTENT_NAME = 'MenuSubContent'
1478
+
1479
+ const MenuSubContent = React.forwardRef<
1480
+ MenuSubContentElement,
1481
+ ScopedProps<MenuSubContentProps>
1482
+ >((props, forwardedRef) => {
1483
+ const scope = props.scope || MENU_CONTEXT
1484
+ const portalContext = usePortalContext(scope)
1485
+ const { forceMount = portalContext.forceMount, ...subContentProps } = props
1486
+ const context = useMenuContext(scope)
1487
+ const rootContext = useMenuRootContext(scope)
1488
+ const subContext = useMenuSubContext(scope)
1489
+ const ref = React.useRef<MenuSubContentElement>(null)
1490
+ const composedRefs = useComposedRefs(forwardedRef, ref)
1491
+ return (
1492
+ <Collection.Provider scope={scope}>
1493
+ <Collection.Slot scope={scope}>
1494
+ <MenuContentImpl
1495
+ id={subContext.contentId}
1496
+ aria-labelledby={subContext.triggerId}
1497
+ {...subContentProps}
1498
+ ref={composedRefs}
1499
+ data-side={rootContext.dir === 'rtl' ? 'left' : 'right'}
1500
+ disableOutsidePointerEvents={false}
1501
+ disableOutsideScroll={false}
1502
+ trapFocus={false}
1503
+ onOpenAutoFocus={(event) => {
1504
+ // when opening a submenu, focus content for keyboard users only
1505
+ if (rootContext.isUsingKeyboardRef.current) ref.current?.focus()
1506
+ event.preventDefault()
1507
+ }}
1508
+ // The menu might close because of focusing another menu item in the parent menu. We
1509
+ // don't want it to refocus the trigger in that case so we handle trigger focus ourselves.
1510
+ onCloseAutoFocus={(event) => event.preventDefault()}
1511
+ onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) => {
1512
+ // We prevent closing when the trigger is focused to avoid triggering a re-open animation
1513
+ // on pointer interaction.
1514
+ if (event.target !== subContext.trigger) context.onOpenChange(false)
1515
+ })}
1516
+ onEscapeKeyDown={composeEventHandlers(props.onEscapeKeyDown, (event) => {
1517
+ rootContext.onClose()
1518
+ // ensure pressing escape in submenu doesn't escape full screen mode
1519
+ event.preventDefault()
1520
+ })}
1521
+ {...(isWeb
1522
+ ? {
1523
+ onKeyDown: composeEventHandlers(props.onKeyDown, (event) => {
1524
+ // Submenu key events bubble through portals. We only care about keys in this menu.
1525
+ // @ts-ignore
1526
+ const isKeyDownInside = event.currentTarget.contains(
1527
+ event.target as HTMLElement
1528
+ )
1529
+ const isCloseKey = SUB_CLOSE_KEYS[rootContext.dir].includes(event.key)
1530
+ if (isKeyDownInside && isCloseKey) {
1531
+ context.onOpenChange(false)
1532
+ // We focus manually because we prevented it in `onCloseAutoFocus`
1533
+ subContext.trigger?.focus()
1534
+ // prevent window from scrolling
1535
+ event.preventDefault()
1536
+ }
1537
+ }),
1538
+ }
1539
+ : null)}
1540
+ />
1541
+ </Collection.Slot>
1542
+ </Collection.Provider>
1543
+ )
1544
+ })
1545
+
1546
+ MenuSubContent.displayName = SUB_CONTENT_NAME
1547
+
1548
+ const Anchor = MenuAnchor
1549
+ const Portal = MenuPortal
1550
+ const Content = MenuContent
1551
+ const Group = _MenuGroup
1552
+ const Label = _Label
1553
+ const Item = MenuItem
1554
+ const CheckboxItem = MenuCheckboxItem
1555
+ const RadioGroup = MenuRadioGroup
1556
+ const RadioItem = MenuRadioItem
1557
+ const ItemIndicator = MenuItemIndicator
1558
+ const Separator = _Separator
1559
+ const Arrow = MenuArrow
1560
+ const Sub = MenuSub
1561
+ const SubTrigger = MenuSubTrigger
1562
+ const SubContent = MenuSubContent
1563
+ const ItemTitle = MenuItemTitle
1564
+ const ItemSubtitle = MenuItemSubTitle
1565
+ const ItemImage = MenuItemImage
1566
+ const ItemIcon = MenuItemIcon
1567
+
1568
+ const Menu = withStaticProperties(MenuComp, {
1569
+ Anchor,
1570
+ Portal,
1571
+ Content,
1572
+ Group,
1573
+ Label,
1574
+ Item,
1575
+ CheckboxItem,
1576
+ RadioGroup,
1577
+ RadioItem,
1578
+ ItemIndicator,
1579
+ Separator,
1580
+ Arrow,
1581
+ Sub,
1582
+ SubTrigger,
1583
+ SubContent,
1584
+ ItemTitle,
1585
+ ItemSubtitle,
1586
+ ItemImage,
1587
+ ItemIcon,
1588
+ })
1589
+
1590
+ return {
1591
+ Menu,
1592
+ }
1593
+ }
1594
+
1595
+ /* -----------------------------------------------------------------------------------------------*/
1596
+
1597
+ function getOpenState(open: boolean) {
1598
+ return open ? 'open' : 'closed'
1599
+ }
1600
+
1601
+ function isIndeterminate(checked?: CheckedState): checked is 'indeterminate' {
1602
+ return checked === 'indeterminate'
1603
+ }
1604
+
1605
+ function getCheckedState(checked: CheckedState) {
1606
+ return isIndeterminate(checked) ? 'indeterminate' : checked ? 'checked' : 'unchecked'
1607
+ }
1608
+
1609
+ function focusFirst(candidates: HTMLElement[]) {
1610
+ const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement
1611
+ for (const candidate of candidates) {
1612
+ // if focus is already where we want to go, we don't want to keep going through the candidates
1613
+ if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return
1614
+ candidate.focus()
1615
+ if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return
1616
+ }
1617
+ }
1618
+
1619
+ /**
1620
+ * Wraps an array around itself at a given start index
1621
+ * Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']`
1622
+ */
1623
+ function wrapArray<T>(array: T[], startIndex: number) {
1624
+ return array.map((_, index) => array[(startIndex + index) % array.length])
1625
+ }
1626
+
1627
+ /**
1628
+ * This is the "meat" of the typeahead matching logic. It takes in all the values,
1629
+ * the search and the current match, and returns the next match (or `undefined`).
1630
+ *
1631
+ * We normalize the search because if a user has repeatedly pressed a character,
1632
+ * we want the exact same behavior as if we only had that one character
1633
+ * (ie. cycle through options starting with that character)
1634
+ *
1635
+ * We also reorder the values by wrapping the array around the current match.
1636
+ * This is so we always look forward from the current match, and picking the first
1637
+ * match will always be the correct one.
1638
+ *
1639
+ * Finally, if the normalized search is exactly one character, we exclude the
1640
+ * current match from the values because otherwise it would be the first to match always
1641
+ * and focus would never move. This is as opposed to the regular case, where we
1642
+ * don't want focus to move if the current match still matches.
1643
+ */
1644
+ function getNextMatch(values: string[], search: string, currentMatch?: string) {
1645
+ const isRepeated =
1646
+ search.length > 1 && Array.from(search).every((char) => char === search[0])
1647
+ const normalizedSearch = isRepeated ? search[0] : search
1648
+ const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1
1649
+ let wrappedValues = wrapArray(values, Math.max(currentMatchIndex, 0))
1650
+ const excludeCurrentMatch = normalizedSearch.length === 1
1651
+ if (excludeCurrentMatch) wrappedValues = wrappedValues.filter((v) => v !== currentMatch)
1652
+ const nextMatch = wrappedValues.find((value) =>
1653
+ value.toLowerCase().startsWith(normalizedSearch.toLowerCase())
1654
+ )
1655
+ return nextMatch !== currentMatch ? nextMatch : undefined
1656
+ }
1657
+
1658
+ // Determine if a point is inside of a polygon.
1659
+ // Based on https://github.com/substack/point-in-polygon
1660
+ function isPointInPolygon(point: Point, polygon: Polygon) {
1661
+ const { x, y } = point
1662
+ let inside = false
1663
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
1664
+ const xi = polygon[i].x
1665
+ const yi = polygon[i].y
1666
+ const xj = polygon[j].x
1667
+ const yj = polygon[j].y
1668
+
1669
+ // prettier-ignore
1670
+ const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
1671
+ if (intersect) inside = !inside
1672
+ }
1673
+
1674
+ return inside
1675
+ }
1676
+
1677
+ function isPointerInGraceArea(event: React.PointerEvent, area?: Polygon) {
1678
+ if (!area) return false
1679
+ const cursorPos = { x: event.clientX, y: event.clientY }
1680
+ return isPointInPolygon(cursorPos, area)
1681
+ }
1682
+
1683
+ type PointerHandler = (e: { pointerType?: string }) => void
1684
+
1685
+ export type {
1686
+ MenuAnchorProps,
1687
+ MenuArrowProps,
1688
+ MenuCheckboxItemProps,
1689
+ MenuContentProps,
1690
+ MenuGroupProps,
1691
+ MenuItemIconProps,
1692
+ MenuItemIndicatorProps,
1693
+ MenuItemProps,
1694
+ MenuItemSubTitleProps,
1695
+ MenuItemTitleProps,
1696
+ MenuLabelProps,
1697
+ MenuPortalProps,
1698
+ MenuBaseProps as MenuProps,
1699
+ MenuRadioGroupProps,
1700
+ MenuRadioItemProps,
1701
+ MenuSeparatorProps,
1702
+ MenuSubTriggerProps,
1703
+ }