···11import React from 'react'
2233-import type {ContextType, ItemContextType} from '#/components/ContextMenu/types'
33+import {
44+ type ContextType,
55+ type ItemContextType,
66+ type MenuContextType,
77+} from '#/components/ContextMenu/types'
4859export const Context = React.createContext<ContextType | null>(null)
1010+1111+export const MenuContext = React.createContext<MenuContextType | null>(null)
612713export const ItemContext = React.createContext<ItemContextType | null>(null)
814···1218 if (!context) {
1319 throw new Error(
1420 'useContextMenuContext must be used within a Context.Provider',
2121+ )
2222+ }
2323+2424+ return context
2525+}
2626+2727+export function useContextMenuMenuContext() {
2828+ const context = React.useContext(MenuContext)
2929+3030+ if (!context) {
3131+ throw new Error(
3232+ 'useContextMenuMenuContext must be used within a Context.Provider',
1533 )
1634 }
1735
+283-97
src/components/ContextMenu/index.tsx
···11-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
11+import React, {
22+ useCallback,
33+ useEffect,
44+ useId,
55+ useMemo,
66+ useRef,
77+ useState,
88+} from 'react'
29import {
310 BackHandler,
411 Keyboard,
512 LayoutChangeEvent,
613 Pressable,
714 StyleProp,
1515+ useWindowDimensions,
816 View,
917 ViewStyle,
1018} from 'react-native'
1111-import {Gesture, GestureDetector} from 'react-native-gesture-handler'
1919+import {
2020+ Gesture,
2121+ GestureDetector,
2222+ GestureStateChangeEvent,
2323+ GestureUpdateEvent,
2424+ PanGestureHandlerEventPayload,
2525+} from 'react-native-gesture-handler'
1226import Animated, {
1327 clamp,
1428 interpolate,
1529 runOnJS,
1630 SharedValue,
3131+ useAnimatedReaction,
1732 useAnimatedStyle,
1833 useSharedValue,
1934 withSpring,
···3550import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
3651import {logger} from '#/logger'
3752import {isAndroid, isIOS} from '#/platform/detection'
3838-import {atoms as a, platform, useTheme} from '#/alf'
5353+import {atoms as a, platform, tokens, useTheme} from '#/alf'
3954import {
4055 Context,
4156 ItemContext,
5757+ MenuContext,
4258 useContextMenuContext,
4359 useContextMenuItemContext,
6060+ useContextMenuMenuContext,
4461} from '#/components/ContextMenu/context'
4562import {
4663 ContextType,
···8299}
8310084101export function Root({children}: {children: React.ReactNode}) {
102102+ const playHaptic = useHaptics()
85103 const [measurement, setMeasurement] = useState<Measurement | null>(null)
86104 const animationSV = useSharedValue(0)
87105 const translationSV = useSharedValue(0)
88106 const isFocused = useIsFocused()
107107+ const hoverables = useRef<
108108+ Map<string, {id: string; rect: Measurement; onTouchUp: () => void}>
109109+ >(new Map())
110110+ const hoverablesSV = useSharedValue<
111111+ Record<string, {id: string; rect: Measurement}>
112112+ >({})
113113+ const syncHoverablesThrottleRef = useRef<ReturnType<typeof setTimeout>>()
114114+ const [hoveredMenuItem, setHoveredMenuItem] = useState<string | null>(null)
891159090- const clearMeasurement = useCallback(() => setMeasurement(null), [])
116116+ const onHoverableTouchUp = useCallback((id: string) => {
117117+ const hoverable = hoverables.current.get(id)
118118+ if (!hoverable) {
119119+ logger.warn(`No such hoverable with id ${id}`)
120120+ return
121121+ }
122122+ hoverable.onTouchUp()
123123+ }, [])
911249292- const context = useMemo<ContextType>(
9393- () => ({
9494- isOpen: !!measurement && isFocused,
9595- measurement,
9696- animationSV,
9797- translationSV,
9898- open: (evt: Measurement) => {
9999- setMeasurement(evt)
100100- animationSV.set(withSpring(1, SPRING))
101101- },
102102- close: () => {
103103- animationSV.set(
104104- withSpring(0, SPRING, finished => {
105105- if (finished) {
106106- translationSV.set(0)
107107- runOnJS(clearMeasurement)()
108108- }
109109- }),
110110- )
111111- },
112112- }),
125125+ const onCompletedClose = useCallback(() => {
126126+ hoverables.current.clear()
127127+ setMeasurement(null)
128128+ }, [])
129129+130130+ const context = useMemo(
131131+ () =>
132132+ ({
133133+ isOpen: !!measurement && isFocused,
134134+ measurement,
135135+ animationSV,
136136+ translationSV,
137137+ open: (evt: Measurement) => {
138138+ setMeasurement(evt)
139139+ animationSV.set(withSpring(1, SPRING))
140140+ },
141141+ close: () => {
142142+ animationSV.set(
143143+ withSpring(0, SPRING, finished => {
144144+ if (finished) {
145145+ hoverablesSV.set({})
146146+ translationSV.set(0)
147147+ runOnJS(onCompletedClose)()
148148+ }
149149+ }),
150150+ )
151151+ },
152152+ registerHoverable: (
153153+ id: string,
154154+ rect: Measurement,
155155+ onTouchUp: () => void,
156156+ ) => {
157157+ hoverables.current.set(id, {id, rect, onTouchUp})
158158+ // we need this data on the UI thread, but we want to limit cross-thread communication
159159+ // and this function will be called in quick succession, so we need to throttle it
160160+ if (syncHoverablesThrottleRef.current)
161161+ clearTimeout(syncHoverablesThrottleRef.current)
162162+ syncHoverablesThrottleRef.current = setTimeout(() => {
163163+ syncHoverablesThrottleRef.current = undefined
164164+ hoverablesSV.set(
165165+ Object.fromEntries(
166166+ // eslint-ignore
167167+ [...hoverables.current.entries()].map(([id, {rect}]) => [
168168+ id,
169169+ {id, rect},
170170+ ]),
171171+ ),
172172+ )
173173+ }, 1)
174174+ },
175175+ hoverablesSV,
176176+ onTouchUpMenuItem: onHoverableTouchUp,
177177+ hoveredMenuItem,
178178+ setHoveredMenuItem: item => {
179179+ if (item) playHaptic('Light')
180180+ setHoveredMenuItem(item)
181181+ },
182182+ } satisfies ContextType),
113183 [
114184 measurement,
115185 setMeasurement,
186186+ onCompletedClose,
116187 isFocused,
117188 animationSV,
118189 translationSV,
119119- clearMeasurement,
190190+ hoverablesSV,
191191+ onHoverableTouchUp,
192192+ hoveredMenuItem,
193193+ setHoveredMenuItem,
194194+ playHaptic,
120195 ],
121196 )
122197···183258 .runOnJS(true)
184259 }, [open])
185260261261+ const {
262262+ hoverablesSV,
263263+ setHoveredMenuItem,
264264+ onTouchUpMenuItem,
265265+ translationSV,
266266+ animationSV,
267267+ } = context
268268+ const hoveredItemSV = useSharedValue<string | null>(null)
269269+270270+ useAnimatedReaction(
271271+ () => hoveredItemSV.get(),
272272+ (hovered, prev) => {
273273+ if (hovered !== prev) {
274274+ runOnJS(setHoveredMenuItem)(hovered)
275275+ }
276276+ },
277277+ )
278278+186279 const pressAndHoldGesture = useMemo(() => {
187187- return Gesture.LongPress()
280280+ return Gesture.Pan()
281281+ .activateAfterLongPress(500)
282282+ .cancelsTouchesInView(false)
283283+ .averageTouches(true)
188284 .onStart(() => {
285285+ 'worklet'
189286 runOnJS(open)()
190287 })
191191- .cancelsTouchesInView(false)
192192- }, [open])
288288+ .onUpdate(evt => {
289289+ 'worklet'
290290+ const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
291291+ hoveredItemSV.set(item)
292292+ })
293293+ .onEnd(evt => {
294294+ 'worklet'
295295+ const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
296296+ hoveredItemSV.set(null)
297297+ if (item) {
298298+ runOnJS(onTouchUpMenuItem)(item)
299299+ }
300300+ })
301301+ }, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
193302194303 const composedGestures = Gesture.Exclusive(
195304 doubleTapGesture,
196305 pressAndHoldGesture,
197306 )
198198-199199- const {translationSV, animationSV} = context
200307201308 const measurement = context.measurement || pendingMeasurement
202309···324431 const context = useContextMenuContext()
325432 const insets = useSafeAreaInsets()
326433 const frame = useSafeAreaFrame()
434434+ const {width: screenWidth} = useWindowDimensions()
327435328436 const {animationSV, translationSV} = context
329437···347455 const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn
348456349457 const {height} = evt.nativeEvent.layout
350350- const topPosition = context.measurement.y + context.measurement.height + 4
458458+ const topPosition =
459459+ context.measurement.y + context.measurement.height + tokens.space.xs
351460 const bottomPosition = topPosition + height
352461 const safeAreaBottomLimit =
353462 frame.height -
···372481 },
373482 [context.measurement, frame.height, insets, translationSV],
374483 )
484484+485485+ const menuContext = useMemo(() => ({align}), [align])
375486376487 if (!context.isOpen || !context.measurement) return null
377488378489 return (
379490 <Portal>
380491 <Context.Provider value={context}>
381381- <Backdrop animation={animationSV} onPress={context.close} />
382382- {/* containing element - stays the same size, so we measure it
383383- to determine if a translation is necessary. also has the positioning */}
384384- <Animated.View
385385- onLayout={onLayout}
386386- style={[
387387- a.absolute,
388388- a.z_10,
389389- a.mt_xs,
390390- {
391391- width: MENU_WIDTH,
392392- top: context.measurement.y + context.measurement.height,
393393- },
394394- align === 'left'
395395- ? {left: context.measurement.x}
396396- : {
397397- right:
398398- frame.x +
399399- frame.width -
400400- context.measurement.x -
401401- context.measurement.width,
402402- },
403403- animatedContainerStyle,
404404- ]}>
405405- {/* scaling element - has the scale/fade animation on it */}
492492+ <MenuContext.Provider value={menuContext}>
493493+ <Backdrop animation={animationSV} onPress={context.close} />
494494+ {/* containing element - stays the same size, so we measure it
495495+ to determine if a translation is necessary. also has the positioning */}
406496 <Animated.View
497497+ onLayout={onLayout}
407498 style={[
408408- a.rounded_md,
409409- a.shadow_md,
410410- t.atoms.bg_contrast_25,
411411- a.w_full,
412412- // @ts-ignore react-native-web expects string, and this file is platform-split -sfn
413413- // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
414414- // in the typecheck CI - presumably because of RNW overriding the types
499499+ a.absolute,
500500+ a.z_10,
501501+ a.mt_xs,
415502 {
416416- transformOrigin:
417417- align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
503503+ width: MENU_WIDTH,
504504+ top: context.measurement.y + context.measurement.height,
418505 },
419419- animatedStyle,
420420- style,
506506+ align === 'left'
507507+ ? {left: context.measurement.x}
508508+ : {
509509+ right:
510510+ screenWidth -
511511+ context.measurement.x -
512512+ context.measurement.width,
513513+ },
514514+ animatedContainerStyle,
421515 ]}>
422422- {/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
423423- so put the shadow on the scaling element and the overflow on the innermost element */}
424424- <View
516516+ {/* scaling element - has the scale/fade animation on it */}
517517+ <Animated.View
425518 style={[
426426- a.flex_1,
427519 a.rounded_md,
428428- a.overflow_hidden,
429429- a.border,
430430- t.atoms.border_contrast_low,
520520+ a.shadow_md,
521521+ t.atoms.bg_contrast_25,
522522+ a.w_full,
523523+ // @ts-ignore react-native-web expects string, and this file is platform-split -sfn
524524+ // note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
525525+ // in the typecheck CI - presumably because of RNW overriding the types
526526+ {
527527+ transformOrigin:
528528+ // "top right" doesn't seem to work on android, so set explicity in pixels
529529+ align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
530530+ },
531531+ animatedStyle,
532532+ style,
431533 ]}>
432432- {flattenReactChildren(children).map((child, i) => {
433433- return React.isValidElement(child) &&
434434- (child.type === Item || child.type === Divider) ? (
435435- <React.Fragment key={i}>
436436- {i > 0 ? (
437437- <View style={[a.border_b, t.atoms.border_contrast_low]} />
438438- ) : null}
439439- {React.cloneElement(child, {
440440- // @ts-expect-error not typed
441441- style: {
442442- borderRadius: 0,
443443- borderWidth: 0,
444444- },
445445- })}
446446- </React.Fragment>
447447- ) : null
448448- })}
449449- </View>
534534+ {/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
535535+ so put the shadow on the scaling element and the overflow on the innermost element */}
536536+ <View
537537+ style={[
538538+ a.flex_1,
539539+ a.rounded_md,
540540+ a.overflow_hidden,
541541+ a.border,
542542+ t.atoms.border_contrast_low,
543543+ ]}>
544544+ {flattenReactChildren(children).map((child, i) => {
545545+ return React.isValidElement(child) &&
546546+ (child.type === Item || child.type === Divider) ? (
547547+ <React.Fragment key={i}>
548548+ {i > 0 ? (
549549+ <View
550550+ style={[a.border_b, t.atoms.border_contrast_low]}
551551+ />
552552+ ) : null}
553553+ {React.cloneElement(child, {
554554+ // @ts-expect-error not typed
555555+ style: {
556556+ borderRadius: 0,
557557+ borderWidth: 0,
558558+ },
559559+ })}
560560+ </React.Fragment>
561561+ ) : null
562562+ })}
563563+ </View>
564564+ </Animated.View>
450565 </Animated.View>
451451- </Animated.View>
566566+ </MenuContext.Provider>
452567 </Context.Provider>
453568 </Portal>
454569 )
···464579 onIn: onPressIn,
465580 onOut: onPressOut,
466581 } = useInteractionState()
582582+ const id = useId()
583583+ const {align} = useContextMenuMenuContext()
584584+585585+ const {close, measurement, registerHoverable} = context
586586+587587+ const handleLayout = useCallback(
588588+ (evt: LayoutChangeEvent) => {
589589+ if (!measurement) return // should be impossible
590590+591591+ const layout = evt.nativeEvent.layout
592592+593593+ registerHoverable(
594594+ id,
595595+ {
596596+ width: layout.width,
597597+ height: layout.height,
598598+ y: measurement.y + measurement.height + tokens.space.xs + layout.y,
599599+ x:
600600+ align === 'left'
601601+ ? measurement.x
602602+ : measurement.x + measurement.width - layout.width,
603603+ },
604604+ () => {
605605+ close()
606606+ onPress()
607607+ },
608608+ )
609609+ },
610610+ [id, measurement, registerHoverable, close, onPress, align],
611611+ )
612612+613613+ const itemContext = useMemo(
614614+ () => ({disabled: Boolean(rest.disabled)}),
615615+ [rest.disabled],
616616+ )
467617468618 return (
469619 <Pressable
470620 {...rest}
621621+ onLayout={handleLayout}
471622 accessibilityHint=""
472623 accessibilityLabel={label}
473624 onFocus={onFocus}
474625 onBlur={onBlur}
475626 onPress={e => {
476476- context.close()
627627+ close()
477628 onPress?.(e)
478629 }}
479630 onPressIn={e => {
···497648 t.atoms.border_contrast_low,
498649 {minHeight: 40},
499650 style,
500500- (focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
651651+ (focused || pressed || context.hoveredMenuItem === id) &&
652652+ !rest.disabled && [t.atoms.bg_contrast_50],
501653 ]}>
502502- <ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
654654+ <ItemContext.Provider value={itemContext}>
503655 {children}
504656 </ItemContext.Provider>
505657 </Pressable>
···589741 />
590742 )
591743}
744744+745745+function getHoveredHoverable(
746746+ evt:
747747+ | GestureStateChangeEvent<PanGestureHandlerEventPayload>
748748+ | GestureUpdateEvent<PanGestureHandlerEventPayload>,
749749+ hoverables: SharedValue<Record<string, {id: string; rect: Measurement}>>,
750750+ translation: SharedValue<number>,
751751+) {
752752+ 'worklet'
753753+754754+ const x = evt.absoluteX
755755+ const y = evt.absoluteY
756756+ const yOffset = translation.get()
757757+758758+ const rects = Object.values(hoverables.get())
759759+760760+ for (const {id, rect} of rects) {
761761+ const isWithinLeftBound = x >= rect.x
762762+ const isWithinRightBound = x <= rect.x + rect.width
763763+ const isWithinTopBound = y >= rect.y + yOffset
764764+ const isWithinBottomBound = y <= rect.y + rect.height + yOffset
765765+766766+ if (
767767+ isWithinLeftBound &&
768768+ isWithinRightBound &&
769769+ isWithinTopBound &&
770770+ isWithinBottomBound
771771+ ) {
772772+ return id
773773+ }
774774+ }
775775+776776+ return null
777777+}
+28-3
src/components/ContextMenu/types.ts
···11import React from 'react'
22-import {AccessibilityRole, StyleProp, ViewStyle} from 'react-native'
22+import {
33+ AccessibilityRole,
44+ GestureResponderEvent,
55+ StyleProp,
66+ ViewStyle,
77+} from 'react-native'
38import {SharedValue} from 'react-native-reanimated'
49510import * as Dialog from '#/components/Dialog'
66-import {RadixPassThroughTriggerProps} from '#/components/Menu/types'
1111+import {
1212+ ItemProps as MenuItemProps,
1313+ RadixPassThroughTriggerProps,
1414+} from '#/components/Menu/types'
715816export type {
917 GroupProps,
1018 ItemIconProps,
1111- ItemProps,
1219 ItemTextProps,
1320} from '#/components/Menu/types'
14212222+// Same as Menu.ItemProps, but onPress is not guaranteed to get an event
2323+export type ItemProps = Omit<MenuItemProps, 'onPress'> & {
2424+ onPress: (evt?: GestureResponderEvent) => void
2525+}
2626+1527export type Measurement = {
1628 x: number
1729 y: number
···2840 translationSV: SharedValue<number>
2941 open: (evt: Measurement) => void
3042 close: () => void
4343+ registerHoverable: (
4444+ id: string,
4545+ rect: Measurement,
4646+ onTouchUp: () => void,
4747+ ) => void
4848+ hoverablesSV: SharedValue<Record<string, {id: string; rect: Measurement}>>
4949+ hoveredMenuItem: string | null
5050+ setHoveredMenuItem: React.Dispatch<React.SetStateAction<string | null>>
5151+ onTouchUpMenuItem: (id: string) => void
5252+}
5353+5454+export type MenuContextType = {
5555+ align: 'left' | 'right'
3156}
32573358export type ItemContextType = {