Skip to content

Commit cd23da4

Browse files
committed
아코디언과 모달은 기본 html 요소를 먼저 설명
1 parent 28c3387 commit cd23da4

File tree

2 files changed

+125
-170
lines changed

2 files changed

+125
-170
lines changed

fundamentals/a11y/ui-foundation/accordion.md

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
**아코디언 목록의 구조와 현재 활성 상태를 바로 이해하고 조작**할 수 있도록 구현하는 게 핵심이에요.
66

7-
아래 내용은 특히 `aria-expanded` 같은 상태 속성과 레이블 처리, 포커스 관리 등 실무에서 실수하기 쉬운 부분을 구체적으로 다뤄요.
7+
아래 내용은 아코디언을 html 표준 요소인 `<details>``<summary>`를 사용하여 구현하는 방법과 `aria-expanded` 같은 상태 속성과 레이블 처리, 포커스 관리 등 실무에서 실수하기 쉬운 부분을 구체적으로 다뤄요.
88

9-
## 이런 아코디언을 보여주려면 어떻게 구현해야 할까요?
9+
## 상황: 스크린리더가 잘 읽는 아코디언 만들기
1010

1111
![아코디언 예시](../images/accordion.png)
1212

@@ -45,7 +45,32 @@
4545
아코디언은 여러 개의 독립된 요소가 모여 있는 구조가 아니라, 펼쳐짐과 접힘 상태를 함께 관리하는 하나의 그룹이에요.
4646
따라서 사용자가 현재 어떤 항목이 펼쳐져 있는지, 그 안에 어떤 내용이 있는지를 쉽게 이해할 수 있어야 해요.
4747

48-
`aria-expanded`, `aria-controls`, `aria-labelledby` 속성을 사용하여 아코디언의 상태와 구조를 명확히 전달할 수 있어요.
48+
## `<details>``<summary>` 요소를 사용하여 아코디언 구현하기
49+
50+
`<details>``<summary>` 요소를 사용하면 아코디언을 쉽게 구현할 수 있고, 접근성을 자연스럽게 챙길 수 있어요.
51+
52+
```tsx
53+
<details open={isOpen1} onToggle={handleToggleAccordion1}>
54+
<summary>토스뱅크의 한도제한계좌는 어떻게 해제할 수 있나요?</summary>
55+
<p>금융거래목적을 확인할 수 있는 증빙서류를 제출하여 한도 계좌 해제 신청을 할 수 있어요. 단, 증빙서류 직접 제출 시에는 영업일 기준 2~3일 소요될 수 있어요.</p>
56+
</details>
57+
<details open={isOpen2} onToggle={handleToggleAccordion2}>
58+
<summary>토스증권 수수료와 세금이 궁금해요!</summary>
59+
<p>토스증권에서 국내 주식 거래 시 수수료는 0.015%, 제세금은 0.20%가 부과됩니다.</p>
60+
</details>
61+
```
62+
63+
:::: info 예제 코드 해설
64+
65+
- `<details>`: 아코디언의 그룹을 나타내는 요소로, 펼쳐짐과 접힘 상태를 함께 관리해요.
66+
- `<summary>`: 아코디언의 제목과 펼침을 제어할 수 있는 요소로, 클릭하면 `<details>` 내에 `<summary>` 외의 영역이 보여지거나 숨겨져요.
67+
- `open`: 아코디언이 펼쳐져 있는지 여부를 제어해요.
68+
- `onToggle`: 아코디언이 펼쳐지거나 접히면 호출되는 이벤트로, 상태를 관리해요.
69+
::::
70+
71+
## 커스텀 컴포넌트로 아코디언 구현하기
72+
73+
`<details>``<summary>` 요소를 사용하여 아코디언을 구현할 수 없다면, `aria-expanded`, `aria-controls`, `aria-labelledby` 속성을 사용하여 아코디언의 상태와 구조를 명확히 전달해야해요.
4974

5075
```tsx{3-4,9,15-16,21}
5176
<div>
@@ -56,7 +81,7 @@
5681
>
5782
토스뱅크의 한도제한계좌는 어떻게 해제할 수 있나요?
5883
</button>
59-
<div id="panel-1" aria-labelledby="button-1" hidden={!isOpen}>
84+
<div id="panel-1" role="region" aria-labelledby="button-1" hidden={!isOpen}>
6085
금융거래목적을 확인할 수 있는 증빙서류를 제출하여 한도 계좌 해제 신청을 할
6186
수 있어요. 단, 증빙서류 직접 제출 시에는 영업일 기준 2~3일 소요될 수 있어요.
6287
</div>
@@ -68,7 +93,7 @@
6893
>
6994
토스증권 수수료와 세금이 궁금해요!
7095
</button>
71-
<div id="panel-2" aria-labelledby="button-2" hidden={!isOpen}>
96+
<div id="panel-2" role="region" aria-labelledby="button-2" hidden={!isOpen}>
7297
토스증권에서 국내 주식 거래 시 수수료는 0.015%, 제세금은 0.20%가 부과됩니다.
7398
</div>
7499
{/* 이하 생략 */}
@@ -93,7 +118,7 @@
93118

94119
### 체크리스트
95120

96-
- **역할:** 패널에는 `role="region"`(선택)`aria-labelledby`로 버튼 id를 참조하면 문맥이 좋아요. 버튼의 `aria-controls`는 연관된 패널의 `id`로 연결해요.
121+
- **역할:** 패널에는 `role="region"` `aria-labelledby`로 버튼 id를 참조하면 문맥이 좋아요. 버튼의 `aria-controls`는 연관된 패널의 `id`로 연결해요.
97122
- **상태:** 헤더는 **버튼**으로 구현한 뒤 `aria-expanded`로 열림/닫힘 상태를 전달해요.
98123
패널의 표시 여부는 `hidden` 속성으로 제어해요.
99124

fundamentals/a11y/ui-foundation/modal.md

Lines changed: 94 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -2,115 +2,137 @@
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
141163
useEffect(() => {
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

Comments
 (0)