Skip to content

Commit

Permalink
feat: interactions: export <MotiHover />, improve hover listeners; ad…
Browse files Browse the repository at this point in the history
…d onContainerLayout & onLayout
  • Loading branch information
nandorojo committed Dec 4, 2021
1 parent 8586b26 commit b77bf3f
Show file tree
Hide file tree
Showing 7 changed files with 98 additions and 47 deletions.
12 changes: 12 additions & 0 deletions packages/interactions/src/pressable/hoverable-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react'
import type Animated from 'react-native-reanimated'

const HoveredContext = createContext<Animated.SharedValue<boolean>>({
value: false,
})

const useIsHovered = () => {
return useContext(HoveredContext)
}

export { HoveredContext, useIsHovered }
11 changes: 11 additions & 0 deletions packages/interactions/src/pressable/hoverable.native.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react'
import { useSharedValue } from 'react-native-reanimated'
import { HoveredContext } from './hoverable-context'

export function Hoverable({ children }) {
return (
<HoveredContext.Provider value={useSharedValue(false)}>
{React.Children.only(children)}
</HoveredContext.Provider>
)
}
87 changes: 44 additions & 43 deletions packages/interactions/src/pressable/hoverable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<ReactChild>
childRef?: React.Ref<any>
}

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<undefined | (() => void)>(() => onHoverIn?.())
const hoverOut = useRef<undefined | (() => void)>(() => onHoverOut?.())
const pressIn = useRef<undefined | (() => void)>(() => onPressIn?.())
const pressOut = useRef<undefined | (() => void)>(() => onPressOut?.())

const localRef = useRef<HTMLDivElement>(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?.()
}
}
},
Expand All @@ -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 (
<HoveredContext.Provider value={isHovered}>
{React.cloneElement(child, {
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
ref: mergeRefs([localRef, childRef || null]),
})}
</HoveredContext.Provider>
)
}
2 changes: 2 additions & 0 deletions packages/interactions/src/pressable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
15 changes: 15 additions & 0 deletions packages/interactions/src/pressable/merge-refs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// credit: https://github.com/gregberge/react-merge-refs/blob/main/src/index.tsx

export function mergeRefs<T = any>(
refs: Array<React.MutableRefObject<T> | React.LegacyRef<T>>
): React.RefCallback<T> {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(value)
} else if (ref != null) {
;(ref as React.MutableRefObject<T | null>).current = value
}
})
}
}
12 changes: 9 additions & 3 deletions packages/interactions/src/pressable/pressable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
useMotiPressableContext,
INTERACTION_CONTAINER_ID,
} from './context'
import Hoverable from './hoverable'
import { Hoverable } from './hoverable'

const AnimatedTouchable = Animated.createAnimatedComponent(
TouchableWithoutFeedback
Expand Down Expand Up @@ -43,6 +43,8 @@ export const MotiPressable = forwardRef<View, MotiPressableProps>(
id,
hoveredValue,
pressedValue,
onLayout,
onContainerLayout,
// Accessibility props
accessibilityActions,
accessibilityElementsHidden,
Expand Down Expand Up @@ -118,6 +120,7 @@ export const MotiPressable = forwardRef<View, MotiPressableProps>(
exitTransition={exitTransition}
state={state}
style={style}
onLayout={onLayout}
>
{children}
</MotiView>
Expand All @@ -129,16 +132,18 @@ export const MotiPressable = forwardRef<View, MotiPressableProps>(
<Hoverable
onHoverIn={updateInteraction('hovered', true, onHoverIn)}
onHoverOut={updateInteraction('hovered', false, onHoverOut)}
onPressIn={updateInteraction('pressed', true, onPressIn)}
onPressOut={updateInteraction('pressed', false, onPressOut)}
childRef={ref}
>
<Pressable
onLongPress={onLongPress}
hitSlop={hitSlop}
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}
Expand Down Expand Up @@ -170,6 +175,7 @@ export const MotiPressable = forwardRef<View, MotiPressableProps>(
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?
Expand Down
6 changes: 5 additions & 1 deletion packages/interactions/src/pressable/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>
/**
* `onLayout` for the container component.
*/
onContainerLayout?: PressableProps['onLayout']
} & Pick<
ComponentProps<typeof MotiView>,
'children' | 'exit' | 'from' | 'exitTransition' | 'style'
'children' | 'exit' | 'from' | 'exitTransition' | 'style' | 'onLayout'
> &
Pick<
PressableProps,
Expand Down

0 comments on commit b77bf3f

Please sign in to comment.