diff options
| author | Oliver <oliver@mohlin.dev> | 2025-04-10 12:04:18 +0200 |
|---|---|---|
| committer | Sebastian Holmin <sebastian.holmin@mullvad.net> | 2025-05-28 13:25:30 +0200 |
| commit | bc553002950e201f001ca5783fa9ed182b9537e9 (patch) | |
| tree | 810dd98f41ac673134d08b04566399d7dac8c45a | |
| parent | 956b800f9f8f0db3288f1439cd85a2002376a3dc (diff) | |
| download | mullvadvpn-bc553002950e201f001ca5783fa9ed182b9537e9.tar.xz mullvadvpn-bc553002950e201f001ca5783fa9ed182b9537e9.zip | |
Support initial prop and conditionally render Animate
Initial prop controls whether the animation is triggered on mount.
Also makes Animate conditionally render self dependant on present prop.
| -rw-r--r-- | desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/Animate.tsx | 94 | ||||
| -rw-r--r-- | desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/index.ts | 3 | ||||
| -rw-r--r-- | desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimations.ts (renamed from desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimations.tsx) | 2 | ||||
| -rw-r--r-- | desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useHandleAnimationEnd.ts | 19 | ||||
| -rw-r--r-- | desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useIsInitialRender.ts | 11 | ||||
| -rw-r--r-- | desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useShow.ts | 14 |
6 files changed, 122 insertions, 21 deletions
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/Animate.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/Animate.tsx index ffcc462385..00475d6cf3 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/Animate.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/Animate.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import styled, { css } from 'styled-components'; +import styled, { css, RuleSet } from 'styled-components'; import { TransientProps } from '../../types'; -import { useAnimations } from './hooks'; +import { useMounted } from '../../utility-hooks'; +import { AnimateProvider } from './AnimateContext'; +import { useAnimations, useHandleAnimationEnd, useShow } from './hooks'; export type Animation = FadeAnimation | WipeAnimation; @@ -15,7 +17,8 @@ export type WipeAnimation = { direction: 'vertical'; }; -export type AnimateProps = React.HTMLAttributes<HTMLDivElement> & { +type AnimateBaseProps = { + initial?: boolean; present?: boolean; duration?: React.CSSProperties['animationDuration']; timingFunction?: React.CSSProperties['animationTimingFunction']; @@ -26,7 +29,14 @@ export type AnimateProps = React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode; }; -const StyledDiv = styled.div<TransientProps<AnimateProps>>` +export type AnimateProps = AnimateBaseProps & + Omit<React.HTMLAttributes<HTMLDivElement>, keyof AnimateBaseProps>; + +const StyledDiv = styled.div< + TransientProps<Omit<AnimateBaseProps, 'animations'>> & { + $animations: RuleSet; + } +>` ${({ $animations, $duration = '0.25s', @@ -34,44 +44,88 @@ const StyledDiv = styled.div<TransientProps<AnimateProps>>` $direction = 'normal', $iterationCount = '1', }) => { - const animations = useAnimations($animations); return css` - &&[data-present-animation='true'] { + // If the user prefers reduced motion, visibility still needs + // to be toggled, otherwise this is handled by animations + &&[data-is-present-animation='true'] { display: none; - &&[data-show='true'] { + &&[data-present='true'] { display: block; } } @media (prefers-reduced-motion: no-preference) { - --duration: ${$duration}; - --timing-function: ${$timingFunction}; - --direction: ${$direction}; - --iteration-count: ${$iterationCount}; + &&[data-animate='true'] { + --duration: ${$duration}; + --timing-function: ${$timingFunction}; + --direction: ${$direction}; + --iteration-count: ${$iterationCount}; - interpolate-size: allow-keywords; - transition-behavior: allow-discrete; + interpolate-size: allow-keywords; + transition-behavior: allow-discrete; - overflow: clip; - ${animations} + overflow: clip; + ${$animations} + } } `; }} `; -export function Animate({ - present, - animations, +/** + * Animate that applies animation to a wrapper around it's children. + * + * @param initial - Whether animation should trigger on mount. + * @param present - Whether element is present, i.e rendered or not. + * @param animations - List of animations to apply. + */ +export function Animate({ initial, present, children, ...props }: AnimateProps) { + return ( + <AnimateProvider initial={initial} present={present}> + <AnimateImpl {...props} present={present} initial={initial}> + {children} + </AnimateImpl> + </AnimateProvider> + ); +} + +function AnimateImpl({ + initial = true, + present: presentProp, + animations: animationsProp, duration, timingFunction, direction, iterationCount, + onAnimationEnd, ...props }: AnimateProps) { + const animations = useAnimations(animationsProp); + const show = useShow(); + const [initialPresent] = React.useState(presentProp); + const [presentChanged, setPresentChanged] = React.useState(false); + const [present, setPresent] = React.useState(presentProp); + + React.useEffect(() => { + if (presentProp !== initialPresent && !presentChanged) { + setPresentChanged(true); + } + }, [initialPresent, presentProp, presentChanged]); + + React.useEffect(() => { + setPresent(presentProp); + }, [presentProp]); + + const handleAnimationEnd = useHandleAnimationEnd(onAnimationEnd); + const mounted = useMounted(); + if (!show) return null; + return ( <StyledDiv - data-show={present} - data-present-animation={present === undefined ? false : true} + data-animate={initial || (mounted() && presentChanged)} + data-present={present} + data-is-present-animation={presentProp !== undefined} + onAnimationEnd={handleAnimationEnd} $animations={animations} $duration={duration} $timingFunction={timingFunction} diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/index.ts index 8811e6a063..5ba626bda4 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/index.ts +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/index.ts @@ -1 +1,4 @@ export * from './useAnimations'; +export * from './useIsInitialRender'; +export * from './useShow'; +export * from './useHandleAnimationEnd'; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimations.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimations.ts index 1b44da8a30..c792d016a1 100644 --- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimations.tsx +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimations.ts @@ -22,7 +22,7 @@ export const useAnimations = (values: Animation[]) => { ${inAnimations.map((animation) => animation.rule)} ${outAnimations.map((animation) => animation.rule)} ${createAnimationDeclaration(outAnimations)} - &&[data-show='true'] { + &&[data-present='true'] { ${createAnimationDeclaration(inAnimations)} } `; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useHandleAnimationEnd.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useHandleAnimationEnd.ts new file mode 100644 index 0000000000..2b4d83441c --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useHandleAnimationEnd.ts @@ -0,0 +1,19 @@ +import React from 'react'; + +import { AnimateProps } from '../Animate'; +import { useAnimateContext } from '../AnimateContext'; + +export const useHandleAnimationEnd = (onAnimationEnd: AnimateProps['onAnimationEnd']) => { + const { present, show, setShow } = useAnimateContext(); + return React.useCallback( + (e: React.AnimationEvent<HTMLDivElement>) => { + if (!present && show) { + setShow(false); + } + if (onAnimationEnd) { + onAnimationEnd(e); + } + }, + [onAnimationEnd, present, setShow, show], + ); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useIsInitialRender.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useIsInitialRender.ts new file mode 100644 index 0000000000..546eba8152 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useIsInitialRender.ts @@ -0,0 +1,11 @@ +import React from 'react'; + +export const useIsInitialRender = () => { + const isInitialRender = React.useRef(true); + + React.useEffect(() => { + isInitialRender.current = false; + }, []); + + return React.useCallback(() => isInitialRender.current, [isInitialRender]); +}; diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useShow.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useShow.ts new file mode 100644 index 0000000000..2a452ffbb4 --- /dev/null +++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useShow.ts @@ -0,0 +1,14 @@ +import React from 'react'; + +import { useAnimateContext } from '../AnimateContext'; + +export const useShow = () => { + const { present, show, setShow } = useAnimateContext(); + + React.useEffect(() => { + if (present && !show) { + setShow(true); + } + }, [present, setShow, show]); + return show; +}; |
