diff --git a/packages/interactions/src/pressable/hoverable-context.tsx b/packages/interactions/src/pressable/hoverable-context.tsx new file mode 100644 index 0000000..a31dce7 --- /dev/null +++ b/packages/interactions/src/pressable/hoverable-context.tsx @@ -0,0 +1,12 @@ +import { createContext, useContext } from 'react' +import type Animated from 'react-native-reanimated' + +const HoveredContext = createContext>({ + value: false, +}) + +const useIsHovered = () => { + return useContext(HoveredContext) +} + +export { HoveredContext, useIsHovered } diff --git a/packages/interactions/src/pressable/hoverable.native.tsx b/packages/interactions/src/pressable/hoverable.native.tsx new file mode 100644 index 0000000..addefc8 --- /dev/null +++ b/packages/interactions/src/pressable/hoverable.native.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import { useSharedValue } from 'react-native-reanimated' +import { HoveredContext } from './hoverable-context' + +export function Hoverable({ children }) { + return ( + + {React.Children.only(children)} + + ) +} diff --git a/packages/interactions/src/pressable/hoverable.tsx b/packages/interactions/src/pressable/hoverable.tsx index 1aab7c1..7059540 100644 --- a/packages/interactions/src/pressable/hoverable.tsx +++ b/packages/interactions/src/pressable/hoverable.tsx @@ -2,6 +2,7 @@ // this file was repurosed from there // via this issue https://gist.github.com/necolas/1c494e44e23eb7f8c5864a2fac66299a // because RNW's pressable doesn't bubble events to parent pressables: https://github.com/necolas/react-native-web/issues/1875 +// click listeners copied from https://gist.github.com/roryabraham/65cd1d2d5e8a48da78fec6a6e3105398 /* eslint-disable no-inner-declarations */ import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment' @@ -43,51 +44,67 @@ function isHoverEnabled(): boolean { return isEnabled } -import React, { useCallback, ReactChild, useRef } from 'react' +import React, { useCallback, ReactChild, useRef, useEffect } from 'react' import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated' -import { Platform } from 'react-native' +import { HoveredContext } from './hoverable-context' +import { mergeRefs } from './merge-refs' export interface HoverableProps { onHoverIn?: () => void onHoverOut?: () => void - onPressIn?: () => void - onPressOut?: () => void children: NonNullable + childRef?: React.Ref } -export default function Hoverable({ +export function Hoverable({ onHoverIn, onHoverOut, children, - onPressIn, - onPressOut, + childRef, }: HoverableProps) { - const showHover = useSharedValue(true) const isHovered = useSharedValue(false) const hoverIn = useRef void)>(() => onHoverIn?.()) const hoverOut = useRef void)>(() => onHoverOut?.()) - const pressIn = useRef void)>(() => onPressIn?.()) - const pressOut = useRef void)>(() => onPressOut?.()) + + const localRef = useRef(null) hoverIn.current = onHoverIn hoverOut.current = onHoverOut - pressIn.current = onPressIn - pressOut.current = onPressOut + useEffect( + function disableHoverOnClickOutside() { + // https://gist.github.com/necolas/1c494e44e23eb7f8c5864a2fac66299a#gistcomment-3629646 + const listener = (event: MouseEvent) => { + if ( + localRef.current && + event.target instanceof HTMLElement && + !localRef.current.contains(event.target) + ) { + isHovered.value = false + } + } + document.addEventListener('mousedown', listener) + + return () => { + document.removeEventListener('mousedown', listener) + } + }, + [isHovered] + ) + + // eslint-disable-next-line react-hooks/rules-of-hooks useAnimatedReaction( () => { // hovering out via click won't trigger this - // return Platform.OS === 'web' && showHover.value && isHovered.value - return Platform.OS === 'web' && isHovered.value + return isHovered.value }, (hovered, previouslyHovered) => { if (hovered !== previouslyHovered) { if (hovered) { - // no need for runOnJS, it's always web hoverIn.current?.() - } else if (hoverOut.current) { - hoverOut.current() + } else { + hoverOut.current?.() } } }, @@ -106,31 +123,15 @@ export default function Hoverable({ } }, [isHovered]) - const handleGrant = useCallback(() => { - showHover.value = false - pressIn.current?.() - }, [showHover]) - - const handleRelease = useCallback(() => { - showHover.value = true - pressOut.current?.() - }, [showHover]) - - let webProps = {} - if (Platform.OS === 'web') { - webProps = { - onMouseEnter: handleMouseEnter, - onMouseLeave: handleMouseLeave, - // prevent hover showing while responder - onResponderGrant: handleGrant, - onResponderRelease: handleRelease, - } - } + const child = React.Children.only(children) as React.ReactElement - return React.cloneElement(React.Children.only(children) as any, { - ...webProps, - // if child is Touchable - onPressIn: handleGrant, - onPressOut: handleRelease, - }) + return ( + + {React.cloneElement(child, { + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + ref: mergeRefs([localRef, childRef || null]), + })} + + ) } diff --git a/packages/interactions/src/pressable/index.tsx b/packages/interactions/src/pressable/index.tsx index b9c3ee2..dfc66b9 100644 --- a/packages/interactions/src/pressable/index.tsx +++ b/packages/interactions/src/pressable/index.tsx @@ -7,3 +7,5 @@ export * from './use-moti-pressable-animated-props' export * from './use-moti-pressable-interpolate' export * from './use-moti-pressable-transition' export * from './merge' +export { Hoverable as MotiHover } from './hoverable' +export { useIsHovered as useMotiHover } from './hoverable-context' diff --git a/packages/interactions/src/pressable/merge-refs.ts b/packages/interactions/src/pressable/merge-refs.ts new file mode 100644 index 0000000..812e21b --- /dev/null +++ b/packages/interactions/src/pressable/merge-refs.ts @@ -0,0 +1,15 @@ +// credit: https://github.com/gregberge/react-merge-refs/blob/main/src/index.tsx + +export function mergeRefs( + refs: Array | React.LegacyRef> +): React.RefCallback { + return (value) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(value) + } else if (ref != null) { + ;(ref as React.MutableRefObject).current = value + } + }) + } +} diff --git a/packages/interactions/src/pressable/pressable.tsx b/packages/interactions/src/pressable/pressable.tsx index 5af04d1..db18a09 100644 --- a/packages/interactions/src/pressable/pressable.tsx +++ b/packages/interactions/src/pressable/pressable.tsx @@ -14,7 +14,7 @@ import { useMotiPressableContext, INTERACTION_CONTAINER_ID, } from './context' -import Hoverable from './hoverable' +import { Hoverable } from './hoverable' const AnimatedTouchable = Animated.createAnimatedComponent( TouchableWithoutFeedback @@ -43,6 +43,8 @@ export const MotiPressable = forwardRef( id, hoveredValue, pressedValue, + onLayout, + onContainerLayout, // Accessibility props accessibilityActions, accessibilityElementsHidden, @@ -118,6 +120,7 @@ export const MotiPressable = forwardRef( exitTransition={exitTransition} state={state} style={style} + onLayout={onLayout} > {children} @@ -129,8 +132,7 @@ export const MotiPressable = forwardRef( ( disabled={disabled} style={containerStyle} onPress={onPress} + onPressIn={updateInteraction('pressed', true, onPressIn)} + onPressOut={updateInteraction('pressed', false, onPressOut)} ref={ref} + onLayout={onContainerLayout} // Accessibility props accessibilityActions={accessibilityActions} accessibilityElementsHidden={accessibilityElementsHidden} @@ -170,6 +175,7 @@ export const MotiPressable = forwardRef( disabled={disabled} onPress={onPress} ref={ref} + onLayout={onContainerLayout} // @ts-expect-error missing containerStyle type // TODO there is an added View child here, which Pressable doesn't have. // should we wrap the pressable children too? diff --git a/packages/interactions/src/pressable/types.ts b/packages/interactions/src/pressable/types.ts index bb6a03d..7f887e1 100644 --- a/packages/interactions/src/pressable/types.ts +++ b/packages/interactions/src/pressable/types.ts @@ -94,9 +94,13 @@ export type MotiPressableProps = { * This lets you get access to the pressed state from outside of the component in a controlled fashion. */ hoveredValue?: Animated.SharedValue + /** + * `onLayout` for the container component. + */ + onContainerLayout?: PressableProps['onLayout'] } & Pick< ComponentProps, - 'children' | 'exit' | 'from' | 'exitTransition' | 'style' + 'children' | 'exit' | 'from' | 'exitTransition' | 'style' | 'onLayout' > & Pick< PressableProps,