Fix hover card animation with a reducer (#3547)
parent
eeb1b5e3b4
commit
41925bdc07
|
@ -5,7 +5,6 @@ import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
@ -51,7 +50,18 @@ export function ProfileHoverCard(props: ProfileHoverCardProps) {
|
||||||
return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} />
|
return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = 'hidden' | 'might-show' | 'showing' | 'might-hide' | 'hiding'
|
type State = {
|
||||||
|
stage: 'hidden' | 'might-show' | 'showing' | 'might-hide' | 'hiding'
|
||||||
|
effect?: () => () => any
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| 'pressed'
|
||||||
|
| 'hovered'
|
||||||
|
| 'unhovered'
|
||||||
|
| 'show-timer-elapsed'
|
||||||
|
| 'hide-timer-elapsed'
|
||||||
|
| 'hide-animation-completed'
|
||||||
|
|
||||||
const SHOW_DELAY = 350
|
const SHOW_DELAY = 350
|
||||||
const SHOW_DURATION = 300
|
const SHOW_DURATION = 300
|
||||||
|
@ -59,16 +69,109 @@ const HIDE_DELAY = 200
|
||||||
const HIDE_DURATION = 200
|
const HIDE_DURATION = 200
|
||||||
|
|
||||||
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
||||||
const [state, setState] = React.useState<State>('hidden')
|
|
||||||
const {refs, floatingStyles} = useFloating({
|
const {refs, floatingStyles} = useFloating({
|
||||||
middleware: floatingMiddlewares,
|
middleware: floatingMiddlewares,
|
||||||
})
|
})
|
||||||
const animationStyle = {
|
|
||||||
animation:
|
const [currentState, dispatch] = React.useReducer(
|
||||||
state === 'hiding'
|
// Tip: console.log(state, action) when debugging.
|
||||||
? `avatarHoverFadeOut ${HIDE_DURATION}ms both`
|
(state: State, action: Action): State => {
|
||||||
: `avatarHoverFadeIn ${SHOW_DURATION}ms both`,
|
// Regardless of which stage we're in, pressing always hides the card.
|
||||||
}
|
if (action === 'pressed') {
|
||||||
|
return {stage: 'hidden'}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.stage === 'hidden') {
|
||||||
|
// Our story starts when the card is hidden.
|
||||||
|
// If the user hovers, we kick off a grace period before showing the card.
|
||||||
|
if (action === 'hovered') {
|
||||||
|
return {
|
||||||
|
stage: 'might-show',
|
||||||
|
effect() {
|
||||||
|
const id = setTimeout(
|
||||||
|
() => dispatch('show-timer-elapsed'),
|
||||||
|
SHOW_DELAY,
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
clearTimeout(id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.stage === 'might-show') {
|
||||||
|
// We're in the grace period when we decide whether to show the card.
|
||||||
|
// At this point, two things can happen. Either the user unhovers, and
|
||||||
|
// we go back to hidden--or they linger enough that we'll show the card.
|
||||||
|
if (action === 'unhovered') {
|
||||||
|
return {stage: 'hidden'}
|
||||||
|
}
|
||||||
|
if (action === 'show-timer-elapsed') {
|
||||||
|
return {stage: 'showing'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.stage === 'showing') {
|
||||||
|
// We're showing the card now.
|
||||||
|
// If the user unhovers, we'll start a grace period before hiding the card.
|
||||||
|
if (action === 'unhovered') {
|
||||||
|
return {
|
||||||
|
stage: 'might-hide',
|
||||||
|
effect() {
|
||||||
|
const id = setTimeout(
|
||||||
|
() => dispatch('hide-timer-elapsed'),
|
||||||
|
HIDE_DELAY,
|
||||||
|
)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.stage === 'might-hide') {
|
||||||
|
// We're in the grace period when we decide whether to hide the card.
|
||||||
|
// At this point, two things can happen. Either the user hovers, and
|
||||||
|
// we go back to showing it--or they linger enough that we'll start hiding the card.
|
||||||
|
if (action === 'hovered') {
|
||||||
|
return {stage: 'showing'}
|
||||||
|
}
|
||||||
|
if (action === 'hide-timer-elapsed') {
|
||||||
|
return {
|
||||||
|
stage: 'hiding',
|
||||||
|
effect() {
|
||||||
|
const id = setTimeout(
|
||||||
|
() => dispatch('hide-animation-completed'),
|
||||||
|
HIDE_DURATION,
|
||||||
|
)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.stage === 'hiding') {
|
||||||
|
// We're currently playing the hiding animation.
|
||||||
|
// We'll ignore all inputs now and wait for the animation to finish.
|
||||||
|
// At that point, we'll hide the entire thing, going back to square one.
|
||||||
|
if (action === 'hide-animation-completed') {
|
||||||
|
return {stage: 'hidden'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Something else happened. Keep calm and carry on.
|
||||||
|
return state
|
||||||
|
},
|
||||||
|
{stage: 'hidden'},
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (currentState.effect) {
|
||||||
|
const effect = currentState.effect
|
||||||
|
delete currentState.effect // Mark as completed
|
||||||
|
return effect()
|
||||||
|
}
|
||||||
|
}, [currentState])
|
||||||
|
|
||||||
const prefetchProfileQuery = usePrefetchProfileQuery()
|
const prefetchProfileQuery = usePrefetchProfileQuery()
|
||||||
const prefetchedProfile = React.useRef(false)
|
const prefetchedProfile = React.useRef(false)
|
||||||
|
@ -79,95 +182,45 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
||||||
}
|
}
|
||||||
}, [prefetchProfileQuery, props.did])
|
}, [prefetchProfileQuery, props.did])
|
||||||
|
|
||||||
const isVisible =
|
|
||||||
state === 'showing' || state === 'might-hide' || state === 'hiding'
|
|
||||||
|
|
||||||
// We need at most one timeout at a time (to transition to the next state).
|
|
||||||
const nextTimeout = React.useRef<NodeJS.Timeout | null>(null)
|
|
||||||
const transitionToState = React.useCallback((nextState: State) => {
|
|
||||||
if (nextTimeout.current) {
|
|
||||||
clearTimeout(nextTimeout.current)
|
|
||||||
nextTimeout.current = null
|
|
||||||
}
|
|
||||||
setState(nextState)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const onReadyToShow = useNonReactiveCallback(() => {
|
|
||||||
if (state === 'might-show') {
|
|
||||||
transitionToState('showing')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onReadyToHide = useNonReactiveCallback(() => {
|
|
||||||
if (state === 'might-hide') {
|
|
||||||
transitionToState('hiding')
|
|
||||||
nextTimeout.current = setTimeout(onHidingAnimationEnd, HIDE_DURATION)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onHidingAnimationEnd = useNonReactiveCallback(() => {
|
|
||||||
if (state === 'hiding') {
|
|
||||||
transitionToState('hidden')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onReceiveHover = useNonReactiveCallback(() => {
|
|
||||||
prefetchIfNeeded()
|
|
||||||
if (state === 'hidden') {
|
|
||||||
transitionToState('might-show')
|
|
||||||
nextTimeout.current = setTimeout(onReadyToShow, SHOW_DELAY)
|
|
||||||
} else if (state === 'might-show') {
|
|
||||||
// Do nothing
|
|
||||||
} else if (state === 'showing') {
|
|
||||||
// Do nothing
|
|
||||||
} else if (state === 'might-hide') {
|
|
||||||
transitionToState('showing')
|
|
||||||
} else if (state === 'hiding') {
|
|
||||||
transitionToState('showing')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onLoseHover = useNonReactiveCallback(() => {
|
|
||||||
if (state === 'hidden') {
|
|
||||||
// Do nothing
|
|
||||||
} else if (state === 'might-show') {
|
|
||||||
transitionToState('hidden')
|
|
||||||
} else if (state === 'showing') {
|
|
||||||
transitionToState('might-hide')
|
|
||||||
nextTimeout.current = setTimeout(onReadyToHide, HIDE_DELAY)
|
|
||||||
} else if (state === 'might-hide') {
|
|
||||||
// Do nothing
|
|
||||||
} else if (state === 'hiding') {
|
|
||||||
// Do nothing
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const onPointerEnterTarget = React.useCallback(() => {
|
const onPointerEnterTarget = React.useCallback(() => {
|
||||||
onReceiveHover()
|
prefetchIfNeeded()
|
||||||
}, [onReceiveHover])
|
dispatch('hovered')
|
||||||
|
}, [prefetchIfNeeded])
|
||||||
|
|
||||||
const onPointerLeaveTarget = React.useCallback(() => {
|
const onPointerLeaveTarget = React.useCallback(() => {
|
||||||
onLoseHover()
|
dispatch('unhovered')
|
||||||
}, [onLoseHover])
|
}, [])
|
||||||
|
|
||||||
const onPointerEnterCard = React.useCallback(() => {
|
const onPointerEnterCard = React.useCallback(() => {
|
||||||
onReceiveHover()
|
dispatch('hovered')
|
||||||
}, [onReceiveHover])
|
}, [])
|
||||||
|
|
||||||
const onPointerLeaveCard = React.useCallback(() => {
|
const onPointerLeaveCard = React.useCallback(() => {
|
||||||
onLoseHover()
|
dispatch('unhovered')
|
||||||
}, [onLoseHover])
|
}, [])
|
||||||
|
|
||||||
const onDismiss = React.useCallback(() => {
|
const onPress = React.useCallback(() => {
|
||||||
transitionToState('hidden')
|
dispatch('pressed')
|
||||||
}, [transitionToState])
|
}, [])
|
||||||
|
|
||||||
|
const isVisible =
|
||||||
|
currentState.stage === 'showing' ||
|
||||||
|
currentState.stage === 'might-hide' ||
|
||||||
|
currentState.stage === 'hiding'
|
||||||
|
|
||||||
|
const animationStyle = {
|
||||||
|
animation:
|
||||||
|
currentState.stage === 'hiding'
|
||||||
|
? `avatarHoverFadeOut ${HIDE_DURATION}ms both`
|
||||||
|
: `avatarHoverFadeIn ${SHOW_DURATION}ms both`,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={refs.setReference}
|
ref={refs.setReference}
|
||||||
onPointerEnter={onPointerEnterTarget}
|
onPointerEnter={onPointerEnterTarget}
|
||||||
onPointerLeave={onPointerLeaveTarget}
|
onPointerLeave={onPointerLeaveTarget}
|
||||||
onMouseUp={onDismiss}
|
onMouseUp={onPress}
|
||||||
style={{
|
style={{
|
||||||
display: props.inline ? 'inline' : 'block',
|
display: props.inline ? 'inline' : 'block',
|
||||||
}}>
|
}}>
|
||||||
|
@ -180,7 +233,7 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
||||||
style={floatingStyles}
|
style={floatingStyles}
|
||||||
onPointerEnter={onPointerEnterCard}
|
onPointerEnter={onPointerEnterCard}
|
||||||
onPointerLeave={onPointerLeaveCard}>
|
onPointerLeave={onPointerLeaveCard}>
|
||||||
<Card did={props.did} hide={onDismiss} />
|
<Card did={props.did} hide={onPress} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|
Loading…
Reference in New Issue