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 /desktop | |
| 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.
Diffstat (limited to 'desktop')
| -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; +}; |
