Allow selecting ContextMenu options via press-and-hold (#8020)
* save locations of menu items * enable panning to select items * rm unused type * fix haptic overfiring
This commit is contained in:
@@ -1,9 +1,15 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import type {ContextType, ItemContextType} from '#/components/ContextMenu/types'
|
import {
|
||||||
|
type ContextType,
|
||||||
|
type ItemContextType,
|
||||||
|
type MenuContextType,
|
||||||
|
} from '#/components/ContextMenu/types'
|
||||||
|
|
||||||
export const Context = React.createContext<ContextType | null>(null)
|
export const Context = React.createContext<ContextType | null>(null)
|
||||||
|
|
||||||
|
export const MenuContext = React.createContext<MenuContextType | null>(null)
|
||||||
|
|
||||||
export const ItemContext = React.createContext<ItemContextType | null>(null)
|
export const ItemContext = React.createContext<ItemContextType | null>(null)
|
||||||
|
|
||||||
export function useContextMenuContext() {
|
export function useContextMenuContext() {
|
||||||
@@ -18,6 +24,18 @@ export function useContextMenuContext() {
|
|||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useContextMenuMenuContext() {
|
||||||
|
const context = React.useContext(MenuContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'useContextMenuMenuContext must be used within a Context.Provider',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
export function useContextMenuItemContext() {
|
export function useContextMenuItemContext() {
|
||||||
const context = React.useContext(ItemContext)
|
const context = React.useContext(ItemContext)
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,34 @@
|
|||||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import {
|
import {
|
||||||
BackHandler,
|
BackHandler,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
LayoutChangeEvent,
|
LayoutChangeEvent,
|
||||||
Pressable,
|
Pressable,
|
||||||
StyleProp,
|
StyleProp,
|
||||||
|
useWindowDimensions,
|
||||||
View,
|
View,
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
import {
|
||||||
|
Gesture,
|
||||||
|
GestureDetector,
|
||||||
|
GestureStateChangeEvent,
|
||||||
|
GestureUpdateEvent,
|
||||||
|
PanGestureHandlerEventPayload,
|
||||||
|
} from 'react-native-gesture-handler'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
clamp,
|
clamp,
|
||||||
interpolate,
|
interpolate,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
SharedValue,
|
SharedValue,
|
||||||
|
useAnimatedReaction,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withSpring,
|
withSpring,
|
||||||
@@ -35,12 +50,14 @@ import {useHaptics} from '#/lib/haptics'
|
|||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isAndroid, isIOS} from '#/platform/detection'
|
import {isAndroid, isIOS} from '#/platform/detection'
|
||||||
import {atoms as a, platform, useTheme} from '#/alf'
|
import {atoms as a, platform, tokens, useTheme} from '#/alf'
|
||||||
import {
|
import {
|
||||||
Context,
|
Context,
|
||||||
ItemContext,
|
ItemContext,
|
||||||
|
MenuContext,
|
||||||
useContextMenuContext,
|
useContextMenuContext,
|
||||||
useContextMenuItemContext,
|
useContextMenuItemContext,
|
||||||
|
useContextMenuMenuContext,
|
||||||
} from '#/components/ContextMenu/context'
|
} from '#/components/ContextMenu/context'
|
||||||
import {
|
import {
|
||||||
ContextType,
|
ContextType,
|
||||||
@@ -82,41 +99,99 @@ export function Provider({children}: {children: React.ReactNode}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Root({children}: {children: React.ReactNode}) {
|
export function Root({children}: {children: React.ReactNode}) {
|
||||||
|
const playHaptic = useHaptics()
|
||||||
const [measurement, setMeasurement] = useState<Measurement | null>(null)
|
const [measurement, setMeasurement] = useState<Measurement | null>(null)
|
||||||
const animationSV = useSharedValue(0)
|
const animationSV = useSharedValue(0)
|
||||||
const translationSV = useSharedValue(0)
|
const translationSV = useSharedValue(0)
|
||||||
const isFocused = useIsFocused()
|
const isFocused = useIsFocused()
|
||||||
|
const hoverables = useRef<
|
||||||
|
Map<string, {id: string; rect: Measurement; onTouchUp: () => void}>
|
||||||
|
>(new Map())
|
||||||
|
const hoverablesSV = useSharedValue<
|
||||||
|
Record<string, {id: string; rect: Measurement}>
|
||||||
|
>({})
|
||||||
|
const syncHoverablesThrottleRef = useRef<ReturnType<typeof setTimeout>>()
|
||||||
|
const [hoveredMenuItem, setHoveredMenuItem] = useState<string | null>(null)
|
||||||
|
|
||||||
const clearMeasurement = useCallback(() => setMeasurement(null), [])
|
const onHoverableTouchUp = useCallback((id: string) => {
|
||||||
|
const hoverable = hoverables.current.get(id)
|
||||||
|
if (!hoverable) {
|
||||||
|
logger.warn(`No such hoverable with id ${id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hoverable.onTouchUp()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const context = useMemo<ContextType>(
|
const onCompletedClose = useCallback(() => {
|
||||||
() => ({
|
hoverables.current.clear()
|
||||||
isOpen: !!measurement && isFocused,
|
setMeasurement(null)
|
||||||
measurement,
|
}, [])
|
||||||
animationSV,
|
|
||||||
translationSV,
|
const context = useMemo(
|
||||||
open: (evt: Measurement) => {
|
() =>
|
||||||
setMeasurement(evt)
|
({
|
||||||
animationSV.set(withSpring(1, SPRING))
|
isOpen: !!measurement && isFocused,
|
||||||
},
|
measurement,
|
||||||
close: () => {
|
animationSV,
|
||||||
animationSV.set(
|
translationSV,
|
||||||
withSpring(0, SPRING, finished => {
|
open: (evt: Measurement) => {
|
||||||
if (finished) {
|
setMeasurement(evt)
|
||||||
translationSV.set(0)
|
animationSV.set(withSpring(1, SPRING))
|
||||||
runOnJS(clearMeasurement)()
|
},
|
||||||
}
|
close: () => {
|
||||||
}),
|
animationSV.set(
|
||||||
)
|
withSpring(0, SPRING, finished => {
|
||||||
},
|
if (finished) {
|
||||||
}),
|
hoverablesSV.set({})
|
||||||
|
translationSV.set(0)
|
||||||
|
runOnJS(onCompletedClose)()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
registerHoverable: (
|
||||||
|
id: string,
|
||||||
|
rect: Measurement,
|
||||||
|
onTouchUp: () => void,
|
||||||
|
) => {
|
||||||
|
hoverables.current.set(id, {id, rect, onTouchUp})
|
||||||
|
// we need this data on the UI thread, but we want to limit cross-thread communication
|
||||||
|
// and this function will be called in quick succession, so we need to throttle it
|
||||||
|
if (syncHoverablesThrottleRef.current)
|
||||||
|
clearTimeout(syncHoverablesThrottleRef.current)
|
||||||
|
syncHoverablesThrottleRef.current = setTimeout(() => {
|
||||||
|
syncHoverablesThrottleRef.current = undefined
|
||||||
|
hoverablesSV.set(
|
||||||
|
Object.fromEntries(
|
||||||
|
// eslint-ignore
|
||||||
|
[...hoverables.current.entries()].map(([id, {rect}]) => [
|
||||||
|
id,
|
||||||
|
{id, rect},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}, 1)
|
||||||
|
},
|
||||||
|
hoverablesSV,
|
||||||
|
onTouchUpMenuItem: onHoverableTouchUp,
|
||||||
|
hoveredMenuItem,
|
||||||
|
setHoveredMenuItem: item => {
|
||||||
|
if (item) playHaptic('Light')
|
||||||
|
setHoveredMenuItem(item)
|
||||||
|
},
|
||||||
|
} satisfies ContextType),
|
||||||
[
|
[
|
||||||
measurement,
|
measurement,
|
||||||
setMeasurement,
|
setMeasurement,
|
||||||
|
onCompletedClose,
|
||||||
isFocused,
|
isFocused,
|
||||||
animationSV,
|
animationSV,
|
||||||
translationSV,
|
translationSV,
|
||||||
clearMeasurement,
|
hoverablesSV,
|
||||||
|
onHoverableTouchUp,
|
||||||
|
hoveredMenuItem,
|
||||||
|
setHoveredMenuItem,
|
||||||
|
playHaptic,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -183,21 +258,53 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
|||||||
.runOnJS(true)
|
.runOnJS(true)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
const {
|
||||||
|
hoverablesSV,
|
||||||
|
setHoveredMenuItem,
|
||||||
|
onTouchUpMenuItem,
|
||||||
|
translationSV,
|
||||||
|
animationSV,
|
||||||
|
} = context
|
||||||
|
const hoveredItemSV = useSharedValue<string | null>(null)
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => hoveredItemSV.get(),
|
||||||
|
(hovered, prev) => {
|
||||||
|
if (hovered !== prev) {
|
||||||
|
runOnJS(setHoveredMenuItem)(hovered)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const pressAndHoldGesture = useMemo(() => {
|
const pressAndHoldGesture = useMemo(() => {
|
||||||
return Gesture.LongPress()
|
return Gesture.Pan()
|
||||||
|
.activateAfterLongPress(500)
|
||||||
|
.cancelsTouchesInView(false)
|
||||||
|
.averageTouches(true)
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
|
'worklet'
|
||||||
runOnJS(open)()
|
runOnJS(open)()
|
||||||
})
|
})
|
||||||
.cancelsTouchesInView(false)
|
.onUpdate(evt => {
|
||||||
}, [open])
|
'worklet'
|
||||||
|
const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
|
||||||
|
hoveredItemSV.set(item)
|
||||||
|
})
|
||||||
|
.onEnd(evt => {
|
||||||
|
'worklet'
|
||||||
|
const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
|
||||||
|
hoveredItemSV.set(null)
|
||||||
|
if (item) {
|
||||||
|
runOnJS(onTouchUpMenuItem)(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
|
||||||
|
|
||||||
const composedGestures = Gesture.Exclusive(
|
const composedGestures = Gesture.Exclusive(
|
||||||
doubleTapGesture,
|
doubleTapGesture,
|
||||||
pressAndHoldGesture,
|
pressAndHoldGesture,
|
||||||
)
|
)
|
||||||
|
|
||||||
const {translationSV, animationSV} = context
|
|
||||||
|
|
||||||
const measurement = context.measurement || pendingMeasurement
|
const measurement = context.measurement || pendingMeasurement
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -324,6 +431,7 @@ export function Outer({
|
|||||||
const context = useContextMenuContext()
|
const context = useContextMenuContext()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const frame = useSafeAreaFrame()
|
const frame = useSafeAreaFrame()
|
||||||
|
const {width: screenWidth} = useWindowDimensions()
|
||||||
|
|
||||||
const {animationSV, translationSV} = context
|
const {animationSV, translationSV} = context
|
||||||
|
|
||||||
@@ -347,7 +455,8 @@ export function Outer({
|
|||||||
const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn
|
const BOTTOM_INSET_ANDROID = 12 // TODO: revisit when edge-to-edge mode is enabled -sfn
|
||||||
|
|
||||||
const {height} = evt.nativeEvent.layout
|
const {height} = evt.nativeEvent.layout
|
||||||
const topPosition = context.measurement.y + context.measurement.height + 4
|
const topPosition =
|
||||||
|
context.measurement.y + context.measurement.height + tokens.space.xs
|
||||||
const bottomPosition = topPosition + height
|
const bottomPosition = topPosition + height
|
||||||
const safeAreaBottomLimit =
|
const safeAreaBottomLimit =
|
||||||
frame.height -
|
frame.height -
|
||||||
@@ -373,82 +482,88 @@ export function Outer({
|
|||||||
[context.measurement, frame.height, insets, translationSV],
|
[context.measurement, frame.height, insets, translationSV],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const menuContext = useMemo(() => ({align}), [align])
|
||||||
|
|
||||||
if (!context.isOpen || !context.measurement) return null
|
if (!context.isOpen || !context.measurement) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<Context.Provider value={context}>
|
<Context.Provider value={context}>
|
||||||
<Backdrop animation={animationSV} onPress={context.close} />
|
<MenuContext.Provider value={menuContext}>
|
||||||
{/* containing element - stays the same size, so we measure it
|
<Backdrop animation={animationSV} onPress={context.close} />
|
||||||
to determine if a translation is necessary. also has the positioning */}
|
{/* containing element - stays the same size, so we measure it
|
||||||
<Animated.View
|
to determine if a translation is necessary. also has the positioning */}
|
||||||
onLayout={onLayout}
|
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
a.z_10,
|
|
||||||
a.mt_xs,
|
|
||||||
{
|
|
||||||
width: MENU_WIDTH,
|
|
||||||
top: context.measurement.y + context.measurement.height,
|
|
||||||
},
|
|
||||||
align === 'left'
|
|
||||||
? {left: context.measurement.x}
|
|
||||||
: {
|
|
||||||
right:
|
|
||||||
frame.x +
|
|
||||||
frame.width -
|
|
||||||
context.measurement.x -
|
|
||||||
context.measurement.width,
|
|
||||||
},
|
|
||||||
animatedContainerStyle,
|
|
||||||
]}>
|
|
||||||
{/* scaling element - has the scale/fade animation on it */}
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
onLayout={onLayout}
|
||||||
style={[
|
style={[
|
||||||
a.rounded_md,
|
a.absolute,
|
||||||
a.shadow_md,
|
a.z_10,
|
||||||
t.atoms.bg_contrast_25,
|
a.mt_xs,
|
||||||
a.w_full,
|
|
||||||
// @ts-ignore react-native-web expects string, and this file is platform-split -sfn
|
|
||||||
// note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
|
|
||||||
// in the typecheck CI - presumably because of RNW overriding the types
|
|
||||||
{
|
{
|
||||||
transformOrigin:
|
width: MENU_WIDTH,
|
||||||
align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
|
top: context.measurement.y + context.measurement.height,
|
||||||
},
|
},
|
||||||
animatedStyle,
|
align === 'left'
|
||||||
style,
|
? {left: context.measurement.x}
|
||||||
|
: {
|
||||||
|
right:
|
||||||
|
screenWidth -
|
||||||
|
context.measurement.x -
|
||||||
|
context.measurement.width,
|
||||||
|
},
|
||||||
|
animatedContainerStyle,
|
||||||
]}>
|
]}>
|
||||||
{/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
|
{/* scaling element - has the scale/fade animation on it */}
|
||||||
so put the shadow on the scaling element and the overflow on the innermost element */}
|
<Animated.View
|
||||||
<View
|
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.overflow_hidden,
|
a.shadow_md,
|
||||||
a.border,
|
t.atoms.bg_contrast_25,
|
||||||
t.atoms.border_contrast_low,
|
a.w_full,
|
||||||
|
// @ts-ignore react-native-web expects string, and this file is platform-split -sfn
|
||||||
|
// note: above @ts-ignore cannot be a @ts-expect-error because this does not cause an error
|
||||||
|
// in the typecheck CI - presumably because of RNW overriding the types
|
||||||
|
{
|
||||||
|
transformOrigin:
|
||||||
|
// "top right" doesn't seem to work on android, so set explicity in pixels
|
||||||
|
align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
|
||||||
|
},
|
||||||
|
animatedStyle,
|
||||||
|
style,
|
||||||
]}>
|
]}>
|
||||||
{flattenReactChildren(children).map((child, i) => {
|
{/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
|
||||||
return React.isValidElement(child) &&
|
so put the shadow on the scaling element and the overflow on the innermost element */}
|
||||||
(child.type === Item || child.type === Divider) ? (
|
<View
|
||||||
<React.Fragment key={i}>
|
style={[
|
||||||
{i > 0 ? (
|
a.flex_1,
|
||||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
a.rounded_md,
|
||||||
) : null}
|
a.overflow_hidden,
|
||||||
{React.cloneElement(child, {
|
a.border,
|
||||||
// @ts-expect-error not typed
|
t.atoms.border_contrast_low,
|
||||||
style: {
|
]}>
|
||||||
borderRadius: 0,
|
{flattenReactChildren(children).map((child, i) => {
|
||||||
borderWidth: 0,
|
return React.isValidElement(child) &&
|
||||||
},
|
(child.type === Item || child.type === Divider) ? (
|
||||||
})}
|
<React.Fragment key={i}>
|
||||||
</React.Fragment>
|
{i > 0 ? (
|
||||||
) : null
|
<View
|
||||||
})}
|
style={[a.border_b, t.atoms.border_contrast_low]}
|
||||||
</View>
|
/>
|
||||||
|
) : null}
|
||||||
|
{React.cloneElement(child, {
|
||||||
|
// @ts-expect-error not typed
|
||||||
|
style: {
|
||||||
|
borderRadius: 0,
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Animated.View>
|
</MenuContext.Provider>
|
||||||
</Context.Provider>
|
</Context.Provider>
|
||||||
</Portal>
|
</Portal>
|
||||||
)
|
)
|
||||||
@@ -464,16 +579,52 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
|||||||
onIn: onPressIn,
|
onIn: onPressIn,
|
||||||
onOut: onPressOut,
|
onOut: onPressOut,
|
||||||
} = useInteractionState()
|
} = useInteractionState()
|
||||||
|
const id = useId()
|
||||||
|
const {align} = useContextMenuMenuContext()
|
||||||
|
|
||||||
|
const {close, measurement, registerHoverable} = context
|
||||||
|
|
||||||
|
const handleLayout = useCallback(
|
||||||
|
(evt: LayoutChangeEvent) => {
|
||||||
|
if (!measurement) return // should be impossible
|
||||||
|
|
||||||
|
const layout = evt.nativeEvent.layout
|
||||||
|
|
||||||
|
registerHoverable(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
width: layout.width,
|
||||||
|
height: layout.height,
|
||||||
|
y: measurement.y + measurement.height + tokens.space.xs + layout.y,
|
||||||
|
x:
|
||||||
|
align === 'left'
|
||||||
|
? measurement.x
|
||||||
|
: measurement.x + measurement.width - layout.width,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
close()
|
||||||
|
onPress()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[id, measurement, registerHoverable, close, onPress, align],
|
||||||
|
)
|
||||||
|
|
||||||
|
const itemContext = useMemo(
|
||||||
|
() => ({disabled: Boolean(rest.disabled)}),
|
||||||
|
[rest.disabled],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
{...rest}
|
{...rest}
|
||||||
|
onLayout={handleLayout}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onPress={e => {
|
onPress={e => {
|
||||||
context.close()
|
close()
|
||||||
onPress?.(e)
|
onPress?.(e)
|
||||||
}}
|
}}
|
||||||
onPressIn={e => {
|
onPressIn={e => {
|
||||||
@@ -497,9 +648,10 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
|||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{minHeight: 40},
|
{minHeight: 40},
|
||||||
style,
|
style,
|
||||||
(focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
|
(focused || pressed || context.hoveredMenuItem === id) &&
|
||||||
|
!rest.disabled && [t.atoms.bg_contrast_50],
|
||||||
]}>
|
]}>
|
||||||
<ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
|
<ItemContext.Provider value={itemContext}>
|
||||||
{children}
|
{children}
|
||||||
</ItemContext.Provider>
|
</ItemContext.Provider>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -589,3 +741,37 @@ export function Divider() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHoveredHoverable(
|
||||||
|
evt:
|
||||||
|
| GestureStateChangeEvent<PanGestureHandlerEventPayload>
|
||||||
|
| GestureUpdateEvent<PanGestureHandlerEventPayload>,
|
||||||
|
hoverables: SharedValue<Record<string, {id: string; rect: Measurement}>>,
|
||||||
|
translation: SharedValue<number>,
|
||||||
|
) {
|
||||||
|
'worklet'
|
||||||
|
|
||||||
|
const x = evt.absoluteX
|
||||||
|
const y = evt.absoluteY
|
||||||
|
const yOffset = translation.get()
|
||||||
|
|
||||||
|
const rects = Object.values(hoverables.get())
|
||||||
|
|
||||||
|
for (const {id, rect} of rects) {
|
||||||
|
const isWithinLeftBound = x >= rect.x
|
||||||
|
const isWithinRightBound = x <= rect.x + rect.width
|
||||||
|
const isWithinTopBound = y >= rect.y + yOffset
|
||||||
|
const isWithinBottomBound = y <= rect.y + rect.height + yOffset
|
||||||
|
|
||||||
|
if (
|
||||||
|
isWithinLeftBound &&
|
||||||
|
isWithinRightBound &&
|
||||||
|
isWithinTopBound &&
|
||||||
|
isWithinBottomBound
|
||||||
|
) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {AccessibilityRole, StyleProp, ViewStyle} from 'react-native'
|
import {
|
||||||
|
AccessibilityRole,
|
||||||
|
GestureResponderEvent,
|
||||||
|
StyleProp,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
import {SharedValue} from 'react-native-reanimated'
|
import {SharedValue} from 'react-native-reanimated'
|
||||||
|
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {RadixPassThroughTriggerProps} from '#/components/Menu/types'
|
import {
|
||||||
|
ItemProps as MenuItemProps,
|
||||||
|
RadixPassThroughTriggerProps,
|
||||||
|
} from '#/components/Menu/types'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
GroupProps,
|
GroupProps,
|
||||||
ItemIconProps,
|
ItemIconProps,
|
||||||
ItemProps,
|
|
||||||
ItemTextProps,
|
ItemTextProps,
|
||||||
} from '#/components/Menu/types'
|
} from '#/components/Menu/types'
|
||||||
|
|
||||||
|
// Same as Menu.ItemProps, but onPress is not guaranteed to get an event
|
||||||
|
export type ItemProps = Omit<MenuItemProps, 'onPress'> & {
|
||||||
|
onPress: (evt?: GestureResponderEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
export type Measurement = {
|
export type Measurement = {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
@@ -28,6 +40,19 @@ export type ContextType = {
|
|||||||
translationSV: SharedValue<number>
|
translationSV: SharedValue<number>
|
||||||
open: (evt: Measurement) => void
|
open: (evt: Measurement) => void
|
||||||
close: () => void
|
close: () => void
|
||||||
|
registerHoverable: (
|
||||||
|
id: string,
|
||||||
|
rect: Measurement,
|
||||||
|
onTouchUp: () => void,
|
||||||
|
) => void
|
||||||
|
hoverablesSV: SharedValue<Record<string, {id: string; rect: Measurement}>>
|
||||||
|
hoveredMenuItem: string | null
|
||||||
|
setHoveredMenuItem: React.Dispatch<React.SetStateAction<string | null>>
|
||||||
|
onTouchUpMenuItem: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MenuContextType = {
|
||||||
|
align: 'left' | 'right'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ItemContextType = {
|
export type ItemContextType = {
|
||||||
|
|||||||
Reference in New Issue
Block a user