From b6f2b846f97bed2740715e9cea98b0b95cf8cfb8 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 31 Jan 2026 20:19:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=AA=A8=EB=8B=AC=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Modal/Modal.tsx | 72 +++++++++++++++++++++ src/components/Modal/style/Modal.module.css | 22 +++++++ src/components/Modal/types/types.ts | 15 +++++ 3 files changed, 109 insertions(+) create mode 100644 src/components/Modal/Modal.tsx create mode 100644 src/components/Modal/style/Modal.module.css create mode 100644 src/components/Modal/types/types.ts 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/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..3182a97 --- /dev/null +++ b/src/components/Modal/types/types.ts @@ -0,0 +1,15 @@ +import { ReactNode } from 'react'; + +export interface ModalProps { + isOpen: boolean; + onClose: () => void; + children?: ReactNode; + + ariaLabel: string; + ariaLabelledby?: string; + ariaDescribedby?: string; + + className?: string; + closeOnOverlayClick?: boolean; + closeOnEscape?: boolean; +} From 1053d2a485c43c9a4bd917842f7cdbec88cdb8c0 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sat, 31 Jan 2026 20:21:34 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=ED=82=A4=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=ED=83=AD=EC=9D=B4=20=EB=AA=A8=EB=8B=AC=20=EC=95=88=EC=97=90?= =?UTF-8?q?=EC=84=9C=EB=A7=8C=20=EC=9E=91=EB=8F=99=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 커스텀훅 분리 - 유틸함수 추가 --- src/components/Modal/hooks/useFocusTrap.ts | 52 +++++++++++++++++++ src/components/Modal/utils/focusable.ts | 12 +++++ src/components/Modal/utils/stopPropagation.ts | 3 ++ 3 files changed, 67 insertions(+) create mode 100644 src/components/Modal/hooks/useFocusTrap.ts create mode 100644 src/components/Modal/utils/focusable.ts create mode 100644 src/components/Modal/utils/stopPropagation.ts 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/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(); +} From 64689e95e911abf17e97e76ed1c73610fc7485e3 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Sun, 1 Feb 2026 14:33:21 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EC=A0=91=EA=B7=BC=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=94=84=EB=A1=AD=EC=8A=A4=20?= =?UTF-8?q?=EB=B6=84=ED=95=A0=EC=A1=B0=EC=B9=98=20-=20=EB=B2=A0=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=ED=83=80=EC=9E=85=EC=9D=84=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20-?= =?UTF-8?q?=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=ED=83=80=EC=9E=85=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EB=A1=9C=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Modal/types/types.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Modal/types/types.ts b/src/components/Modal/types/types.ts index 3182a97..6c53d07 100644 --- a/src/components/Modal/types/types.ts +++ b/src/components/Modal/types/types.ts @@ -1,15 +1,15 @@ import { ReactNode } from 'react'; -export interface ModalProps { +interface BaseModalProps { isOpen: boolean; onClose: () => void; children?: ReactNode; - - ariaLabel: string; - ariaLabelledby?: string; ariaDescribedby?: string; - className?: string; closeOnOverlayClick?: boolean; closeOnEscape?: boolean; } + +export type ModalProps = + | (BaseModalProps & { ariaLabel: string; ariaLabelledby?: never }) + | (BaseModalProps & { ariaLabel?: never; ariaLabelledby: string });