[DMs] Emoji reaction picker (#8023)
This commit is contained in:
@@ -2,26 +2,36 @@ import {Pressable} from 'react-native'
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
SharedValue,
|
||||
type SharedValue,
|
||||
useAnimatedProps,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {BlurView} from 'expo-blur'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useContextMenuContext} from './context'
|
||||
|
||||
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
|
||||
|
||||
export function Backdrop({
|
||||
animation,
|
||||
intensity = 50,
|
||||
onPress,
|
||||
}: {
|
||||
type Props = {
|
||||
animation: SharedValue<number>
|
||||
intensity?: number
|
||||
onPress?: () => void
|
||||
}) {
|
||||
}
|
||||
|
||||
export function Backdrop(props: Props) {
|
||||
const {mode} = useContextMenuContext()
|
||||
switch (mode) {
|
||||
case 'full':
|
||||
return <BlurredBackdrop {...props} />
|
||||
case 'auxiliary-only':
|
||||
return <OpacityBackdrop {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
function BlurredBackdrop({animation, intensity = 50, onPress}: Props) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const animatedProps = useAnimatedProps(() => ({
|
||||
@@ -37,7 +47,7 @@ export function Backdrop({
|
||||
<AnimatedBlurView
|
||||
animatedProps={animatedProps}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
tint="systemThinMaterialDark">
|
||||
tint="systemMaterialDark">
|
||||
<Pressable
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={_(msg`Close menu`)}
|
||||
@@ -47,3 +57,29 @@ export function Backdrop({
|
||||
</AnimatedBlurView>
|
||||
)
|
||||
}
|
||||
|
||||
function OpacityBackdrop({animation, onPress}: Props) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
animation.get(),
|
||||
[0, 1],
|
||||
[0, 0.05],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
}))
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[a.absolute, a.inset_0, t.atoms.bg_contrast_975, animatedStyle]}>
|
||||
<Pressable
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={_(msg`Close menu`)}
|
||||
accessibilityHint={_(msg`Tap to close context menu`)}
|
||||
onPress={onPress}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useContextMenuContext} from './context'
|
||||
|
||||
export function Backdrop({
|
||||
animation,
|
||||
@@ -21,12 +22,17 @@ export function Backdrop({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {mode} = useContextMenuContext()
|
||||
|
||||
const reduced = mode === 'auxiliary-only'
|
||||
|
||||
const target = reduced ? 0.05 : intensity / 100
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
animation.get(),
|
||||
[0, 1],
|
||||
[0, intensity / 100],
|
||||
[0, target],
|
||||
Extrapolation.CLAMP,
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -9,37 +9,37 @@ import React, {
|
||||
import {
|
||||
BackHandler,
|
||||
Keyboard,
|
||||
LayoutChangeEvent,
|
||||
type LayoutChangeEvent,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
type StyleProp,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
ViewStyle,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
GestureStateChangeEvent,
|
||||
GestureUpdateEvent,
|
||||
PanGestureHandlerEventPayload,
|
||||
type GestureStateChangeEvent,
|
||||
type GestureUpdateEvent,
|
||||
type PanGestureHandlerEventPayload,
|
||||
} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
clamp,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
SharedValue,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
WithSpringConfig,
|
||||
type WithSpringConfig,
|
||||
} from 'react-native-reanimated'
|
||||
import {
|
||||
useSafeAreaFrame,
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context'
|
||||
import {captureRef} from 'react-native-view-shot'
|
||||
import {Image, ImageErrorEventData} from 'expo-image'
|
||||
import {Image, type ImageErrorEventData} from 'expo-image'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
@@ -60,12 +60,13 @@ import {
|
||||
useContextMenuMenuContext,
|
||||
} from '#/components/ContextMenu/context'
|
||||
import {
|
||||
ContextType,
|
||||
ItemIconProps,
|
||||
ItemProps,
|
||||
ItemTextProps,
|
||||
Measurement,
|
||||
TriggerProps,
|
||||
type AuxiliaryViewProps,
|
||||
type ContextType,
|
||||
type ItemIconProps,
|
||||
type ItemProps,
|
||||
type ItemTextProps,
|
||||
type Measurement,
|
||||
type TriggerProps,
|
||||
} from '#/components/ContextMenu/types'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {createPortalGroup} from '#/components/Portal'
|
||||
@@ -79,7 +80,14 @@ export {
|
||||
|
||||
const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup()
|
||||
|
||||
const SPRING: WithSpringConfig = {
|
||||
const SPRING_IN: WithSpringConfig = {
|
||||
mass: isIOS ? 1.25 : 0.75,
|
||||
damping: 50,
|
||||
stiffness: 1100,
|
||||
restDisplacementThreshold: 0.01,
|
||||
}
|
||||
|
||||
const SPRING_OUT: WithSpringConfig = {
|
||||
mass: isIOS ? 1.25 : 0.75,
|
||||
damping: 150,
|
||||
stiffness: 1000,
|
||||
@@ -100,6 +108,7 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
|
||||
export function Root({children}: {children: React.ReactNode}) {
|
||||
const playHaptic = useHaptics()
|
||||
const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full')
|
||||
const [measurement, setMeasurement] = useState<Measurement | null>(null)
|
||||
const animationSV = useSharedValue(0)
|
||||
const translationSV = useSharedValue(0)
|
||||
@@ -134,13 +143,15 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
measurement,
|
||||
animationSV,
|
||||
translationSV,
|
||||
open: (evt: Measurement) => {
|
||||
mode,
|
||||
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => {
|
||||
setMeasurement(evt)
|
||||
animationSV.set(withSpring(1, SPRING))
|
||||
setMode(mode)
|
||||
animationSV.set(withSpring(1, SPRING_IN))
|
||||
},
|
||||
close: () => {
|
||||
animationSV.set(
|
||||
withSpring(0, SPRING, finished => {
|
||||
withSpring(0, SPRING_OUT, finished => {
|
||||
if (finished) {
|
||||
hoverablesSV.set({})
|
||||
translationSV.set(0)
|
||||
@@ -192,6 +203,7 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
hoveredMenuItem,
|
||||
setHoveredMenuItem,
|
||||
playHaptic,
|
||||
mode,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -216,45 +228,49 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
const ref = useRef<View>(null)
|
||||
const isFocused = useIsFocused()
|
||||
const [image, setImage] = useState<string | null>(null)
|
||||
const [pendingMeasurement, setPendingMeasurement] =
|
||||
useState<Measurement | null>(null)
|
||||
const [pendingMeasurement, setPendingMeasurement] = useState<{
|
||||
measurement: Measurement
|
||||
mode: 'full' | 'auxiliary-only'
|
||||
} | null>(null)
|
||||
|
||||
const open = useNonReactiveCallback(async () => {
|
||||
playHaptic()
|
||||
Keyboard.dismiss()
|
||||
const [measurement, capture] = await Promise.all([
|
||||
new Promise<Measurement>(resolve => {
|
||||
ref.current?.measureInWindow((x, y, width, height) =>
|
||||
resolve({
|
||||
x,
|
||||
y:
|
||||
y +
|
||||
platform({
|
||||
default: 0,
|
||||
android: topInset, // not included in measurement
|
||||
}),
|
||||
width,
|
||||
height,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
captureRef(ref, {result: 'data-uri'}).catch(err => {
|
||||
logger.error(err instanceof Error ? err : String(err), {
|
||||
message: 'Failed to capture image of context menu trigger',
|
||||
})
|
||||
// will cause the image to fail to load, but it will get handled gracefully
|
||||
return '<failed capture>'
|
||||
}),
|
||||
])
|
||||
setImage(capture)
|
||||
setPendingMeasurement(measurement)
|
||||
})
|
||||
const open = useNonReactiveCallback(
|
||||
async (mode: 'full' | 'auxiliary-only') => {
|
||||
playHaptic()
|
||||
Keyboard.dismiss()
|
||||
const [measurement, capture] = await Promise.all([
|
||||
new Promise<Measurement>(resolve => {
|
||||
ref.current?.measureInWindow((x, y, width, height) =>
|
||||
resolve({
|
||||
x,
|
||||
y:
|
||||
y +
|
||||
platform({
|
||||
default: 0,
|
||||
android: topInset, // not included in measurement
|
||||
}),
|
||||
width,
|
||||
height,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
captureRef(ref, {result: 'data-uri'}).catch(err => {
|
||||
logger.error(err instanceof Error ? err : String(err), {
|
||||
message: 'Failed to capture image of context menu trigger',
|
||||
})
|
||||
// will cause the image to fail to load, but it will get handled gracefully
|
||||
return '<failed capture>'
|
||||
}),
|
||||
])
|
||||
setImage(capture)
|
||||
setPendingMeasurement({measurement, mode})
|
||||
},
|
||||
)
|
||||
|
||||
const doubleTapGesture = useMemo(() => {
|
||||
return Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.hitSlop(HITSLOP_10)
|
||||
.onEnd(open)
|
||||
.onEnd(() => open('auxiliary-only'))
|
||||
.runOnJS(true)
|
||||
}, [open])
|
||||
|
||||
@@ -283,17 +299,19 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
.averageTouches(true)
|
||||
.onStart(() => {
|
||||
'worklet'
|
||||
runOnJS(open)()
|
||||
runOnJS(open)('full')
|
||||
})
|
||||
.onUpdate(evt => {
|
||||
'worklet'
|
||||
const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
|
||||
hoveredItemSV.set(item)
|
||||
})
|
||||
.onEnd(evt => {
|
||||
.onEnd(() => {
|
||||
'worklet'
|
||||
const item = getHoveredHoverable(evt, hoverablesSV, translationSV)
|
||||
hoveredItemSV.set(null)
|
||||
// don't recalculate hovered item - if they haven't moved their finger from
|
||||
// the initial press, it's jarring to then select the item underneath
|
||||
// as the menu may have slid into place beneath their finger
|
||||
const item = hoveredItemSV.get()
|
||||
if (item) {
|
||||
runOnJS(onTouchUpMenuItem)(item)
|
||||
}
|
||||
@@ -305,7 +323,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
pressAndHoldGesture,
|
||||
)
|
||||
|
||||
const measurement = context.measurement || pendingMeasurement
|
||||
const measurement = context.measurement || pendingMeasurement?.measurement
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -343,7 +361,10 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) {
|
||||
measurement={measurement}
|
||||
onDisplay={() => {
|
||||
if (pendingMeasurement) {
|
||||
context.open(pendingMeasurement)
|
||||
context.open(
|
||||
pendingMeasurement.measurement,
|
||||
pendingMeasurement.mode,
|
||||
)
|
||||
setPendingMeasurement(null)
|
||||
}
|
||||
}}
|
||||
@@ -416,7 +437,90 @@ function TriggerClone({
|
||||
)
|
||||
}
|
||||
|
||||
const MENU_WIDTH = 230
|
||||
export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
|
||||
const context = useContextMenuContext()
|
||||
const {width: screenWidth} = useWindowDimensions()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const ensureOnScreenTranslationSV = useSharedValue(0)
|
||||
|
||||
const {isOpen, mode, measurement, translationSV, animationSV} = context
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: clamp(animationSV.get(), 0, 1),
|
||||
transform: [
|
||||
{
|
||||
translateY:
|
||||
(ensureOnScreenTranslationSV.get() || translationSV.get()) *
|
||||
animationSV.get(),
|
||||
},
|
||||
{scale: interpolate(animationSV.get(), [0, 1], [0.2, 1])},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const menuContext = useMemo(() => ({align}), [align])
|
||||
|
||||
const onLayout = useCallback(() => {
|
||||
if (!measurement) return
|
||||
|
||||
let translation = 0
|
||||
|
||||
// vibes based, just assuming it'll fit within this space. revisit if we use
|
||||
// AuxiliaryView for something tall
|
||||
const TOP_INSET = topInset + 80
|
||||
|
||||
const distanceMessageFromTop = measurement.y - TOP_INSET
|
||||
if (distanceMessageFromTop < 0) {
|
||||
translation = -distanceMessageFromTop
|
||||
}
|
||||
|
||||
// normally, the context menu is responsible for measuring itself and moving everything into the right place
|
||||
// however, in auxiliary-only mode, that doesn't happen, so we need to do it ourselves here
|
||||
if (mode === 'auxiliary-only') {
|
||||
translationSV.set(translation)
|
||||
ensureOnScreenTranslationSV.set(0)
|
||||
}
|
||||
// however, we also need to make sure that for super tall triggers, we don't go off the screen
|
||||
// so we have an additional cap on the standard transform every other element has
|
||||
// note: this breaks the press-and-hold gesture for the reaction items. unfortunately I think
|
||||
// we'll just have to live with it for now, fixing it would be possible but be a large complexity
|
||||
// increase for an edge case
|
||||
else {
|
||||
ensureOnScreenTranslationSV.set(translation)
|
||||
}
|
||||
}, [mode, measurement, translationSV, topInset, ensureOnScreenTranslationSV])
|
||||
|
||||
if (!isOpen || !measurement) return null
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Context.Provider value={context}>
|
||||
<MenuContext.Provider value={menuContext}>
|
||||
<Animated.View
|
||||
onLayout={onLayout}
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
top: measurement.y,
|
||||
transformOrigin:
|
||||
align === 'left' ? 'bottom left' : 'bottom right',
|
||||
},
|
||||
align === 'left'
|
||||
? {left: measurement.x}
|
||||
: {right: screenWidth - measurement.x - measurement.width},
|
||||
animatedStyle,
|
||||
a.z_20,
|
||||
]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</MenuContext.Provider>
|
||||
</Context.Provider>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
const MENU_WIDTH = 240
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
@@ -491,85 +595,95 @@ export function Outer({
|
||||
<Context.Provider value={context}>
|
||||
<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.absolute,
|
||||
a.z_10,
|
||||
a.mt_xs,
|
||||
{
|
||||
width: MENU_WIDTH,
|
||||
top: context.measurement.y + context.measurement.height,
|
||||
},
|
||||
align === 'left'
|
||||
? {left: context.measurement.x}
|
||||
: {
|
||||
right:
|
||||
screenWidth -
|
||||
context.measurement.x -
|
||||
context.measurement.width,
|
||||
},
|
||||
animatedContainerStyle,
|
||||
]}>
|
||||
{/* scaling element - has the scale/fade animation on it */}
|
||||
{context.mode === 'full' && (
|
||||
/* 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:
|
||||
// "top right" doesn't seem to work on android, so set explicity in pixels
|
||||
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 explicitly 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>
|
||||
)
|
||||
}
|
||||
|
||||
export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
export function Item({
|
||||
children,
|
||||
label,
|
||||
unstyled,
|
||||
style,
|
||||
onPress,
|
||||
position,
|
||||
...rest
|
||||
}: ItemProps) {
|
||||
const t = useTheme()
|
||||
const context = useContextMenuContext()
|
||||
const playHaptic = useHaptics()
|
||||
@@ -590,16 +704,22 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
|
||||
const layout = evt.nativeEvent.layout
|
||||
|
||||
const yOffset = position
|
||||
? position.y
|
||||
: measurement.y + measurement.height + tokens.space.xs
|
||||
const xOffset = position
|
||||
? position.x
|
||||
: align === 'left'
|
||||
? measurement.x
|
||||
: measurement.x + measurement.width - layout.width
|
||||
|
||||
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,
|
||||
y: yOffset + layout.y,
|
||||
x: xOffset + layout.x,
|
||||
},
|
||||
() => {
|
||||
close()
|
||||
@@ -607,7 +727,7 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
},
|
||||
)
|
||||
},
|
||||
[id, measurement, registerHoverable, close, onPress, align],
|
||||
[id, measurement, registerHoverable, close, onPress, align, position],
|
||||
)
|
||||
|
||||
const itemContext = useMemo(
|
||||
@@ -637,22 +757,26 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
rest.onPressOut?.(e)
|
||||
}}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.py_sm,
|
||||
a.px_md,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
{minHeight: 40},
|
||||
!unstyled && [
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.px_md,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
{minHeight: 44, paddingVertical: 10},
|
||||
(focused || pressed || context.hoveredMenuItem === id) &&
|
||||
!rest.disabled &&
|
||||
t.atoms.bg_contrast_50,
|
||||
],
|
||||
style,
|
||||
(focused || pressed || context.hoveredMenuItem === id) &&
|
||||
!rest.disabled && [t.atoms.bg_contrast_50],
|
||||
]}>
|
||||
<ItemContext.Provider value={itemContext}>
|
||||
{children}
|
||||
{typeof children === 'function'
|
||||
? children(focused || pressed || context.hoveredMenuItem === id)
|
||||
: children}
|
||||
</ItemContext.Provider>
|
||||
</Pressable>
|
||||
)
|
||||
@@ -667,7 +791,7 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
ellipsizeMode="middle"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
{paddingTop: 3},
|
||||
@@ -684,7 +808,7 @@ export function ItemIcon({icon: Comp}: ItemIconProps) {
|
||||
const {disabled} = useContextMenuItemContext()
|
||||
return (
|
||||
<Comp
|
||||
size="md"
|
||||
size="lg"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import {type AuxiliaryViewProps} from './types'
|
||||
|
||||
export * from '#/components/Menu'
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
// native only
|
||||
export function AuxiliaryView({}: AuxiliaryViewProps) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AccessibilityRole,
|
||||
GestureResponderEvent,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
type AccessibilityRole,
|
||||
type GestureResponderEvent,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {SharedValue} from 'react-native-reanimated'
|
||||
import {type SharedValue} from 'react-native-reanimated'
|
||||
import type React from 'react'
|
||||
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import type * as Dialog from '#/components/Dialog'
|
||||
import {
|
||||
ItemProps as MenuItemProps,
|
||||
RadixPassThroughTriggerProps,
|
||||
type ItemProps as MenuItemProps,
|
||||
type RadixPassThroughTriggerProps,
|
||||
} from '#/components/Menu/types'
|
||||
|
||||
export type {
|
||||
@@ -19,9 +19,19 @@ export type {
|
||||
ItemTextProps,
|
||||
} from '#/components/Menu/types'
|
||||
|
||||
// Same as Menu.ItemProps, but onPress is not guaranteed to get an event
|
||||
export type ItemProps = Omit<MenuItemProps, 'onPress'> & {
|
||||
export type AuxiliaryViewProps = {
|
||||
children?: React.ReactNode
|
||||
align?: 'left' | 'right'
|
||||
}
|
||||
|
||||
export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & {
|
||||
// remove default styles (i.e. for emoji reactions)
|
||||
unstyled?: boolean
|
||||
onPress: (evt?: GestureResponderEvent) => void
|
||||
children?: React.ReactNode | ((hovered: boolean) => React.ReactNode)
|
||||
// absolute position of the parent element. if undefined, assumed to
|
||||
// be in the context menu. use this if using AuxiliaryView
|
||||
position?: Measurement
|
||||
}
|
||||
|
||||
export type Measurement = {
|
||||
@@ -38,7 +48,8 @@ export type ContextType = {
|
||||
animationSV: SharedValue<number>
|
||||
/* Translation in Y axis to ensure everything's onscreen */
|
||||
translationSV: SharedValue<number>
|
||||
open: (evt: Measurement) => void
|
||||
mode: 'full' | 'auxiliary-only'
|
||||
open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void
|
||||
close: () => void
|
||||
registerHoverable: (
|
||||
id: string,
|
||||
@@ -76,7 +87,10 @@ export type TriggerProps = {
|
||||
export type TriggerChildProps =
|
||||
| {
|
||||
isNative: true
|
||||
control: {isOpen: boolean; open: () => void}
|
||||
control: {
|
||||
isOpen: boolean
|
||||
open: (mode: 'full' | 'auxiliary-only') => void
|
||||
}
|
||||
state: {
|
||||
hovered: false
|
||||
focused: false
|
||||
|
||||
Reference in New Issue
Block a user