From 9219b886fe4446ef3769a0a347877b1862a60437 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:50:16 +0200 Subject: [PATCH] fix(app): add a quick modal motion preset and settle instant modals (#1943) --- .../features/app/components/dialogs/Modal.tsx | 77 ++++--------- .../app/components/dialogs/ModalMotion.ts | 104 ++++++++++++++++++ 2 files changed, 123 insertions(+), 58 deletions(-) create mode 100644 fluxer_app/src/features/app/components/dialogs/ModalMotion.ts diff --git a/fluxer_app/src/features/app/components/dialogs/Modal.tsx b/fluxer_app/src/features/app/components/dialogs/Modal.tsx index 4c14e4074..a4ddc010b 100644 --- a/fluxer_app/src/features/app/components/dialogs/Modal.tsx +++ b/fluxer_app/src/features/app/components/dialogs/Modal.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import styles from '@app/features/app/components/dialogs/Modal.module.css'; +import {resolveModalBackdropMotionSpec, resolveModalMotionSpec} from '@app/features/app/components/dialogs/ModalMotion'; import {useModalBackHandler} from '@app/features/app/hooks/useModalBackHandler'; import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands'; @@ -103,51 +104,16 @@ const RootComponent = React.forwardRef( }, [ref], ); - const mobileFullscreenAnimations = { - initial: {opacity: 0}, - animate: {opacity: 1}, - exit: {opacity: 0}, - }; - const profileSlideAnimations = prefersReducedMotion - ? { - initial: {opacity: 0}, - animate: {opacity: 1}, - exit: {opacity: 0}, - } - : { - initial: {opacity: 0, x: 14}, - animate: {opacity: 1, x: 0}, - exit: {opacity: 0, x: 14}, - }; - const defaultAnimations = prefersReducedMotion - ? { - initial: {opacity: 0}, - animate: {opacity: 1}, - exit: {opacity: 0}, - } - : { - initial: {opacity: 0, scale: 0.95}, - animate: {opacity: 1, scale: 1}, - exit: {opacity: 0, scale: 0.95}, - }; - const animations = isFullscreenOnMobile - ? mobileFullscreenAnimations - : transitionPreset === 'profile-slide' - ? profileSlideAnimations - : defaultAnimations; - const rootTransition: Transition = - prefersReducedMotion || transitionPreset === 'instant' - ? {duration: 0} - : isFullscreenOnMobile - ? {duration: 0.15} - : transitionPreset === 'profile-slide' - ? {duration: 0.14, ease: 'easeOut'} - : { - type: 'spring', - stiffness: 400, - damping: 30, - mass: 0.8, - }; + const motionSpec = resolveModalMotionSpec({ + transitionPreset, + prefersReducedMotion, + isFullscreenOnMobile, + }); + const backdropMotionSpec = resolveModalBackdropMotionSpec({ + transitionPreset, + prefersReducedMotion, + isMobile, + }); const handleBackdropClickEvent = useCallback( (event: React.MouseEvent) => { if (event.target === event.currentTarget) { @@ -226,23 +192,16 @@ const RootComponent = React.forwardRef( }; }, [isVisible]); const isCenteredOnMobile = isMobile && !useFullscreenLayer; - const shouldInstantBackdrop = isMobile && !prefersReducedMotion; return ( {needsBackdrop && isVisible && ( )} @@ -326,8 +285,10 @@ const RootComponent = React.forwardRef( className, )} data-flx="app.modal.root" - {...animations} - transition={rootTransition} + initial={motionSpec.initial} + animate={motionSpec.animate} + exit={motionSpec.exit} + transition={motionSpec.transition as Transition} onAnimationStart={handleAnimationStart} onAnimationComplete={handleAnimationComplete} ref={setMotionElementRef} diff --git a/fluxer_app/src/features/app/components/dialogs/ModalMotion.ts b/fluxer_app/src/features/app/components/dialogs/ModalMotion.ts new file mode 100644 index 000000000..0fa873f01 --- /dev/null +++ b/fluxer_app/src/features/app/components/dialogs/ModalMotion.ts @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {ModalTransitionPreset} from '@app/features/ui/utils/ModalUtils'; + +export interface ModalMotionInput { + transitionPreset: ModalTransitionPreset; + prefersReducedMotion: boolean; + isFullscreenOnMobile: boolean; +} + +export interface ModalBackdropMotionInput { + transitionPreset: ModalTransitionPreset; + prefersReducedMotion: boolean; + isMobile: boolean; +} + +const SETTLED_SURFACE = {opacity: 1, scale: 1} as const; + +const BACKDROP_RESTING_OPACITY = 0.85; + +const BACKDROP_DISMISS_SPRING = {type: 'spring', stiffness: 1000, damping: 48, mass: 1} as const; + +const QUICK_TRANSITION = {duration: 0.12, ease: 'easeOut'} as const; + +const QUICK_SURFACE_OFFSET = 0.98; + +export function resolveModalMotionSpec({ + transitionPreset, + prefersReducedMotion, + isFullscreenOnMobile, +}: ModalMotionInput) { + if (transitionPreset === 'instant') { + return { + initial: SETTLED_SURFACE, + animate: SETTLED_SURFACE, + exit: SETTLED_SURFACE, + transition: {duration: 0}, + }; + } + if (prefersReducedMotion) { + return { + initial: {opacity: 0}, + animate: {opacity: 1}, + exit: {opacity: 0}, + transition: {duration: 0}, + }; + } + if (isFullscreenOnMobile) { + return { + initial: {opacity: 0}, + animate: {opacity: 1}, + exit: {opacity: 0}, + transition: {duration: 0.15}, + }; + } + if (transitionPreset === 'quick') { + return { + initial: {opacity: 0, scale: QUICK_SURFACE_OFFSET}, + animate: {opacity: 1, scale: 1}, + exit: {opacity: 0, scale: QUICK_SURFACE_OFFSET}, + transition: QUICK_TRANSITION, + }; + } + if (transitionPreset === 'profile-slide') { + return { + initial: {opacity: 0, x: 14}, + animate: {opacity: 1, x: 0}, + exit: {opacity: 0, x: 14}, + transition: {duration: 0.14, ease: 'easeOut'}, + }; + } + return { + initial: {opacity: 0, scale: 0.95}, + animate: {opacity: 1, scale: 1}, + exit: {opacity: 0, scale: 0.95}, + transition: {type: 'spring', stiffness: 400, damping: 30, mass: 0.8}, + }; +} + +export function resolveModalBackdropMotionSpec({ + transitionPreset, + prefersReducedMotion, + isMobile, +}: ModalBackdropMotionInput) { + const mountsAtRest = (isMobile && !prefersReducedMotion) || transitionPreset === 'instant'; + const surface = { + initial: {opacity: mountsAtRest ? BACKDROP_RESTING_OPACITY : 0}, + animate: {opacity: BACKDROP_RESTING_OPACITY}, + exit: {opacity: 0}, + }; + if (prefersReducedMotion) { + return {...surface, transition: {duration: 0}}; + } + if (transitionPreset === 'instant') { + return {...surface, transition: BACKDROP_DISMISS_SPRING}; + } + if (transitionPreset === 'quick') { + return {...surface, transition: QUICK_TRANSITION}; + } + if (isMobile) { + return {...surface, transition: {duration: 0.15}}; + } + return {...surface, transition: {duration: 0.2}}; +}