mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): add a quick modal motion preset and settle instant modals (#1943)
This commit is contained in:
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}};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user