feat: 컴포넌트 추가, 스타일 수정

This commit is contained in:
2026-04-10 12:23:48 +09:00
parent a836333512
commit 35f6023f5a
19 changed files with 1263 additions and 56 deletions

View File

@@ -0,0 +1,57 @@
import type { ReactNode } from 'react';
import { Button } from '../button/Button';
import type { ModalRootProps } from './Modal';
import { ModalBody, ModalFooter, ModalHeader, ModalRoot } from './Modal';
interface AlertModalProps {
isOpen?: ModalRootProps['isOpen'];
onOpenChange?: ModalRootProps['onOpenChange'];
title?: string;
content?: ReactNode;
confirmText?: string;
onConfirm?: () => void;
}
export const AlertModal = (props: AlertModalProps) => {
const { isOpen, onOpenChange, title, ...contentProps } = props;
return (
<ModalRoot isOpen={isOpen} onOpenChange={onOpenChange} className="w-75" aria-label={title ?? '알림'}>
{({ close }) => <AlertModalContent close={close} title={title} {...contentProps} />}
</ModalRoot>
);
};
function AlertModalContent({
close,
title,
content,
confirmText = '확인',
onConfirm,
}: {
close: () => void;
title?: string;
content?: ReactNode;
confirmText?: string;
onConfirm?: () => void;
}) {
const handleConfirm = () => {
onConfirm?.();
close();
};
return (
<>
{title && <ModalHeader>{title}</ModalHeader>}
<ModalBody className="px-7.5 py-8">
<p className="text-sm text-dabeeo-black-34 font-medium text-center whitespace-pre-wrap">{content}</p>
</ModalBody>
<ModalFooter>
<Button color="gray" size="large" autoFocus onClick={handleConfirm}>
{confirmText}
</Button>
</ModalFooter>
</>
);
}

View File

@@ -0,0 +1,79 @@
import type { ReactNode } from 'react';
import { useTransition } from 'react';
import { Button } from '../button/Button';
import { ModalBody, ModalFooter, ModalHeader, ModalRoot } from './Modal';
interface ConfirmModalProps {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
title?: string;
content?: ReactNode;
confirmText?: string;
cancelText?: string;
onConfirm?: () => Promise<void> | void;
onCancel?: () => void;
}
export const ConfirmModal = (props: ConfirmModalProps) => {
const { isOpen, onOpenChange, title, ...contentProps } = props;
return (
<ModalRoot isOpen={isOpen} onOpenChange={onOpenChange} className="w-75" aria-label={title ?? '확인'}>
{({ close }) => <ConfirmModalContent close={close} title={title} {...contentProps} />}
</ModalRoot>
);
};
function ConfirmModalContent({
close,
title,
content,
confirmText = '확인',
cancelText = '취소',
onConfirm,
onCancel,
}: {
close: () => void;
title?: string;
content?: ReactNode;
confirmText?: string;
cancelText?: string;
onConfirm?: () => Promise<void> | void;
onCancel?: () => void;
}) {
const [isPending, startTransition] = useTransition();
const handleConfirm = () => {
if (onConfirm) {
startTransition(async () => {
await onConfirm();
close();
});
} else {
close();
}
};
const handleCancel = () => {
onCancel?.();
close();
};
return (
<>
{title && <ModalHeader>{title}</ModalHeader>}
<ModalBody className="px-7.5 py-8">
<p className="text-sm text-dabeeo-black-34 font-medium text-center whitespace-pre-wrap">{content}</p>
</ModalBody>
<ModalFooter>
<Button color="gray" size="large" isDisabled={isPending} onClick={handleCancel}>
{cancelText}
</Button>
<Button color="primary" size="large" isPending={isPending} onClick={handleConfirm}>
{confirmText}
</Button>
</ModalFooter>
</>
);
}

View File

@@ -0,0 +1,229 @@
'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 };

View File

@@ -0,0 +1,29 @@
'use client';
import { useSyncExternalStore } from 'react';
import { modalStore } from './store';
export const ModalRenderer = () => {
const modals = useSyncExternalStore(modalStore.subscribe, modalStore.getSnapshot, modalStore.getServerSnapshot);
return (
<>
{modals.map((modal) => {
const { Component, props, id, resolve } = modal;
return (
<Component
key={id}
{...props}
isOpen={true}
onOpenChange={(open: boolean) => {
if (!open) {
resolve();
modalStore.removeModal(id);
}
}}
/>
);
})}
</>
);
};

View File

@@ -0,0 +1,25 @@
import {
ModalBody,
ModalFooter,
ModalHeader,
ModalRegion as ModalRegionPrimitive,
ModalRoot,
alert,
confirm,
} from './Modal';
export const Modal = Object.assign(ModalRoot, {
Header: ModalHeader,
Body: ModalBody,
Footer: ModalFooter,
});
export const ModalRegion = Object.assign(ModalRegionPrimitive, {
alert,
confirm,
});
export { useModal } from './useModal';
export { ModalRenderer } from './ModalRenderer';
export { AlertModal } from './AlertModal';
export { ConfirmModal } from './ConfirmModal';

View File

@@ -0,0 +1,43 @@
import type { ComponentType } from 'react';
interface ModalData {
id: string;
scopeId: string;
Component: ComponentType<any>;
props: Record<string, unknown>;
resolve: () => void;
}
let modals: ModalData[] = [];
let listeners: (() => void)[] = [];
const emitChange = () => {
listeners.forEach((listener) => listener());
};
const SERVER_SNAPSHOT: ModalData[] = [];
export const modalStore = {
subscribe: (listener: () => void) => {
listeners = [...listeners, listener];
return () => {
listeners = listeners.filter((l) => l !== listener);
};
},
getSnapshot: () => modals,
getServerSnapshot: () => SERVER_SNAPSHOT,
addModal: (modal: ModalData) => {
modals = [...modals, modal];
emitChange();
},
removeModal: (id: string) => {
modals = modals.filter((m) => m.id !== id);
emitChange();
},
removeByScope: (scopeId: string) => {
modals = modals.filter((m) => m.scopeId !== scopeId);
emitChange();
},
};
export type { ModalData };

View File

@@ -0,0 +1,50 @@
import type { ComponentType } from 'react';
import { useCallback, useEffect, useId } from 'react';
import { modalStore } from './store';
interface ModalControlProps {
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
}
export const useModal = () => {
const scopeId = useId();
useEffect(() => {
return () => {
modalStore
.getSnapshot()
.filter((m) => m.scopeId === scopeId)
.forEach((m) => m.resolve());
modalStore.removeByScope(scopeId);
};
}, [scopeId]);
const show = useCallback(
<P extends ModalControlProps>(
Component: ComponentType<P>,
props?: Omit<P, keyof ModalControlProps>
): Promise<void> =>
new Promise((resolve) => {
modalStore.addModal({
id: crypto.randomUUID(),
scopeId,
Component,
props: props ?? {},
resolve,
});
}),
[scopeId]
);
const hide = useCallback(() => {
modalStore
.getSnapshot()
.filter((m) => m.scopeId === scopeId)
.forEach((m) => m.resolve());
modalStore.removeByScope(scopeId);
}, [scopeId]);
return { show, hide };
};