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:
Samuel Newman
2025-03-27 18:53:42 +02:00
committed by GitHub
parent 1e688dee24
commit 7d1ebf6a02
3 changed files with 330 additions and 101 deletions
+19 -1
View File
@@ -1,9 +1,15 @@
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 MenuContext = React.createContext<MenuContextType | null>(null)
export const ItemContext = React.createContext<ItemContextType | null>(null)
export function useContextMenuContext() {
@@ -18,6 +24,18 @@ export function useContextMenuContext() {
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() {
const context = React.useContext(ItemContext)
+283 -97
View File
@@ -1,19 +1,34 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import React, {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import {
BackHandler,
Keyboard,
LayoutChangeEvent,
Pressable,
StyleProp,
useWindowDimensions,
View,
ViewStyle,
} from 'react-native'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import {
Gesture,
GestureDetector,
GestureStateChangeEvent,
GestureUpdateEvent,
PanGestureHandlerEventPayload,
} from 'react-native-gesture-handler'
import Animated, {
clamp,
interpolate,
runOnJS,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
@@ -35,12 +50,14 @@ import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {atoms as a, platform, useTheme} from '#/alf'
import {atoms as a, platform, tokens, useTheme} from '#/alf'
import {
Context,
ItemContext,
MenuContext,
useContextMenuContext,
useContextMenuItemContext,
useContextMenuMenuContext,
} from '#/components/ContextMenu/context'
import {
ContextType,
@@ -82,41 +99,99 @@ export function Provider({children}: {children: React.ReactNode}) {
}
export function Root({children}: {children: React.ReactNode}) {
const playHaptic = useHaptics()
const [measurement, setMeasurement] = useState<Measurement | null>(null)
const animationSV = useSharedValue(0)
const translationSV = useSharedValue(0)
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>(
() => ({
isOpen: !!measurement && isFocused,
measurement,
animationSV,
translationSV,
open: (evt: Measurement) => {
setMeasurement(evt)
animationSV.set(withSpring(1, SPRING))
},
close: () => {
animationSV.set(
withSpring(0, SPRING, finished => {
if (finished) {
translationSV.set(0)
runOnJS(clearMeasurement)()
}
}),
)
},
}),
const onCompletedClose = useCallback(() => {
hoverables.current.clear()
setMeasurement(null)
}, [])
const context = useMemo(
() =>
({
isOpen: !!measurement && isFocused,
measurement,
animationSV,
translationSV,
open: (evt: Measurement) => {
setMeasurement(evt)
animationSV.set(withSpring(1, SPRING))
},
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,
setMeasurement,
onCompletedClose,
isFocused,
animationSV,
translationSV,
clearMeasurement,
hoverablesSV,
onHoverableTouchUp,
hoveredMenuItem,
setHoveredMenuItem,
playHaptic,
],
)
@@ -183,21 +258,53 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
.runOnJS(true)
}, [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(() => {
return Gesture.LongPress()
return Gesture.Pan()
.activateAfterLongPress(500)
.cancelsTouchesInView(false)
.averageTouches(true)
.onStart(() => {
'worklet'
runOnJS(open)()
})
.cancelsTouchesInView(false)
}, [open])
.onUpdate(evt => {
'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(
doubleTapGesture,
pressAndHoldGesture,
)
const {translationSV, animationSV} = context
const measurement = context.measurement || pendingMeasurement
return (
@@ -324,6 +431,7 @@ export function Outer({
const context = useContextMenuContext()
const insets = useSafeAreaInsets()
const frame = useSafeAreaFrame()
const {width: screenWidth} = useWindowDimensions()
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 {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 safeAreaBottomLimit =
frame.height -
@@ -373,82 +482,88 @@ export function Outer({
[context.measurement, frame.height, insets, translationSV],
)
const menuContext = useMemo(() => ({align}), [align])
if (!context.isOpen || !context.measurement) return null
return (
<Portal>
<Context.Provider value={context}>
<Backdrop animation={animationSV} onPress={context.close} />
{/* containing element - stays the same size, so we measure it
to determine if a translation is necessary. also has the positioning */}
<Animated.View
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 */}
<MenuContext.Provider value={menuContext}>
<Backdrop animation={animationSV} onPress={context.close} />
{/* containing element - stays the same size, so we measure it
to determine if a translation is necessary. also has the positioning */}
<Animated.View
onLayout={onLayout}
style={[
a.rounded_md,
a.shadow_md,
t.atoms.bg_contrast_25,
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
a.absolute,
a.z_10,
a.mt_xs,
{
transformOrigin:
align === 'left' ? [0, 0, 0] : [MENU_WIDTH, 0, 0],
width: MENU_WIDTH,
top: context.measurement.y + context.measurement.height,
},
animatedStyle,
style,
align === 'left'
? {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,
so put the shadow on the scaling element and the overflow on the innermost element */}
<View
{/* scaling element - has the scale/fade animation on it */}
<Animated.View
style={[
a.flex_1,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
a.shadow_md,
t.atoms.bg_contrast_25,
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) => {
return React.isValidElement(child) &&
(child.type === Item || child.type === Divider) ? (
<React.Fragment key={i}>
{i > 0 ? (
<View style={[a.border_b, t.atoms.border_contrast_low]} />
) : null}
{React.cloneElement(child, {
// @ts-expect-error not typed
style: {
borderRadius: 0,
borderWidth: 0,
},
})}
</React.Fragment>
) : null
})}
</View>
{/* innermost element - needs an overflow: hidden for children, but we also need a shadow,
so put the shadow on the scaling element and the overflow on the innermost element */}
<View
style={[
a.flex_1,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
]}>
{flattenReactChildren(children).map((child, i) => {
return React.isValidElement(child) &&
(child.type === Item || child.type === Divider) ? (
<React.Fragment key={i}>
{i > 0 ? (
<View
style={[a.border_b, t.atoms.border_contrast_low]}
/>
) : null}
{React.cloneElement(child, {
// @ts-expect-error not typed
style: {
borderRadius: 0,
borderWidth: 0,
},
})}
</React.Fragment>
) : null
})}
</View>
</Animated.View>
</Animated.View>
</Animated.View>
</MenuContext.Provider>
</Context.Provider>
</Portal>
)
@@ -464,16 +579,52 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
onIn: onPressIn,
onOut: onPressOut,
} = 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 (
<Pressable
{...rest}
onLayout={handleLayout}
accessibilityHint=""
accessibilityLabel={label}
onFocus={onFocus}
onBlur={onBlur}
onPress={e => {
context.close()
close()
onPress?.(e)
}}
onPressIn={e => {
@@ -497,9 +648,10 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
t.atoms.border_contrast_low,
{minHeight: 40},
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}
</ItemContext.Provider>
</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
}
+28 -3
View File
@@ -1,17 +1,29 @@
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 * as Dialog from '#/components/Dialog'
import {RadixPassThroughTriggerProps} from '#/components/Menu/types'
import {
ItemProps as MenuItemProps,
RadixPassThroughTriggerProps,
} from '#/components/Menu/types'
export type {
GroupProps,
ItemIconProps,
ItemProps,
ItemTextProps,
} 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 = {
x: number
y: number
@@ -28,6 +40,19 @@ export type ContextType = {
translationSV: SharedValue<number>
open: (evt: Measurement) => 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 = {