22
33모달은 사용자의 주의를 끌어 중요한 정보나 작업을 처리할 때 사용하는 컴포넌트예요.
44
5- ** 모달의 존재와 내용을 인식하고, 모달 내부에서만 상호작용** 할 수 있도록 구현하는 게 핵심이에요.
6-
7- ## 이런 모달을 보여주려면 어떻게 구현해야 할까요?
8-
95![ 모달 예시] ( ../images/modal.png )
106
117모달의 경우 일반적인 페이지 콘텐츠와 분리된 독립적인 영역이에요.
128
13- 때문에 모달이 열렸다는 것을 사용자가 인식하고, 모달 내부에서만 상호작용할 수 있어야 해요.
9+ 때문에 ** 모달이 열렸다는 것을 사용자가 인식하고, 모달 내부에서만 상호작용 ** 할 수 있어야 해요.
1410
15- 겉보기에는 모달이 구성되어 있지만, ` role="dialog" ` 와 ` aria-modal ` 속성이 없어 스크린리더가 모달을 인식하지 못해요 .
11+ 아래 예제는 버튼을 클릭하면 모달이 열리는 예제에요 .
1612
1713``` tsx
18- <div >
19- <h3 >다음에 다시 시도해 주세요</h3 >
20- <button onClick = { closeModal } >확인</button >
21- </div >
14+ <button onClick = { openModal } >모달 열기</button >;
15+ <>
16+ { isOpen && (
17+ <div style = { { position: " fixed" }} >
18+ <h3 >다음에 다시 시도해 주세요</h3 >
19+ <button onClick = { closeModal } >확인</button >
20+ </div >
21+ )}
22+ </>;
2223```
2324
24- :::: info 예제 코드 해설
25+ 겉보기에는 모달이 구성되어 있지만, 다음과 같은 문제가 있어요.
2526
26- - ` role="dialog" ` : 이 영역이 대화상자(모달)임을 알려요.
27- - ` aria-modal="true" ` : 모달이 떠 있는 동안 배경과의 상호작용을 차단해야 함을 나타내요.
28- - ` aria-labelledby ` /` aria-label ` (추가 가능): 모달의 제목을 스크린리더에 전달해요.
29- ::::
27+ - 모달이 열렸다는 것을 사용자가 인식하지 못해요.
28+ - 모달 바깥의 콘텐츠와 상호작용할 수 있어요.
3029
31- ::: danger ❌ 접근성을 지키지 않으면 이렇게 들려요 .
30+ ::: danger ❌ 접근성을 지키지 않으면 모달 영역이 이렇게 탐색돼요 .
3231
3332다음에 다시 시도해 주세요, 머리말<br />
3433확인, ** 버튼** <br />
3534
3635:::
3736
38- ` role="dialog" ` 와 ` aria-modal="true" ` 를 사용하여 모달을 명확히 표시할 수 있어요.
37+ ## 모달 접근성을 지키기 위한 방법
38+
39+ 가장 쉬운 방법은 html 표준 요소인 ` <dialog> ` 요소를 사용하는 것이에요.
3940
4041``` tsx
41- <div role = " dialog" aria-modal = " true" >
42- <h3 >다음에 다시 시도해 주세요</h3 >
43- <button onClick = { closeModal } >확인</button >
44- </div >
42+ const ref = useRef <HTMLDialogElement >(null );
43+ return (
44+ <>
45+ <button aria-haspopup = " dialog" onClick = { () => ref .current ?.showModal ()} >
46+ 모달 열기
47+ </button >
48+ <dialog ref = { ref } aria-labelledby = " modal-title" >
49+ <h3 id = " modal-title" >다음에 다시 시도해 주세요</h3 >
50+ <button onClick = { () => ref .current ?.close ()} >확인</button >
51+ </dialog >
52+ </>
53+ );
4554```
4655
47- ::: tip ✅ 접근성을 지키면 이렇게 들려요.
56+ ::: tip ✅ 접근성을 지키면 모달 영역이 이렇게 들려요.
4857
4958제목은 한 줄로 써주세요, ** 대화상자** <br />
5059확인, ** 버튼** <br />
5160
5261:::
5362
54- ### 체크리스트
63+ ` <dialog> ` 요소를 ` showModal() ` 을 이용해서 열면 다음과 기능들을 자동으로 브라우저에서 제공해줘요.
5564
56- - 모달은 ` role="dialog" ` 와 ` aria-modal="true" ` 로 구현해요 .
57- - 모달 제목은 ` aria-labelledby ` 로 연결하거나 ` aria-label ` 로 제공해요 .
58- - 모달이 열릴 때 포커스를 모달 내부로 이동시키고, 모달이 닫힐 때 원래 위치로 돌려보내요 .
59- - ` ESC ` 키로 모달을 닫을 수 있도록 구현해요 .
60- - 모달이 열려있는 동안 배경 콘텐츠와의 상호작용을 차단해요 .
65+ - 쌓임맥락(z-index)과 상관없이 화면의 최상위에 위치하게 돼요 .
66+ - 자동으로 다이얼로그 내부에 포커스를 이동시켜줘요 .
67+ - ` <dialog> ` 내부의 요소만 포커스할 수 있게 돼요 .
68+ - 키보드의 ESC 키로 다이얼로그를 닫을 수 있어요 .
69+ - 다이얼로그를 끄면 원래 포커스로 돌아가게 돼요 .
6170
62- ## role 속성으로 모달 컴포넌트 표현하기
71+ ::: info
72+ 버튼에 들어간 ` aria-haspopup="dialog" ` 속성은 해당 버튼이 다이얼로그를 열 수 있음을 나타내요.
73+ :::
6374
64- ` role="dialog" ` 는 모달이 대화상자 역할임을 나타내요.
65- ` aria-modal="true" ` 는 모달이 열려있을 때 배경 콘텐츠와의 상호작용을 차단해야 함을 나타내요.
75+ ::: warning
76+ ` showModal() ` 을 사용하지 않고 ` show() ` 를 사용하거나 ` <dialog open={true}> ` 를 통해 다이얼로그를 열면 브라우저에서는 "비대화형 다이얼로그" 라고 판단해서 최상위에 위치하게 되는 외의 기능들을 사용할 수 없어요.
77+ :::
6678
67- ``` tsx
68- <div role = " dialog" aria-modal = " true" aria-labelledby = " modal-title" >
69- <h2 id = " modal-title" >제목은 한 줄로 써주세요</h2 >
70- <button onClick = { closeModal } >확인</button >
71- </div >
72- ```
79+ ## role과 aria-modal 속성으로 모달 컴포넌트 표현하기
7380
74- ::: tip ✅ role="dialog"와 aria-modal을 함께 사용하면 이런 이점이 있어요.
81+ ` <dialog> ` 를 사용하지 않는다면 ` role="dialog" ` 와 ` aria-modal="true" ` 를 사용하여 모달을 명확히 표시할 수 있어요. 그 외에 포커스 관리, ESC 키로 모달을 닫기, 배경 콘텐츠 숨기기 등의 기능을 구현해야 해요 .
7582
76- 1 . 스크린리더로 모달 인식이 가능해요
77- - 모달이 대화상자임을 명확히 알려줘요
78- - 모달 제목과 내용을 구분해서 읽어줘요
79- 2 . 포커스 관리가 자동화돼요
80- - 모달이 열릴 때 자동으로 포커스가 모달로 이동해요
81- - 모달이 닫힐 때 원래 위치로 포커스가 돌아가요
82- 3 . 배경 콘텐츠 차단이 가능해요
83- - aria-modal="true"로 배경과의 상호작용을 차단해, 사용자가 모달에만 집중할 수 있어요
83+ ``` tsx
84+ <button onClick = { openModal } >모달 열기</button >;
85+ <>
86+ { isOpen && (
87+ <div role = " dialog" aria-modal = " true" >
88+ <h3 >다음에 다시 시도해 주세요</h3 >
89+ <button onClick = { closeModal } >확인</button >
90+ </div >
91+ )}
92+ </>;
93+ ```
8494
85- :::
95+ ### 체크리스트
8696
87- ## 포커스 트랩과 속성 관리 구현하기
97+ - 모달은 ` role="dialog" ` 와 ` aria-modal="true" ` 로 구현해요.
98+ - 모달 제목은 ` aria-labelledby ` 로 연결하거나 ` aria-label ` 로 제공해요.
99+ - 모달이 열릴 때 포커스를 모달 내부로 이동시키고, 모달이 닫힐 때 원래 위치로 돌려보내요.
100+ - ` ESC ` 키로 모달을 닫을 수 있도록 구현해요.
101+ - 모달이 열려있는 동안 배경 콘텐츠와의 상호작용을 차단해요.
88102
89- 모달을 접근 가능하게 만들려면 다음 네 가지를 구현해야 해요.
103+ ### 포커스 트랩과 속성 관리 구현하기
90104
91- ### 1. 포커스 저장과 복원
105+ 모달을 접근 가능하게 만들려면 다음 세 가지를 구현해야 해요.
92106
93- 모달이 열릴 때 현재 포커스 위치를 저장하고, 닫힐 때 원래 위치로 돌려보내요.
107+ #### 1. 포커스 저장과 복원
94108
95- ` useRef ` 로 이전 포커스 위치를 저장하고, ` useEffect ` 의 cleanup 함수에서 복원해요 .
109+ 모달이 열릴 때 현재 포커스 위치를 기억해뒀다가, 닫힐 때 원래 위치로 돌려보내야해요 .
96110
97111``` tsx
98- const previousFocusRef = useRef (null );
99-
100- useEffect (() => {
101- if (! isOpen ) return ;
102-
103- // 모달 열릴 때 현재 포커스 저장
104- previousFocusRef .current = document .activeElement ;
105-
106- return () => {
107- // 모달 닫힐 때 원래 위치로 포커스 복원
108- previousFocusRef .current ?.focus ();
109- };
110- }, [isOpen ]);
112+ const buttonRef = useRef <HTMLButtonElement >(null );
113+ const closeModal = () => {
114+ setIsOpen (false );
115+ requestAnimationFrame (() => {
116+ buttonRef .current ?.focus ();
117+ });
118+ };
119+
120+ return (
121+ <>
122+ <button onClick = { openModal } ref = { buttonRef } >
123+ 모달 열기
124+ </button >
125+ { isOpen && (
126+ <div role = " dialog" aria-modal = " true" >
127+ <h3 >다음에 다시 시도해 주세요</h3 >
128+ <button onClick = { closeModal } >확인</button >
129+ </div >
130+ )}
131+ </>
132+ );
111133```
112134
113- ### 2. ESC 키로 모달 닫기
135+ #### 2. ESC 키로 모달 닫기
114136
115137사용자가 ESC 키를 눌렀을 때 모달이 닫히도록 해요.
116138
@@ -131,114 +153,22 @@ useEffect(() => {
131153}, [isOpen , onClose ]);
132154```
133155
134- ### 3. 배경 콘텐츠 숨기기 (aria-hidden )
156+ #### 3. 배경 콘텐츠 숨기기 (inert )
135157
136- 모달이 열려있을 때 배경 콘텐츠가 스크린리더에 읽히지 않도록 해요.
158+ 모달이 열려있을 때 모달 외의 배경 콘텐츠가 스크린리더에 읽히지 않도록 해요.
137159
138- 배경 콘텐츠에 ` aria-hidden="true" ` 를 설정하여 스크린리더가 해당 영역을 건너뛰도록 해요.
160+ 배경 콘텐츠에 ` inert ` 를 ` true ` 로 설정하여 스크린리더가 해당 영역을 모달이 열려있는 동안 인식하지 못하도록 해요.
139161
140162``` tsx
141163useEffect (() => {
142164 const main = document .querySelector (" main" );
143165
144166 if (isOpen ) {
145- main ?.setAttribute (" aria-hidden" , " true" );
146- } else {
147- main ?.removeAttribute (" aria-hidden" );
148- }
149-
150- return () => main ?.removeAttribute (" aria-hidden" );
151- }, [isOpen ]);
152- ```
153-
154- ### 4. 스크롤 락
155-
156- 모달이 열려있을 때 배경 페이지가 스크롤되지 않도록 해요.
157-
158- ` document.body.style.overflow ` 를 ` hidden ` 으로 설정하여 배경 스크롤을 막아요.
159-
160- ``` tsx
161- useEffect (() => {
162- if (isOpen ) {
163- document .body .style .overflow = " hidden" ;
167+ main ?.setAttribute (" inert" , " true" );
164168 } else {
165- document . body . style . overflow = " " ;
169+ main ?. removeAttribute ( " inert " ) ;
166170 }
167171
168- return () => {
169- document .body .style .overflow = " " ;
170- };
172+ return () => main ?.removeAttribute (" inert" );
171173}, [isOpen ]);
172174```
173-
174- ### 완성된 모달 컴포넌트
175-
176- 위의 모든 개념을 통합한 간단한 예제예요.
177-
178- 포커스 관리, ESC 키 처리, 배경 콘텐츠 숨기기, 스크롤 락을 모두 포함한 완성된 모달 컴포넌트예요.
179-
180- ``` tsx
181- function Modal({ isOpen , onClose , children , title }) {
182- const modalRef = useRef (null );
183- const previousFocusRef = useRef (null );
184-
185- useEffect (() => {
186- if (! isOpen ) return ;
187-
188- // 1. 포커스 저장 및 aria-hidden 설정
189- previousFocusRef .current = document .activeElement ;
190- document .querySelector (" main" )?.setAttribute (" aria-hidden" , " true" );
191- document .body .style .overflow = " hidden" ;
192-
193- // 2. ESC 키 처리
194- const handleEscape = (e : KeyboardEvent ) => {
195- if (e .key === " Escape" ) onClose ();
196- };
197- document .addEventListener (" keydown" , handleEscape );
198-
199- return () => {
200- // 3. 정리
201- document .removeEventListener (" keydown" , handleEscape );
202- document .querySelector (" main" )?.removeAttribute (" aria-hidden" );
203- document .body .style .overflow = " " ;
204- previousFocusRef .current ?.focus ();
205- };
206- }, [isOpen , onClose ]);
207-
208- if (! isOpen ) return null ;
209-
210- return (
211- <div
212- role = " dialog"
213- aria-modal = " true"
214- aria-labelledby = " modal-title"
215- ref = { modalRef }
216- >
217- <h2 id = " modal-title" >{ title } </h2 >
218- <button onClick = { onClose } >닫기</button >
219- { children }
220- </div >
221- );
222- }
223- ```
224-
225- :::: info 예제 코드 해설
226-
227- - 모달 열림 시: 이전 포커스 저장 → ` aria-hidden="true" ` 로 배경 숨김 → body 스크롤 락.
228- - 모달 닫힘 시: 이벤트 정리 → 배경 ` aria-hidden ` 제거/스크롤 복원 → 저장한 포커스로 복귀.
229- - ` role="dialog" ` /` aria-modal ` : 모달 영역과 배경 차단을 명시해요.
230- ::::
231-
232- ### 핵심 요약
233-
234- 모달을 열 때 필요한 작업:
235-
236- 1 . 현재 포커스 위치를 저장해요
237- 2 . 배경 콘텐츠에 ` aria-hidden="true" ` 를 추가해요
238- 3 . 배경 스크롤을 막아요
239-
240- 모달을 닫을 때 필요한 작업:
241-
242- 1 . 저장해둔 포커스 위치로 돌아가요
243- 2 . ` aria-hidden ` 을 제거해요
244- 3 . 스크롤을 복원해요
0 commit comments