feat: 컴포넌트 추가, 스타일 수정
This commit is contained in:
57
web-app/app/shared/components/modal/AlertModal.tsx
Normal file
57
web-app/app/shared/components/modal/AlertModal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
79
web-app/app/shared/components/modal/ConfirmModal.tsx
Normal file
79
web-app/app/shared/components/modal/ConfirmModal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
229
web-app/app/shared/components/modal/Modal.tsx
Normal file
229
web-app/app/shared/components/modal/Modal.tsx
Normal 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 };
|
||||
|
||||
29
web-app/app/shared/components/modal/ModalRenderer.tsx
Normal file
29
web-app/app/shared/components/modal/ModalRenderer.tsx
Normal 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
25
web-app/app/shared/components/modal/index.ts
Normal file
25
web-app/app/shared/components/modal/index.ts
Normal 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';
|
||||
43
web-app/app/shared/components/modal/store.ts
Normal file
43
web-app/app/shared/components/modal/store.ts
Normal 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 };
|
||||
50
web-app/app/shared/components/modal/useModal.ts
Normal file
50
web-app/app/shared/components/modal/useModal.ts
Normal 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 };
|
||||
};
|
||||
Reference in New Issue
Block a user