-
Notifications
You must be signed in to change notification settings - Fork 3
모달 컴포넌트 #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
모달 컴포넌트 #12
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLDivElement>(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 ( | ||
| <section | ||
| className={clsx(style.overlay, className)} | ||
| onClick={closeOnOverlayClick ? onClose : undefined} | ||
| role="presentation" | ||
| > | ||
| <div | ||
| className={style.contentsBox} | ||
| ref={dialogRef} | ||
| onClick={stopPropagation} | ||
| onKeyDown={onKeyDown} | ||
| role="dialog" | ||
| aria-modal="true" | ||
| aria-label={ariaLabel} | ||
| tabIndex={-1} | ||
| {...ariaProps} | ||
| > | ||
| {children} | ||
| </div> | ||
| </section> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<HTMLDivElement | null>) { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| const lastActiveElementRef = useRef<HTMLElement | null>(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<HTMLDivElement>) => { | ||||||
| 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 }; | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLElement>(selector)); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export function stopPropagation(e: React.SyntheticEvent): void { | ||
| e.stopPropagation(); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
오버레이 요소로
role="presentation"속성을 가진<section>을 사용하고 있습니다.role="presentation"이<section>의 시맨틱 의미를 제거하므로 기능적으로는 문제가 없지만, 이처럼 순수하게 표현적인 래퍼(wrapper) 역할만 하는 요소에는<div>를 사용하는 것이 더 일반적이고 코드의 의도를 명확하게 나타냅니다.<section>요소는 일반적으로 제목(heading)과 함께 콘텐츠의 주제별 그룹을 나타내는 데 사용해야 합니다.