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 clsx from 'clsx';
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 { IconButton } from '../button';
@@ -28,7 +29,8 @@ export interface ModalProps extends DialogProps {
* @default false
*/
persistent?: boolean;
// animation for modal open/close
animationTimeout?: number;
portalOptions?: DialogPortalProps;
contentOptions?: DialogContentProps;
overlayOptions?: DialogOverlayProps;
@@ -57,8 +59,10 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
withoutCloseButton = false,
modal,
persistent,
animationTimeout = 120,
portalOptions,
open: customOpen,
onOpenChange: customOnOpenChange,
contentOptions: {
style: contentStyle,
className: contentClassName,
@@ -68,6 +72,7 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
} = {},
overlayOptions: {
className: overlayClassName,
style: overlayStyle,
...otherOverlayOptions
} = {},
closeButtonOptions = {},
@@ -76,11 +81,38 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
},
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 (
<Dialog.Root modal={modal} {...props}>
<Dialog.Root
modal={modal}
open={status !== 'exited'}
onOpenChange={toggle}
{...props}
>
<Dialog.Portal {...portalOptions}>
<Dialog.Overlay
className={clsx(styles.modalOverlay, overlayClassName)}
data-state={status}
style={{
...assignInlineVars({
[styles.animationTimeout]: `${animationTimeout}ms`,
}),
...overlayStyle,
}}
{...otherOverlayOptions}
/>
<div data-modal={modal} className={clsx(styles.modalContentWrapper)}>
@@ -100,14 +132,17 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
[onEscapeKeyDown, persistent]
)}
className={clsx(styles.modalContent, contentClassName)}
data-state={status}
style={{
...assignInlineVars({
[styles.widthVar]: getVar(width, '50vw'),
[styles.heightVar]: getVar(height, 'unset'),
[styles.minHeightVar]: getVar(minHeight, '26px'),
[styles.animationTimeout]: `${animationTimeout}ms`,
}),
...contentStyle,
}}
{...(description ? {} : { 'aria-describedby': undefined })}
{...otherContentOptions}
ref={ref}
>

View File

@@ -1,13 +1,61 @@
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 heightVar = createVar('heightVar');
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({
position: 'fixed',
inset: 0,
backgroundColor: cssVar('backgroundModalColor'),
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({
position: 'fixed',
@@ -39,6 +87,16 @@ export const modalContent = style({
maxHeight: 'calc(100vh - 32px)',
// :focus-visible will set outline
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({
position: 'absolute',