@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.
- package/LICENSE +21 -0
- package/dist/cjs/MenuPredefined.cjs +182 -0
- package/dist/cjs/MenuPredefined.js +162 -0
- package/dist/cjs/MenuPredefined.js.map +6 -0
- package/dist/cjs/MenuPredefined.native.js +185 -0
- package/dist/cjs/MenuPredefined.native.js.map +1 -0
- package/dist/cjs/createBaseMenu.cjs +927 -0
- package/dist/cjs/createBaseMenu.js +724 -0
- package/dist/cjs/createBaseMenu.js.map +6 -0
- package/dist/cjs/createBaseMenu.native.js +1105 -0
- package/dist/cjs/createBaseMenu.native.js.map +1 -0
- package/dist/cjs/createNativeMenu/createNativeMenu.cjs +224 -0
- package/dist/cjs/createNativeMenu/createNativeMenu.js +172 -0
- package/dist/cjs/createNativeMenu/createNativeMenu.js.map +6 -0
- package/dist/cjs/createNativeMenu/createNativeMenu.native.js +287 -0
- package/dist/cjs/createNativeMenu/createNativeMenu.native.js.map +1 -0
- package/dist/cjs/createNativeMenu/createNativeMenuTypes.cjs +16 -0
- package/dist/cjs/createNativeMenu/createNativeMenuTypes.js +14 -0
- package/dist/cjs/createNativeMenu/createNativeMenuTypes.js.map +6 -0
- package/dist/cjs/createNativeMenu/createNativeMenuTypes.native.js +19 -0
- package/dist/cjs/createNativeMenu/createNativeMenuTypes.native.js.map +1 -0
- package/dist/cjs/createNativeMenu/index.cjs +19 -0
- package/dist/cjs/createNativeMenu/index.js +16 -0
- package/dist/cjs/createNativeMenu/index.js.map +6 -0
- package/dist/cjs/createNativeMenu/index.native.js +22 -0
- package/dist/cjs/createNativeMenu/index.native.js.map +1 -0
- package/dist/cjs/createNativeMenu/utils.cjs +68 -0
- package/dist/cjs/createNativeMenu/utils.js +66 -0
- package/dist/cjs/createNativeMenu/utils.js.map +6 -0
- package/dist/cjs/createNativeMenu/utils.native.js +94 -0
- package/dist/cjs/createNativeMenu/utils.native.js.map +1 -0
- package/dist/cjs/createNativeMenu/withNativeMenu.cjs +37 -0
- package/dist/cjs/createNativeMenu/withNativeMenu.js +30 -0
- package/dist/cjs/createNativeMenu/withNativeMenu.js.map +6 -0
- package/dist/cjs/createNativeMenu/withNativeMenu.native.js +43 -0
- package/dist/cjs/createNativeMenu/withNativeMenu.native.js.map +1 -0
- package/dist/cjs/index.cjs +30 -0
- package/dist/cjs/index.js +24 -0
- package/dist/cjs/index.js.map +6 -0
- package/dist/cjs/index.native.js +33 -0
- package/dist/cjs/index.native.js.map +1 -0
- package/dist/esm/MenuPredefined.js +147 -0
- package/dist/esm/MenuPredefined.js.map +6 -0
- package/dist/esm/MenuPredefined.mjs +159 -0
- package/dist/esm/MenuPredefined.mjs.map +1 -0
- package/dist/esm/MenuPredefined.native.js +159 -0
- package/dist/esm/MenuPredefined.native.js.map +1 -0
- package/dist/esm/createBaseMenu.js +729 -0
- package/dist/esm/createBaseMenu.js.map +6 -0
- package/dist/esm/createBaseMenu.mjs +893 -0
- package/dist/esm/createBaseMenu.mjs.map +1 -0
- package/dist/esm/createBaseMenu.native.js +1068 -0
- package/dist/esm/createBaseMenu.native.js.map +1 -0
- package/dist/esm/createNativeMenu/createNativeMenu.js +150 -0
- package/dist/esm/createNativeMenu/createNativeMenu.js.map +6 -0
- package/dist/esm/createNativeMenu/createNativeMenu.mjs +190 -0
- package/dist/esm/createNativeMenu/createNativeMenu.mjs.map +1 -0
- package/dist/esm/createNativeMenu/createNativeMenu.native.js +250 -0
- package/dist/esm/createNativeMenu/createNativeMenu.native.js.map +1 -0
- package/dist/esm/createNativeMenu/createNativeMenuTypes.js +1 -0
- package/dist/esm/createNativeMenu/createNativeMenuTypes.js.map +6 -0
- package/dist/esm/createNativeMenu/createNativeMenuTypes.mjs +2 -0
- package/dist/esm/createNativeMenu/createNativeMenuTypes.mjs.map +1 -0
- package/dist/esm/createNativeMenu/createNativeMenuTypes.native.js +2 -0
- package/dist/esm/createNativeMenu/createNativeMenuTypes.native.js.map +1 -0
- package/dist/esm/createNativeMenu/index.js +3 -0
- package/dist/esm/createNativeMenu/index.js.map +6 -0
- package/dist/esm/createNativeMenu/index.mjs +3 -0
- package/dist/esm/createNativeMenu/index.mjs.map +1 -0
- package/dist/esm/createNativeMenu/index.native.js +3 -0
- package/dist/esm/createNativeMenu/index.native.js.map +1 -0
- package/dist/esm/createNativeMenu/utils.js +47 -0
- package/dist/esm/createNativeMenu/utils.js.map +6 -0
- package/dist/esm/createNativeMenu/utils.mjs +29 -0
- package/dist/esm/createNativeMenu/utils.mjs.map +1 -0
- package/dist/esm/createNativeMenu/utils.native.js +52 -0
- package/dist/esm/createNativeMenu/utils.native.js.map +1 -0
- package/dist/esm/createNativeMenu/withNativeMenu.js +15 -0
- package/dist/esm/createNativeMenu/withNativeMenu.js.map +6 -0
- package/dist/esm/createNativeMenu/withNativeMenu.mjs +14 -0
- package/dist/esm/createNativeMenu/withNativeMenu.mjs.map +1 -0
- package/dist/esm/createNativeMenu/withNativeMenu.native.js +17 -0
- package/dist/esm/createNativeMenu/withNativeMenu.native.js.map +1 -0
- package/dist/esm/index.js +8 -0
- package/dist/esm/index.js.map +6 -0
- package/dist/esm/index.mjs +6 -0
- package/dist/esm/index.mjs.map +1 -0
- package/dist/esm/index.native.js +6 -0
- package/dist/esm/index.native.js.map +1 -0
- package/dist/jsx/MenuPredefined.js +147 -0
- package/dist/jsx/MenuPredefined.js.map +6 -0
- package/dist/jsx/MenuPredefined.mjs +159 -0
- package/dist/jsx/MenuPredefined.mjs.map +1 -0
- package/dist/jsx/MenuPredefined.native.js +185 -0
- package/dist/jsx/MenuPredefined.native.js.map +1 -0
- package/dist/jsx/createBaseMenu.js +729 -0
- package/dist/jsx/createBaseMenu.js.map +6 -0
- package/dist/jsx/createBaseMenu.mjs +893 -0
- package/dist/jsx/createBaseMenu.mjs.map +1 -0
- package/dist/jsx/createBaseMenu.native.js +1105 -0
- package/dist/jsx/createBaseMenu.native.js.map +1 -0
- package/dist/jsx/createNativeMenu/createNativeMenu.js +150 -0
- package/dist/jsx/createNativeMenu/createNativeMenu.js.map +6 -0
- package/dist/jsx/createNativeMenu/createNativeMenu.mjs +190 -0
- package/dist/jsx/createNativeMenu/createNativeMenu.mjs.map +1 -0
- package/dist/jsx/createNativeMenu/createNativeMenu.native.js +287 -0
- package/dist/jsx/createNativeMenu/createNativeMenu.native.js.map +1 -0
- package/dist/jsx/createNativeMenu/createNativeMenuTypes.js +1 -0
- package/dist/jsx/createNativeMenu/createNativeMenuTypes.js.map +6 -0
- package/dist/jsx/createNativeMenu/createNativeMenuTypes.mjs +2 -0
- package/dist/jsx/createNativeMenu/createNativeMenuTypes.mjs.map +1 -0
- package/dist/jsx/createNativeMenu/createNativeMenuTypes.native.js +19 -0
- package/dist/jsx/createNativeMenu/createNativeMenuTypes.native.js.map +1 -0
- package/dist/jsx/createNativeMenu/index.js +3 -0
- package/dist/jsx/createNativeMenu/index.js.map +6 -0
- package/dist/jsx/createNativeMenu/index.mjs +3 -0
- package/dist/jsx/createNativeMenu/index.mjs.map +1 -0
- package/dist/jsx/createNativeMenu/index.native.js +22 -0
- package/dist/jsx/createNativeMenu/index.native.js.map +1 -0
- package/dist/jsx/createNativeMenu/utils.js +47 -0
- package/dist/jsx/createNativeMenu/utils.js.map +6 -0
- package/dist/jsx/createNativeMenu/utils.mjs +29 -0
- package/dist/jsx/createNativeMenu/utils.mjs.map +1 -0
- package/dist/jsx/createNativeMenu/utils.native.js +94 -0
- package/dist/jsx/createNativeMenu/utils.native.js.map +1 -0
- package/dist/jsx/createNativeMenu/withNativeMenu.js +15 -0
- package/dist/jsx/createNativeMenu/withNativeMenu.js.map +6 -0
- package/dist/jsx/createNativeMenu/withNativeMenu.mjs +14 -0
- package/dist/jsx/createNativeMenu/withNativeMenu.mjs.map +1 -0
- package/dist/jsx/createNativeMenu/withNativeMenu.native.js +43 -0
- package/dist/jsx/createNativeMenu/withNativeMenu.native.js.map +1 -0
- package/dist/jsx/index.js +8 -0
- package/dist/jsx/index.js.map +6 -0
- package/dist/jsx/index.mjs +6 -0
- package/dist/jsx/index.mjs.map +1 -0
- package/dist/jsx/index.native.js +33 -0
- package/dist/jsx/index.native.js.map +1 -0
- package/package.json +80 -0
- package/src/MenuPredefined.tsx +195 -0
- package/src/createBaseMenu.tsx +1703 -0
- package/src/createNativeMenu/createNativeMenu.tsx +372 -0
- package/src/createNativeMenu/createNativeMenuTypes.ts +214 -0
- package/src/createNativeMenu/index.tsx +7 -0
- package/src/createNativeMenu/utils.tsx +150 -0
- package/src/createNativeMenu/withNativeMenu.tsx +38 -0
- package/src/index.tsx +9 -0
- package/types/MenuPredefined.d.ts +28 -0
- package/types/MenuPredefined.d.ts.map +1 -0
- package/types/createBaseMenu.d.ts +207 -0
- package/types/createBaseMenu.d.ts.map +1 -0
- package/types/createNativeMenu/createNativeMenu.d.ts +42 -0
- package/types/createNativeMenu/createNativeMenu.d.ts.map +1 -0
- package/types/createNativeMenu/createNativeMenuTypes.d.ts +188 -0
- package/types/createNativeMenu/createNativeMenuTypes.d.ts.map +1 -0
- package/types/createNativeMenu/index.d.ts +4 -0
- package/types/createNativeMenu/index.d.ts.map +1 -0
- package/types/createNativeMenu/utils.d.ts +38 -0
- package/types/createNativeMenu/utils.d.ts.map +1 -0
- package/types/createNativeMenu/withNativeMenu.d.ts +9 -0
- package/types/createNativeMenu/withNativeMenu.d.ts.map +1 -0
- package/types/index.d.ts +6 -0
- 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
|
+
}
|