fix(app): add a quick modal motion preset and settle instant modals (#1943)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:50:16 +02:00
1 parent 2377a4c9d0
commit 9219b886fe
2 files changed
+123 -58

No files matched your search

@@ -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<HTMLDivElement, ModalProps>(
},
[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<HTMLDivElement, ModalProps>(
};
}, [isVisible]);
const isCenteredOnMobile = isMobile && !useFullscreenLayer;
const shouldInstantBackdrop = isMobile && !prefersReducedMotion;
return (
<FloatingPortal root={portalHost ?? undefined} data-flx="app.modal.floating-portal">
{needsBackdrop && isVisible && (
<motion.div
className={styles.modalBackdrop}
style={{zIndex: backdropZIndex}}
initial={{opacity: shouldInstantBackdrop || isInstantTransition ? 0.85 : 0}}
animate={{opacity: 0.85}}
exit={{opacity: 0}}
transition={
prefersReducedMotion || isInstantTransition
? {duration: 0}
: shouldInstantBackdrop
? {duration: 0.15}
: {duration: 0.2}
}
initial={backdropMotionSpec.initial}
animate={backdropMotionSpec.animate}
exit={backdropMotionSpec.exit}
transition={backdropMotionSpec.transition as Transition}
data-flx="app.modal.modal-backdrop"
/>
)}
@@ -326,8 +285,10 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
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}
@@ -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}};
}