feat(component): add animations to modal (#7474)

Add opening and closing animations to modal.

The usage of conditional rendering as shown below is not recommended:
```
open ? (
      <Modal
        open={open}
        ...
      />
    ) : null,
```

When the modal is closed, it gets removed from the DOM instantly without running any exit animations that might be defined in the Modal component.
This commit is contained in:
JimmFly
2024-07-22 03:22:42 +00:00
parent e3c3d1ac69
commit 55db9f9719
8 changed files with 141 additions and 97 deletions

View File

@@ -10,7 +10,8 @@ import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx'; import clsx from 'clsx';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { forwardRef, useCallback } from 'react'; import { forwardRef, useCallback, useEffect } from 'react';
import { type TransitionState, useTransition } from 'react-transition-state';
import type { IconButtonProps } from '../button'; import type { IconButtonProps } from '../button';
import { IconButton } from '../button'; import { IconButton } from '../button';
@@ -28,7 +29,8 @@ export interface ModalProps extends DialogProps {
* @default false * @default false
*/ */
persistent?: boolean; persistent?: boolean;
// animation for modal open/close
animationTimeout?: number;
portalOptions?: DialogPortalProps; portalOptions?: DialogPortalProps;
contentOptions?: DialogContentProps; contentOptions?: DialogContentProps;
overlayOptions?: DialogOverlayProps; overlayOptions?: DialogOverlayProps;
@@ -57,8 +59,10 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
withoutCloseButton = false, withoutCloseButton = false,
modal, modal,
persistent, persistent,
animationTimeout = 120,
portalOptions, portalOptions,
open: customOpen,
onOpenChange: customOnOpenChange,
contentOptions: { contentOptions: {
style: contentStyle, style: contentStyle,
className: contentClassName, className: contentClassName,
@@ -68,6 +72,7 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
} = {}, } = {},
overlayOptions: { overlayOptions: {
className: overlayClassName, className: overlayClassName,
style: overlayStyle,
...otherOverlayOptions ...otherOverlayOptions
} = {}, } = {},
closeButtonOptions = {}, closeButtonOptions = {},
@@ -76,11 +81,38 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
}, },
ref ref
) => { ) => {
const [{ status }, toggle] = useTransition({
timeout: animationTimeout,
onStateChange: useCallback(
({ current }: { current: TransitionState }) => {
// add more status if needed
if (current.status === 'exited') customOnOpenChange?.(false);
if (current.status === 'entered') customOnOpenChange?.(true);
},
[customOnOpenChange]
),
});
useEffect(() => {
toggle(customOpen);
}, [customOpen]);
return ( return (
<Dialog.Root modal={modal} {...props}> <Dialog.Root
modal={modal}
open={status !== 'exited'}
onOpenChange={toggle}
{...props}
>
<Dialog.Portal {...portalOptions}> <Dialog.Portal {...portalOptions}>
<Dialog.Overlay <Dialog.Overlay
className={clsx(styles.modalOverlay, overlayClassName)} className={clsx(styles.modalOverlay, overlayClassName)}
data-state={status}
style={{
...assignInlineVars({
[styles.animationTimeout]: `${animationTimeout}ms`,
}),
...overlayStyle,
}}
{...otherOverlayOptions} {...otherOverlayOptions}
/> />
<div data-modal={modal} className={clsx(styles.modalContentWrapper)}> <div data-modal={modal} className={clsx(styles.modalContentWrapper)}>
@@ -100,14 +132,17 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
[onEscapeKeyDown, persistent] [onEscapeKeyDown, persistent]
)} )}
className={clsx(styles.modalContent, contentClassName)} className={clsx(styles.modalContent, contentClassName)}
data-state={status}
style={{ style={{
...assignInlineVars({ ...assignInlineVars({
[styles.widthVar]: getVar(width, '50vw'), [styles.widthVar]: getVar(width, '50vw'),
[styles.heightVar]: getVar(height, 'unset'), [styles.heightVar]: getVar(height, 'unset'),
[styles.minHeightVar]: getVar(minHeight, '26px'), [styles.minHeightVar]: getVar(minHeight, '26px'),
[styles.animationTimeout]: `${animationTimeout}ms`,
}), }),
...contentStyle, ...contentStyle,
}} }}
{...(description ? {} : { 'aria-describedby': undefined })}
{...otherContentOptions} {...otherContentOptions}
ref={ref} ref={ref}
> >

View File

