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 // SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/app/components/dialogs/Modal.module.css'; 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 {useModalBackHandler} from '@app/features/app/hooks/useModalBackHandler';
import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands'; import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
@@ -103,51 +104,16 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
}, },
[ref], [ref],
); );
const mobileFullscreenAnimations = { const motionSpec = resolveModalMotionSpec({
initial: {opacity: 0}, transitionPreset,
animate: {opacity: 1}, prefersReducedMotion,
exit: {opacity: 0}, isFullscreenOnMobile,
}; });
const profileSlideAnimations = prefersReducedMotion const backdropMotionSpec = resolveModalBackdropMotionSpec({
? { transitionPreset,
initial: {opacity: 0}, prefersReducedMotion,
animate: {opacity: 1}, isMobile,
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 handleBackdropClickEvent = useCallback( const handleBackdropClickEvent = useCallback(
(event: React.MouseEvent) => { (event: React.MouseEvent) => {
if (event.target === event.currentTarget) { if (event.target === event.currentTarget) {
@@ -226,23 +192,16 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
}; };
}, [isVisible]); }, [isVisible]);
const isCenteredOnMobile = isMobile && !useFullscreenLayer; const isCenteredOnMobile = isMobile && !useFullscreenLayer;
const shouldInstantBackdrop = isMobile && !prefersReducedMotion;
return ( return (
<FloatingPortal root={portalHost ?? undefined} data-flx="app.modal.floating-portal"> <FloatingPortal root={portalHost ?? undefined} data-flx="app.modal.floating-portal">
{needsBackdrop && isVisible && ( {needsBackdrop && isVisible && (
<motion.div <motion.div
className={styles.modalBackdrop} className={styles.modalBackdrop}
style={{zIndex: backdropZIndex}} style={{zIndex: backdropZIndex}}
initial={{opacity: shouldInstantBackdrop || isInstantTransition ? 0.85 : 0}} initial={backdropMotionSpec.initial}
animate={{opacity: 0.85}} animate={backdropMotionSpec.animate}
exit={{opacity: 0}} exit={backdropMotionSpec.exit}
transition={ transition={backdropMotionSpec.transition as Transition}
prefersReducedMotion || isInstantTransition
? {duration: 0}
: shouldInstantBackdrop
? {duration: 0.15}
: {duration: 0.2}
}
data-flx="app.modal.modal-backdrop" data-flx="app.modal.modal-backdrop"
/> />
)} )}
@@ -326,8 +285,10 @@ const RootComponent = React.forwardRef<HTMLDivElement, ModalProps>(
className, className,
)} )}
data-flx="app.modal.root" data-flx="app.modal.root"
{...animations} initial={motionSpec.initial}
transition={rootTransition} animate={motionSpec.animate}
exit={motionSpec.exit}
transition={motionSpec.transition as Transition}
onAnimationStart={handleAnimationStart} onAnimationStart={handleAnimationStart}
onAnimationComplete={handleAnimationComplete} onAnimationComplete={handleAnimationComplete}
ref={setMotionElementRef} 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}};
}