230 lines
6.5 KiB
TypeScript
230 lines
6.5 KiB
TypeScript
'use client';
|
|
import { use, useSyncExternalStore, useTransition } from 'react';
|
|
import type { DialogProps, ModalOverlayProps } from 'react-aria-components';
|
|
import {
|
|
Button as AriaButton,
|
|
Dialog,
|
|
Heading,
|
|
Modal,
|
|
ModalOverlay,
|
|
OverlayTriggerStateContext,
|
|
} from 'react-aria-components';
|
|
|
|
import clsx from 'clsx';
|
|
import { twMerge } from 'tailwind-merge';
|
|
import { Button } from '../button/Button';
|
|
|
|
interface ModalRootProps
|
|
extends DialogProps,
|
|
Pick<ModalOverlayProps, 'isOpen' | 'onOpenChange' | 'isKeyboardDismissDisabled' | 'isDismissable'> {
|
|
className?: string;
|
|
}
|
|
const ModalRoot = (props: ModalRootProps) => {
|
|
const { isOpen, onOpenChange, isKeyboardDismissDisabled, isDismissable, className, ...dialogProps } = props;
|
|
return (
|
|
<ModalOverlay
|
|
isOpen={isOpen}
|
|
onOpenChange={onOpenChange}
|
|
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
|
|
isDismissable={isDismissable}
|
|
className="absolute top-0 left-0 w-full h-full bg-black/70 z-100"
|
|
>
|
|
<Modal className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 supports-[translate:round(up,-50%,1px)]:translate-[round(up,-50%,1px)] min-w-75 bg-white outline-none drop-shadow-modal">
|
|
<Dialog className={twMerge('outline-0', className)} {...dialogProps}>
|
|
{props.children}
|
|
</Dialog>
|
|
</Modal>
|
|
</ModalOverlay>
|
|
);
|
|
};
|
|
|
|
interface ModalHeaderProps {
|
|
hasCloseButton?: boolean;
|
|
children: React.ReactNode;
|
|
}
|
|
const ModalHeader = (props: ModalHeaderProps) => {
|
|
const ctx = use(OverlayTriggerStateContext);
|
|
|
|
const { hasCloseButton = true } = props;
|
|
return (
|
|
<div className="px-5 py-4 flex items-center justify-between gap-1 border-b border-dabeeo-gray-eb">
|
|
<Heading slot="title" className="text-lg text-primary font-bold">
|
|
{props.children}
|
|
</Heading>
|
|
{hasCloseButton && (
|
|
<AriaButton
|
|
onClick={ctx?.close}
|
|
className="text-dabeeo-black-47 cursor-pointer data-focus-visible:ring-2 ring-offset-2 ring-dabeeo-gray-34"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
|
<path
|
|
d="M18.25 2.4927L17.5074 1.75L10.0021 9.25678L2.49675 1.75L1.7542 2.4927L9.25955 9.99947L1.75 17.5073L2.49255 18.25L9.9979 10.7432L17.5032 18.25L18.2458 17.5073L10.7404 10.0005L18.25 2.4927Z"
|
|
fill="currentColor"
|
|
stroke="currentColor"
|
|
strokeWidth="0.5"
|
|
/>
|
|
</svg>
|
|
</AriaButton>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface ModalBodyProps extends React.PropsWithChildren {
|
|
className?: string;
|
|
}
|
|
const ModalBody = (props: ModalBodyProps) => {
|
|
return <div className={clsx('px-6 py-2.5', props.className)}>{props.children}</div>;
|
|
};
|
|
|
|
interface ModalFooterProps extends React.PropsWithChildren {}
|
|
const ModalFooter = (props: ModalFooterProps) => {
|
|
return <div className="px-4 py-2.5 flex justify-center gap-2 border-t border-dabeeo-gray-eb">{props.children}</div>;
|
|
};
|
|
|
|
let _modals: (
|
|
| {
|
|
typeof: 'confirm';
|
|
title?: string;
|
|
content: string;
|
|
confirm?: boolean;
|
|
cancelText?: string;
|
|
onCancel?: () => void;
|
|
confirmText?: string;
|
|
onConfirm: (close: () => void) => void;
|
|
}
|
|
| {
|
|
typeof: 'alert';
|
|
title?: string;
|
|
content: string;
|
|
cancelText?: string;
|
|
onCancel?: () => void;
|
|
}
|
|
)[] = [];
|
|
let listeners: (() => void)[] = [];
|
|
function subscribe(listener: () => void) {
|
|
listeners = [...listeners, listener];
|
|
return () => {
|
|
listeners = listeners.filter((l) => l !== listener);
|
|
};
|
|
}
|
|
function getSnapshot() {
|
|
return _modals;
|
|
}
|
|
|
|
function emitChange() {
|
|
for (const listener of listeners) {
|
|
listener();
|
|
}
|
|
}
|
|
|
|
type AlertProps = {
|
|
title?: string;
|
|
content?: string;
|
|
cancelText?: string;
|
|
onCancel?: () => void;
|
|
};
|
|
const alert = (props: AlertProps) => {
|
|
_modals = [
|
|
..._modals,
|
|
{
|
|
typeof: 'alert',
|
|
content: props.content || '',
|
|
cancelText: props.cancelText || '확인',
|
|
onCancel: props.onCancel,
|
|
},
|
|
];
|
|
emitChange();
|
|
};
|
|
|
|
const confirm = (props: {
|
|
title?: string;
|
|
content: string;
|
|
cancelText?: string;
|
|
onCancel?: () => void;
|
|
confirmText?: string;
|
|
onConfirm: (close: () => void) => void;
|
|
}) => {
|
|
_modals = [
|
|
..._modals,
|
|
{
|
|
typeof: 'confirm',
|
|
title: props.title,
|
|
content: props.content,
|
|
cancelText: props.cancelText,
|
|
onCancel: props.onCancel,
|
|
confirmText: props.confirmText,
|
|
onConfirm: props.onConfirm,
|
|
},
|
|
];
|
|
emitChange();
|
|
};
|
|
|
|
function getServerSnapshot() {
|
|
return _modals;
|
|
}
|
|
|
|
const ModalRegion = () => {
|
|
const modals = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
|
|
const [isPending, startTransition] = useTransition();
|
|
return (
|
|
<>
|
|
{modals.map((modal, idx) => (
|
|
<ModalRoot
|
|
key={`modal-${idx}`}
|
|
aria-label={`모달 ${idx}`}
|
|
className="w-75"
|
|
isOpen
|
|
onOpenChange={(isOpen) => {
|
|
if (!isOpen) {
|
|
_modals = _modals.filter((f) => f !== modal);
|
|
emitChange();
|
|
}
|
|
}}
|
|
>
|
|
{({ close }) => (
|
|
<>
|
|
{modal.title && <ModalHeader>{modal.title}</ModalHeader>}
|
|
<ModalBody className="px-7.5 py-8">
|
|
<p className="text-sm text-dabeeo-black-34 font-medium text-center whitespace-pre-wrap">{modal.content}</p>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button
|
|
color="gray"
|
|
size="large"
|
|
autoFocus
|
|
isDisabled={isPending}
|
|
onClick={() => {
|
|
close();
|
|
if (typeof modal.onCancel === 'function') {
|
|
modal.onCancel();
|
|
}
|
|
}}
|
|
>
|
|
{modal.cancelText || '취소'}
|
|
</Button>
|
|
{modal.typeof === 'confirm' && (
|
|
<Button
|
|
color="primary"
|
|
size="large"
|
|
isPending={isPending}
|
|
onClick={() => {
|
|
startTransition(modal.onConfirm.bind(null, close));
|
|
}}
|
|
>
|
|
{modal.confirmText || '확인'}
|
|
</Button>
|
|
)}
|
|
</ModalFooter>
|
|
</>
|
|
)}
|
|
</ModalRoot>
|
|
))}
|
|
</>
|
|
);
|
|
};
|
|
export { alert, confirm, ModalBody, ModalFooter, ModalHeader, ModalRegion, ModalRoot };
|
|
export type { ModalBodyProps, ModalFooterProps, ModalHeaderProps, ModalRootProps };
|
|
|