π― ν€λ³΄λ ν¬μ»€μ€ κ΄λ¦¬ λ―Έν‘: μ κ·Όμ± μ¬κ°μ§λ μμ±
Summary
ν€λ³΄λ ν¬μ»€μ€ κ΄λ¦¬λ₯Ό μνν νλ©΄ ν€λ³΄λ λ° λ³΄μ‘° κΈ°μ μ¬μ©μμ μ κ·Όμ±μ μ¬κ°νκ² μ ν΄νκ³ , μ¬μ©μ κ²½νμ μ
νμν΅λλ€. μλ§¨ν± HTML μμ, tabindex
μ μ μ ν μ¬μ©, λμ UIμμμ ν¬μ»€μ€ νΈλν λ° λ³΅μ, SPA λΌμ°νΈ λ³κ²½ μ ν¬μ»€μ€ μ΄λ, κ·Έλ¦¬κ³ λͺ
νν μκ°μ ν¬μ»€μ€ νμλ₯Ό ν΅ν΄ λͺ¨λ μ¬μ©μκ° μννκ² μ ν리μΌμ΄μ
μ νμν μ μλλ‘ ν΄μΌ ν©λλ€.
Why Wrong?
μΉ μ ν리μΌμ΄μ μμ ν€λ³΄λ ν¬μ»€μ€ κ΄λ¦¬κ° μ λλ‘ μ΄λ£¨μ΄μ§μ§ μμΌλ©΄, ν€λ³΄λλ§μ μ¬μ©νλ μ¬μ©μ(μκ° μ₯μ μΈ, μ΄λ λ₯λ ₯ μ ν μ¬μ©μ)λ μ€ν¬λ¦° 리λ μ¬μ©μμκ² μ¬κ°ν μ κ·Όμ± λ¬Έμ λ₯Ό μΌκΈ°ν©λλ€. μλ₯Ό λ€μ΄, λͺ¨λ¬ μ°½μ΄ μ΄λ Έμ λ ν¬μ»€μ€κ° λͺ¨λ¬ λ΄λΆμ 'κ°νμ§' μκ³ λ· λ°°κ²½ μμλ‘ μ΄λνκ±°λ, λͺ¨λ¬μ΄ λ«νμ λ ν¬μ»€μ€κ° μλ μμΉλ‘ λμμ€μ§ μμΌλ©΄ μ¬μ©μλ νμ¬ μμΉλ₯Ό μκ³ μ ν리μΌμ΄μ μ ν¨κ³Όμ μΌλ‘ νμν μ μκ² λ©λλ€. λν, SPA(Single Page Application)μμ λΌμ°νΈκ° λ³κ²½λ λ λ©μΈ μ½ν μΈ μμμΌλ‘ ν¬μ»€μ€λ₯Ό μ΄λμν€μ§ μμΌλ©΄, μ€ν¬λ¦° 리λ μ¬μ©μλ νμ΄μ§κ° λ³κ²½λμμμ μΈμ§νκΈ° μ΄λ ΅κ³ λ€μ μ²μλΆν° νμν΄μΌ νλ λΆνΈν¨μ κ²ͺκ² λ©λλ€. μ΄λ WCAG(μΉ μ½ν μΈ μ κ·Όμ± μ§μΉ¨) μλ°μΌλ‘ μ΄μ΄μ§λ©°, μ μ¬μ μΈ λ²μ λ¬Έμ μ ν¨κ» μ¬μ©μ κ²½νμ ν¬κ² μ ν΄ν©λλ€.
How to Fix?
μ¬λ°λ₯Έ ν€λ³΄λ ν¬μ»€μ€ κ΄λ¦¬λ₯Ό μν΄μλ λ€μ μ¬νλ€μ μ€μν΄μΌ ν©λλ€:
- μλ§¨ν± HTML μμ μ¬μ©: κΈ°λ³Έμ μΌλ‘
button
,a
,input
λ±κ³Ό κ°μ΄ ν¬μ»€μ€κ° κ°λ₯ν μλ§¨ν± HTML μμλ₯Ό μ¬μ©νμ¬ λΈλΌμ°μ μ κΈ°λ³Έ ν¬μ»€μ€ λμμ νμ©ν©λλ€. tabindex
μ μ¬λ°λ₯Έ νμ©: λΉνμ± μμμ ν¬μ»€μ€λ₯Ό μ£Όκ±°λ ν¬μ»€μ€ μμλ₯Ό λ³κ²½ν΄μΌ ν λλ§tabindex
μμ±μ μ μ€νκ² μ¬μ©ν©λλ€.tabindex="0"
μ μμλ₯Ό μμ°μ€λ¬μ΄ ν μμμ ν¬ν¨μν€κ³ ,tabindex="-1"
μ μμλ₯Ό νλ‘κ·Έλλ° λ°©μμΌλ‘ ν¬μ»€μ€ κ°λ₯νκ² νμ§λ§ ν μμμμλ μ μΈν©λλ€.- λμ UI (λͺ¨λ¬, λλ‘λ€μ΄ λ±) ν¬μ»€μ€ νΈλ© λ° λ³΅μ: λͺ¨λ¬μ΄λ νμ
μ΄ μ΄λ¦΄ λ ν¬μ»€μ€λ₯Ό λͺ¨λ¬ λ΄λΆμ κ°λκ³ (
focus trap
), λͺ¨λ¬μ΄ λ«ν λλ λͺ¨λ¬μ μ΄κΈ° μ μ μμλ‘ ν¬μ»€μ€λ₯Ό 볡μνλ λ‘μ§μ ꡬνν΄μΌ ν©λλ€. μ΄λuseEffect
μuseRef
λ₯Ό μ¬μ©νμ¬ νλ‘κ·Έλλ°μ μΌλ‘element.focus()
λ₯Ό νΈμΆν¨μΌλ‘μ¨ κ°λ₯ν©λλ€.aria-modal
μμ±μ μ¬μ©νμ¬ μ€ν¬λ¦° 리λμ λͺ¨λ¬μ μλ―Έλ₯Ό μ λ¬νλ κ²λ μ€μν©λλ€. - SPA λΌμ°νΈ λ³κ²½ μ ν¬μ»€μ€ μ΄λ: λΌμ°νΈ λ³κ²½ μ
<main>
νκ·Έλ μλ‘μ΄ νμ΄μ§μ 첫 λ²μ§Έ μ€μ μ½ν μΈ μμλ‘ ν¬μ»€μ€λ₯Ό μ΄λμμΌ μ€ν¬λ¦° 리λ μ¬μ©μκ° μ½ν μΈ λ³κ²½μ μΈμ§νκ³ λ°λ‘ νμν μ μλλ‘ ν©λλ€. - μκ°μ μΈ ν¬μ»€μ€ νμ μ μ§:
:focus
μμ¬ ν΄λμ€λ₯Ό μ¬μ©νμ¬ ν¬μ»€μ€λ μμμ λν λͺ νν μκ°μ μ€νμΌ(μ:outline
μ€νμΌ λ³κ²½)μ μ 곡νμ¬ μκ°μ μΈ μ¬μ©μλ νμ¬ ν¬μ»€μ€ μμΉλ₯Ό μ½κ² μ μ μλλ‘ ν΄μΌ ν©λλ€. λΈλΌμ°μ μ κΈ°λ³Έoutline
μoutline: none
μΌλ‘ μ κ±°νλ κ²μ μ§μν΄μΌ ν©λλ€.
Before Code (Bad)
// μλͺ»λ λͺ¨λ¬ ꡬν (ν¬μ»€μ€ κ΄λ¦¬ μμ)
import React, { useState } from 'react';
const BadModal = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(true)}>λͺ¨λ¬ μ΄κΈ°</button>
{isOpen && (
<div style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: 'white',
padding: '20px',
border: '1px solid black',
zIndex: 1000,
}}>
<h2>μλ
νμΈμ, λͺ¨λ¬μ
λλ€!</h2>
<p>μ¬κΈ°μ μ€μν λ΄μ©μ΄ μμ΅λλ€.</p>
<button onClick={() => setIsOpen(false)}>λ«κΈ°</button>
<input type="text" placeholder="μ
λ ₯" />
</div>
)}
{isOpen && <div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 999 }} />}
</div>
);
};
export default BadModal;
After Code (Good)
// μ¬λ°λ₯Έ λͺ¨λ¬ ꡬν (μ κ·Όμ±μ κ³ λ €ν ν¬μ»€μ€ κ΄λ¦¬ ν¬ν¨)
import React, { useState, useRef, useEffect } from 'react';
const AccessibleModal = () => {
const [isOpen, setIsOpen] = useState(false);
const modalRef = useRef(null);
const triggerRef = useRef(null); // λͺ¨λ¬μ μ° λ²νΌ μ°Έμ‘°
useEffect(() => {
if (isOpen) {
// λͺ¨λ¬μ΄ μ΄λ¦¬λ©΄ λͺ¨λ¬ μ체μ ν¬μ»€μ€ (tabindex="-1" νμ)
// λλ λͺ¨λ¬ λ΄ μ²« λ²μ§Έ ν¬μ»€μ€ κ°λ₯ν μμμ ν¬μ»€μ€
modalRef.current.focus();
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
// TODO: λͺ¨λ¬ λ΄λΆμμλ§ ν¬μ»€μ€ μν (tab ν€)
// 볡μ‘νλ―λ‘ ARIA APG λ¬Έμ μ°Έκ³ νμ¬ λΌμ΄λΈλ¬λ¦¬ μ¬μ© κΆμ₯
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
} else {
// λͺ¨λ¬μ΄ λ«νλ©΄ μλ λͺ¨λ¬μ μ΄μλ λ²νΌμΌλ‘ ν¬μ»€μ€ 볡μ
triggerRef.current.focus();
}
}, [isOpen]);
const openModal = () => {
triggerRef.current = document.activeElement; // νμ¬ ν¬μ»€μ€λ μμ μ μ₯
setIsOpen(true);
};
const closeModal = () => {
setIsOpen(false);
};
return (
<div>
<button onClick={openModal}>λͺ¨λ¬ μ΄κΈ°</button>
{isOpen && (
<div
ref={modalRef}
role="dialog"
aria-modal="true" // μ€ν¬λ¦° 리λμ λͺ¨λ¬μμ μλ¦Ό
tabIndex="-1" // νλ‘κ·Έλλ° λ°©μμΌλ‘ ν¬μ»€μ€ κ°λ₯νκ² ν¨
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
backgroundColor: 'white',
padding: '20px',
border: '1px solid black',
zIndex: 1000,
}}
>
<h2>μλ
νμΈμ, λͺ¨λ¬μ
λλ€!</h2>
<p>μ¬κΈ°μ μ€μν λ΄μ©μ΄ μμ΅λλ€.</p>
<button onClick={closeModal}>λ«κΈ°</button>
<input type="text" placeholder="μ
λ ₯" />
</div>
)}
{isOpen && <div style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 999 }} onClick={closeModal} />}
</div>
);
};
export default AccessibleModal;