@@ -1,13 +1,61 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { createVar, globalStyle, style } from '@vanilla-extract/css'; import { createVar, globalStyle, keyframes, style } from '@vanilla-extract/css';
export const widthVar = createVar('widthVar'); export const widthVar = createVar('widthVar');
export const heightVar = createVar('heightVar'); export const heightVar = createVar('heightVar');
export const minHeightVar = createVar('minHeightVar'); export const minHeightVar = createVar('minHeightVar');
export const animationTimeout = createVar();
const overlayShow = keyframes({
from: {
opacity: 0,
},
to: {
opacity: 1,
},
});
const overlayHide = keyframes({
to: {
opacity: 0,
},
from: {
opacity: 1,
},
});
const contentShow = keyframes({
from: {
opacity: 0,
transform: 'translateY(-2%) scale(0.96)',
},
to: {
opacity: 1,
transform: 'translateY(0) scale(1)',
},
});
const contentHide = keyframes({
to: {
opacity: 0,
transform: 'translateY(-2%) scale(0.96)',
},
from: {
opacity: 1,
transform: 'translateY(0) scale(1)',
},
});
export const modalOverlay = style({ export const modalOverlay = style({
position: 'fixed', position: 'fixed',
inset: 0, inset: 0,
backgroundColor: cssVar('backgroundModalColor'), backgroundColor: cssVar('backgroundModalColor'),
zIndex: cssVar('zIndexModal'), zIndex: cssVar('zIndexModal'),
selectors: {
'&[data-state=entered], &[data-state=entering]': {
animation: `${overlayShow} ${animationTimeout} forwards`,
},
'&[data-state=exited], &[data-state=exiting]': {
animation: `${overlayHide} ${animationTimeout} forwards`,
},
},
}); });
export const modalContentWrapper = style({ export const modalContentWrapper = style({
position: 'fixed', position: 'fixed',
@@ -39,6 +87,16 @@ export const modalContent = style({
maxHeight: 'calc(100vh - 32px)', maxHeight: 'calc(100vh - 32px)',
// :focus-visible will set outline // :focus-visible will set outline
outline: 'none', outline: 'none',
selectors: {
'&[data-state=entered], &[data-state=entering]': {
animation: `${contentShow} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`,
animationFillMode: 'forwards',
},
'&[data-state=exited], &[data-state=exiting]': {
animation: `${contentHide} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`,
animationFillMode: 'forwards',
},
},
}); });
export const closeButton = style({ export const closeButton = style({
position: 'absolute', position: 'absolute',

View File

@@ -1,10 +1,12 @@
import { notify } from '@affine/component'; import { notify } from '@affine/component';
import { AuthInput, ModalHeader } from '@affine/component/auth-components'; import { AuthInput, ModalHeader } from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button'; import { Button } from '@affine/component/ui/button';
import { authAtom } from '@affine/core/atoms';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { Trans, useI18n } from '@affine/i18n'; import { Trans, useI18n } from '@affine/i18n';
import { ArrowDownBigIcon } from '@blocksuite/icons/rc'; import { ArrowDownBigIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useAtomValue } from 'jotai';
import type { FC } from 'react'; import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
@@ -34,6 +36,7 @@ export const SignIn: FC<AuthPanelProps> = ({
const [verifyToken, challenge] = useCaptcha(); const [verifyToken, challenge] = useCaptcha();
const [isValidEmail, setIsValidEmail] = useState(true); const [isValidEmail, setIsValidEmail] = useState(true);
const { openModal } = useAtomValue(authAtom);
useEffect(() => { useEffect(() => {
const timeout = setInterval(() => { const timeout = setInterval(() => {
@@ -45,7 +48,7 @@ export const SignIn: FC<AuthPanelProps> = ({
}; };
}, [authService]); }, [authService]);
const loginStatus = useLiveData(authService.session.status$); const loginStatus = useLiveData(authService.session.status$);
if (loginStatus === 'authenticated') { if (loginStatus === 'authenticated' && openModal) {
onSignedIn?.(); onSignedIn?.();
} }

View File

@@ -1,46 +1,13 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { createVar, keyframes, style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
export const animationTimeout = createVar(); export const container = style({
maxWidth: 480,
const contentShow = keyframes({ minWidth: 360,
from: { padding: '20px 0',
opacity: 0, alignSelf: 'start',
}, marginTop: '120px',
to: {
opacity: 1,
},
}); });
const contentHide = keyframes({
to: {
opacity: 0,
},
from: {
opacity: 1,
},
});
export const overlay = style({
selectors: {
'&.entered, &.entering': {
animation: `${contentShow} ${animationTimeout} forwards`,
},
'&.exited, &.exiting': {
animation: `${contentHide} ${animationTimeout} forwards`,
},
},
});
export const container = style([
overlay,
{
maxWidth: 480,
minWidth: 360,
padding: '20px 0',
alignSelf: 'start',
marginTop: '120px',
},
]);
export const titleContainer = style({ export const titleContainer = style({
display: 'flex', display: 'flex',

View File

@@ -12,17 +12,7 @@ import {
useService, useService,
type Workspace, type Workspace,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { Suspense, useCallback, useContext, useMemo, useRef } from 'react';
import clsx from 'clsx';
import {
Suspense,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react';
import { useTransition } from 'react-transition-state';
import { BlocksuiteHeaderTitle } from '../../../blocksuite/block-suite-header/title'; import { BlocksuiteHeaderTitle } from '../../../blocksuite/block-suite-header/title';
import { managerContext } from '../common'; import { managerContext } from '../common';
@@ -37,8 +27,6 @@ import * as styles from './info-modal.css';
import { TagsRow } from './tags-row'; import { TagsRow } from './tags-row';
import { TimeRow } from './time-row'; import { TimeRow } from './time-row';
const animationTimeout = 120;
export const InfoModal = ({ export const InfoModal = ({
open, open,
onOpenChange, onOpenChange,
@@ -52,14 +40,6 @@ export const InfoModal = ({
}) => { }) => {
const titleInputHandleRef = useRef<InlineEditHandle>(null); const titleInputHandleRef = useRef<InlineEditHandle>(null);
const [{ status }, toggle] = useTransition({
timeout: animationTimeout,
});
useEffect(() => {
toggle(open);
}, [open, toggle]);
const manager = usePagePropertiesManager(page); const manager = usePagePropertiesManager(page);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -80,20 +60,10 @@ export const InfoModal = ({
return ( return (
<Modal <Modal
overlayOptions={{
className: clsx(styles.overlay, status),
style: assignInlineVars({
[styles.animationTimeout]: `${animationTimeout}ms`,
}),
}}
contentOptions={{ contentOptions={{
className: clsx(styles.container, status), className: styles.container,
'aria-describedby': undefined,
style: assignInlineVars({
[styles.animationTimeout]: `${animationTimeout}ms`,
}),
}} }}
open={status !== 'exited'} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
withoutCloseButton withoutCloseButton
> >

View File

@@ -23,9 +23,6 @@ export interface EditCollectionModalProps {
} }
const contentOptions: DialogContentProps = { const contentOptions: DialogContentProps = {
onPointerDownOutside: e => {
e.preventDefault();
},
style: { style: {
padding: 0, padding: 0,
maxWidth: 944, maxWidth: 944,
@@ -60,6 +57,7 @@ export const EditCollectionModal = ({
width="calc(100% - 64px)" width="calc(100% - 64px)"
height="80%" height="80%"
contentOptions={contentOptions} contentOptions={contentOptions}
persistent
> >
{open && init ? ( {open && init ? (
<EditCollection <EditCollection

View File

@@ -12,9 +12,14 @@ export const useSelectPage = ({
init: string[]; init: string[];
onConfirm: (ids: string[]) => void; onConfirm: (ids: string[]) => void;
}>(); }>();
const close = useCallback(() => { const close = useCallback((open: boolean) => {
onChange(undefined); if (!open) {
onChange(undefined);
}
}, []); }, []);
const handleCancel = useCallback(() => {
close(false);
}, [close]);
return { return {
node: ( node: (
<Modal <Modal
@@ -38,7 +43,7 @@ export const useSelectPage = ({
allPageListConfig={allPageListConfig} allPageListConfig={allPageListConfig}
init={value.init} init={value.init}
onConfirm={value.onConfirm} onConfirm={value.onConfirm}
onCancel={close} onCancel={handleCancel}
/> />
) : null} ) : null}
</Modal> </Modal>
@@ -48,7 +53,7 @@ export const useSelectPage = ({
onChange({ onChange({
init, init,
onConfirm: list => { onConfirm: list => {
close(); close(false);
res(list); res(list);
}, },
}); });

View File

@@ -11,18 +11,22 @@ export const useEditCollection = () => {
mode?: 'page' | 'rule'; mode?: 'page' | 'rule';
onConfirm: (collection: Collection) => void; onConfirm: (collection: Collection) => void;
}>(); }>();
const close = useCallback(() => setData(undefined), []); const close = useCallback((open: boolean) => {
if (!open) {
setData(undefined);
}
}, []);
return { return {
node: data ? ( node: (
<EditCollectionModal <EditCollectionModal
init={data.collection} init={data?.collection}
open={!!data} open={!!data}
mode={data.mode} mode={data?.mode}
onOpenChange={close} onOpenChange={close}
onConfirm={data.onConfirm} onConfirm={data?.onConfirm ?? (() => {})}
/> />
) : null, ),
open: ( open: (
collection: Collection, collection: Collection,
mode?: EditCollectionMode mode?: EditCollectionMode
@@ -50,19 +54,23 @@ export const useEditCollectionName = ({
name: string; name: string;
onConfirm: (name: string) => void; onConfirm: (name: string) => void;
}>(); }>();
const close = useCallback(() => setData(undefined), []); const close = useCallback((open: boolean) => {
if (!open) {
setData(undefined);
}
}, []);
return { return {
node: data ? ( node: (
<CreateCollectionModal <CreateCollectionModal
showTips={showTips} showTips={showTips}
title={title} title={title}
init={data.name} init={data?.name ?? ''}
open={!!data} open={!!data}
onOpenChange={close} onOpenChange={close}
onConfirm={data.onConfirm} onConfirm={data?.onConfirm ?? (() => {})}
/> />
) : null, ),
open: (name: string): Promise<string> => open: (name: string): Promise<string> =>
new Promise<string>(res => { new Promise<string>(res => {
setData({ setData({