mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 06:16:59 +08:00
297 lines
8.5 KiB
TypeScript
297 lines
8.5 KiB
TypeScript
import { CloseIcon } from '@blocksuite/icons/rc';
|
|
import type {
|
|
DialogContentProps,
|
|
DialogOverlayProps,
|
|
DialogPortalProps,
|
|
DialogProps,
|
|
} from '@radix-ui/react-dialog';
|
|
import * as Dialog from '@radix-ui/react-dialog';
|
|
import * as VisuallyHidden from '@radix-ui/react-visually-hidden';
|
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
|
import clsx from 'clsx';
|
|
import type { CSSProperties, MouseEvent } from 'react';
|
|
import { forwardRef, useCallback, useEffect, useState } from 'react';
|
|
|
|
import { isMobile } from '../../utils/env';
|
|
import type { IconButtonProps } from '../button';
|
|
import { IconButton } from '../button';
|
|
import * as styles from './styles.css';
|
|
|
|
export interface ModalProps extends DialogProps {
|
|
width?: CSSProperties['width'];
|
|
height?: CSSProperties['height'];
|
|
minHeight?: CSSProperties['minHeight'];
|
|
title?: React.ReactNode;
|
|
description?: React.ReactNode;
|
|
withoutCloseButton?: boolean;
|
|
/**
|
|
* __Click outside__ or __Press `Esc`__ won't close the modal
|
|
* @default false
|
|
*/
|
|
persistent?: boolean;
|
|
portalOptions?: DialogPortalProps;
|
|
contentOptions?: DialogContentProps;
|
|
overlayOptions?: DialogOverlayProps;
|
|
closeButtonOptions?: IconButtonProps;
|
|
contentWrapperClassName?: string;
|
|
contentWrapperStyle?: CSSProperties;
|
|
/**
|
|
* @default 'fadeScaleTop'
|
|
*/
|
|
animation?: 'fadeScaleTop' | 'none' | 'slideBottom';
|
|
}
|
|
type PointerDownOutsideEvent = Parameters<
|
|
Exclude<DialogContentProps['onPointerDownOutside'], undefined>
|
|
>[0];
|
|
|
|
const getVar = (style: number | string = '', defaultValue = '') => {
|
|
return style
|
|
? typeof style === 'number'
|
|
? `${style}px`
|
|
: style
|
|
: defaultValue;
|
|
};
|
|
|
|
/**
|
|
* This component is a hack to support `startViewTransition` in the modal.
|
|
*/
|
|
class ModalTransitionContainer extends HTMLElement {
|
|
pendingTransitionNodes: Node[] = [];
|
|
animationFrame: number | null = null;
|
|
|
|
/**
|
|
* This method will be called when the modal is removed from the DOM
|
|
* https://github.com/facebook/react/blob/e4b4aac2a01b53f8151ca85148873096368a7de2/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L833
|
|
*/
|
|
override removeChild<T extends Node>(child: T): T {
|
|
if (typeof document.startViewTransition === 'function') {
|
|
this.pendingTransitionNodes.push(child);
|
|
this.requestTransition();
|
|
return child;
|
|
} else {
|
|
// eslint-disable-next-line unicorn/prefer-dom-node-remove
|
|
return super.removeChild(child);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* We collect all the nodes that are removed in the single frame and then trigger the transition.
|
|
*/
|
|
private requestTransition() {
|
|
if (this.animationFrame) {
|
|
cancelAnimationFrame(this.animationFrame);
|
|
}
|
|
|
|
this.animationFrame = requestAnimationFrame(() => {
|
|
if (typeof document.startViewTransition === 'function') {
|
|
const nodes = this.pendingTransitionNodes;
|
|
document.startViewTransition(() => {
|
|
nodes.forEach(child => {
|
|
// eslint-disable-next-line unicorn/prefer-dom-node-remove
|
|
super.removeChild(child);
|
|
});
|
|
});
|
|
this.pendingTransitionNodes = [];
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
let defined = false;
|
|
function createContainer() {
|
|
if (!defined) {
|
|
customElements.define(
|
|
'modal-transition-container',
|
|
ModalTransitionContainer
|
|
);
|
|
defined = true;
|
|
}
|
|
const container = new ModalTransitionContainer();
|
|
document.body.append(container);
|
|
return container;
|
|
}
|
|
|
|
export const ModalInner = forwardRef<HTMLDivElement, ModalProps>(
|
|
(props, ref) => {
|
|
const {
|
|
modal,
|
|
portalOptions,
|
|
open,
|
|
onOpenChange,
|
|
width,
|
|
height,
|
|
minHeight = 194,
|
|
title,
|
|
description,
|
|
withoutCloseButton = false,
|
|
persistent,
|
|
contentOptions: {
|
|
style: contentStyle,
|
|
className: contentClassName,
|
|
onPointerDownOutside,
|
|
onEscapeKeyDown,
|
|
...otherContentOptions
|
|
} = {},
|
|
overlayOptions: {
|
|
className: overlayClassName,
|
|
style: overlayStyle,
|
|
onClick: onOverlayClick,
|
|
...otherOverlayOptions
|
|
} = {},
|
|
closeButtonOptions,
|
|
children,
|
|
contentWrapperClassName,
|
|
contentWrapperStyle,
|
|
animation = 'fadeScaleTop',
|
|
...otherProps
|
|
} = props;
|
|
const { className: closeButtonClassName, ...otherCloseButtonProps } =
|
|
closeButtonOptions || {};
|
|
|
|
const [container, setContainer] = useState<ModalTransitionContainer | null>(
|
|
null
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
const container = createContainer();
|
|
setContainer(container);
|
|
return () => {
|
|
setTimeout(() => {
|
|
container.remove();
|
|
}, 1000) as unknown as number;
|
|
};
|
|
} else {
|
|
setContainer(null);
|
|
return;
|
|
}
|
|
}, [open]);
|
|
|
|
const handlePointerDownOutSide = useCallback(
|
|
(e: PointerDownOutsideEvent) => {
|
|
onPointerDownOutside?.(e);
|
|
persistent && e.preventDefault();
|
|
},
|
|
[onPointerDownOutside, persistent]
|
|
);
|
|
|
|
const handleEscapeKeyDown = useCallback(
|
|
(e: KeyboardEvent) => {
|
|
onEscapeKeyDown?.(e);
|
|
persistent && e.preventDefault();
|
|
},
|
|
[onEscapeKeyDown, persistent]
|
|
);
|
|
|
|
const handleOverlayClick = useCallback(
|
|
(e: MouseEvent<HTMLDivElement>) => {
|
|
onOverlayClick?.(e);
|
|
if (persistent) {
|
|
e.preventDefault();
|
|
} else {
|
|
onOpenChange?.(false);
|
|
}
|
|
},
|
|
[onOpenChange, onOverlayClick, persistent]
|
|
);
|
|
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
return (
|
|
<Dialog.Root
|
|
modal={modal}
|
|
open={open}
|
|
onOpenChange={onOpenChange}
|
|
{...otherProps}
|
|
>
|
|
<Dialog.Portal container={container} {...portalOptions}>
|
|
<Dialog.Overlay
|
|
className={clsx(
|
|
`anim-${animation}`,
|
|
styles.modalOverlay,
|
|
overlayClassName,
|
|
{ mobile: isMobile() }
|
|
)}
|
|
style={{
|
|
...overlayStyle,
|
|
}}
|
|
onClick={handleOverlayClick}
|
|
{...otherOverlayOptions}
|
|
/>
|
|
<div
|
|
data-modal={modal}
|
|
className={clsx(
|
|
`anim-${animation}`,
|
|
styles.modalContentWrapper,
|
|
contentWrapperClassName
|
|
)}
|
|
style={contentWrapperStyle}
|
|
>
|
|
<Dialog.Content
|
|
onPointerDownOutside={handlePointerDownOutSide}
|
|
onEscapeKeyDown={handleEscapeKeyDown}
|
|
className={clsx(styles.modalContent, contentClassName)}
|
|
style={{
|
|
...assignInlineVars({
|
|
[styles.widthVar]: getVar(width, '50vw'),
|
|
[styles.heightVar]: getVar(height, 'unset'),
|
|
[styles.minHeightVar]: getVar(minHeight, '26px'),
|
|
}),
|
|
...contentStyle,
|
|
}}
|
|
{...(description ? {} : { 'aria-describedby': undefined })}
|
|
{...otherContentOptions}
|
|
ref={ref}
|
|
>
|
|
{withoutCloseButton ? null : (
|
|
<Dialog.Close asChild>
|
|
<IconButton
|
|
size="20"
|
|
className={clsx(styles.closeButton, closeButtonClassName)}
|
|
aria-label="Close"
|
|
data-testid="modal-close-button"
|
|
{...otherCloseButtonProps}
|
|
>
|
|
<CloseIcon />
|
|
</IconButton>
|
|
</Dialog.Close>
|
|
)}
|
|
{title ? (
|
|
<Dialog.Title className={styles.modalHeader}>
|
|
{title}
|
|
</Dialog.Title>
|
|
) : (
|
|
// Refer: https://www.radix-ui.com/primitives/docs/components/dialog#title
|
|
// If you want to hide the title, wrap it inside our Visually Hidden utility like this <VisuallyHidden asChild>.
|
|
<VisuallyHidden.Root asChild>
|
|
<Dialog.Title></Dialog.Title>
|
|
</VisuallyHidden.Root>
|
|
)}
|
|
{description ? (
|
|
<Dialog.Description className={styles.modalDescription}>
|
|
{description}
|
|
</Dialog.Description>
|
|
) : null}
|
|
|
|
{children}
|
|
</Dialog.Content>
|
|
</div>
|
|
</Dialog.Portal>
|
|
</Dialog.Root>
|
|
);
|
|
}
|
|
);
|
|
|
|
ModalInner.displayName = 'ModalInner';
|
|
|
|
export const Modal = forwardRef<HTMLDivElement, ModalProps>((props, ref) => {
|
|
if (!props.open) {
|
|
return;
|
|
}
|
|
return <ModalInner {...props} ref={ref} />;
|
|
});
|
|
|
|
Modal.displayName = 'Modal';
|