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:
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user