diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx new file mode 100644 index 0000000..b31070a --- /dev/null +++ b/src/components/Modal/Modal.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef } from 'react'; +import clsx from 'clsx'; +import style from './style/Modal.module.css'; +import type { ModalProps } from './types/types'; +import { stopPropagation } from './utils/stopPropagation'; +import { useFocusTrap } from './hooks/useFocusTrap'; + +/** + * @param isOpen 모달을 렌더링할지 여부를 제어합니다. + * @param onClose 모달을 닫아야 할 때 호출되는 콜백입니다. + * @param children 모달 내부에 렌더링할 콘텐츠입니다. + * @param ariaLabel 다이얼로그의 접근성 이름(필수)입니다. + * @param ariaLabelledby 다이얼로그 제목 요소의 id입니다. + * @param ariaDescribedby 다이얼로그 설명 요소의 id입니다. + * @param className 오버레이에 적용할 추가 클래스입니다. + * @param closeOnOverlayClick 오버레이 클릭 시 닫을지 여부(기본값: true)입니다. + * @param closeOnEscape Escape 키 입력 시 닫을지 여부(기본값: true)입니다. + */ +export default function Modal({ + isOpen, + onClose, + children, + ariaLabel, + ariaLabelledby, + ariaDescribedby, + className, + closeOnOverlayClick = true, + closeOnEscape = true, +}: ModalProps) { + const dialogRef = useRef(null); + const { onKeyDown } = useFocusTrap(isOpen, dialogRef); + + useEffect(() => { + if (!isOpen || !closeOnEscape) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, closeOnEscape, onClose]); + + if (!isOpen) return null; + + const ariaProps = { + ...(ariaLabelledby ? { 'aria-labelledby': ariaLabelledby } : {}), + ...(ariaDescribedby ? { 'aria-describedby': ariaDescribedby } : {}), + }; + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/components/Modal/hooks/useFocusTrap.ts b/src/components/Modal/hooks/useFocusTrap.ts new file mode 100644 index 0000000..2255659 --- /dev/null +++ b/src/components/Modal/hooks/useFocusTrap.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { getFocusableElements } from '../utils/focusable'; +import type React from 'react'; + +export function useFocusTrap(isOpen: boolean, dialogRef: React.RefObject) { + const lastActiveElementRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + const dialogEl = dialogRef.current; + if (!dialogEl) return; + + lastActiveElementRef.current = document.activeElement as HTMLElement | null; + + const focusable = getFocusableElements(dialogEl); + (focusable[0] ?? dialogEl).focus(); + + return () => { + lastActiveElementRef.current?.focus?.(); + }; + }, [isOpen, dialogRef]); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== 'Tab') return; + + const dialogEl = dialogRef.current; + if (!dialogEl) return; + + const focusable = getFocusableElements(dialogEl); + if (focusable.length === 0) { + e.preventDefault(); + dialogEl.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }, + [dialogRef], + ); + + return { onKeyDown }; +} diff --git a/src/components/Modal/style/Modal.module.css b/src/components/Modal/style/Modal.module.css new file mode 100644 index 0000000..b31e8b1 --- /dev/null +++ b/src/components/Modal/style/Modal.module.css @@ -0,0 +1,22 @@ +@import '@shared/styles/color.css'; + +.overlay { + display: flex; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + align-items: center; + justify-content: center; + z-index: var(--z-index-modal, 1000); +} + +.contentsBox { + position: relative; + max-width: 90vw; + max-height: 90vh; + border-radius: 24px; + box-shadow: 4px 4px 10px 0px #24242440; + background-color: var(--color-background-inverse); + overflow: auto; +} diff --git a/src/components/Modal/types/types.ts b/src/components/Modal/types/types.ts new file mode 100644 index 0000000..6c53d07 --- /dev/null +++ b/src/components/Modal/types/types.ts @@ -0,0 +1,15 @@ +import { ReactNode } from 'react'; + +interface BaseModalProps { + isOpen: boolean; + onClose: () => void; + children?: ReactNode; + ariaDescribedby?: string; + className?: string; + closeOnOverlayClick?: boolean; + closeOnEscape?: boolean; +} + +export type ModalProps = + | (BaseModalProps & { ariaLabel: string; ariaLabelledby?: never }) + | (BaseModalProps & { ariaLabel?: never; ariaLabelledby: string }); diff --git a/src/components/Modal/utils/focusable.ts b/src/components/Modal/utils/focusable.ts new file mode 100644 index 0000000..eeebcbe --- /dev/null +++ b/src/components/Modal/utils/focusable.ts @@ -0,0 +1,12 @@ +export function getFocusableElements(root: HTMLElement): HTMLElement[] { + const selector = [ + 'a[href]', + 'button:not([disabled])', + 'textarea:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + ].join(','); + + return Array.from(root.querySelectorAll(selector)); +} diff --git a/src/components/Modal/utils/stopPropagation.ts b/src/components/Modal/utils/stopPropagation.ts new file mode 100644 index 0000000..c6b207d --- /dev/null +++ b/src/components/Modal/utils/stopPropagation.ts @@ -0,0 +1,3 @@ +export function stopPropagation(e: React.SyntheticEvent): void { + e.stopPropagation(); +}