Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/components/Modal/Modal.tsx
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>
Comment on lines +52 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

오버레이 요소로 role="presentation" 속성을 가진 <section>을 사용하고 있습니다. role="presentation"<section>의 시맨틱 의미를 제거하므로 기능적으로는 문제가 없지만, 이처럼 순수하게 표현적인 래퍼(wrapper) 역할만 하는 요소에는 <div>를 사용하는 것이 더 일반적이고 코드의 의도를 명확하게 나타냅니다. <section> 요소는 일반적으로 제목(heading)과 함께 콘텐츠의 주제별 그룹을 나타내는 데 사용해야 합니다.

Suggested change
<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>
<div
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>
</div>

);
}
52 changes: 52 additions & 0 deletions src/components/Modal/hooks/useFocusTrap.ts
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>) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

dialogRef의 타입이 React.RefObject<HTMLDivElement | null>로 선언되어 있습니다. useRef<HTMLDivElement>(null)을 사용하여 생성된 ref 객체의 타입은 React.RefObject<HTMLDivElement>입니다. 이 타입의 current 속성이 HTMLDivElement | null이 됩니다. 현재 타입도 호환은 되지만, 혼란을 줄 수 있고 정확하지 않습니다. React.RefObject<HTMLDivElement>로 수정하는 것이 더 명확하고 정확한 타입 선언입니다.

Suggested change
export function useFocusTrap(isOpen: boolean, dialogRef: React.RefObject<HTMLDivElement | null>) {
export function useFocusTrap(isOpen: boolean, dialogRef: React.RefObject<HTMLDivElement>) {

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 };
}
22 changes: 22 additions & 0 deletions src/components/Modal/style/Modal.module.css
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;
}
15 changes: 15 additions & 0 deletions src/components/Modal/types/types.ts
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 });
12 changes: 12 additions & 0 deletions src/components/Modal/utils/focusable.ts
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));
}
3 changes: 3 additions & 0 deletions src/components/Modal/utils/stopPropagation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function stopPropagation(e: React.SyntheticEvent): void {
e.stopPropagation();
}