𧱠컀μ€ν μ»΄ν¬λνΈμ ARIA μμ± λλ½: μ κ·Όμ±μ λ²½ μκΈ°
Summary
μΌλ° HTML μμλ‘ λ§λ 컀μ€ν
μ»΄ν¬λνΈ(ν, λͺ¨λ¬ λ±)μ WAI-ARIA role
, state
, property
λ₯Ό λλ½νλ©΄ 보쑰 κΈ°μ μ¬μ©μκ° λͺ©μ κ³Ό μνλ₯Ό μ΄ν΄νκΈ° μ΄λ €μ μ κ·Όμ±μ μ ν΄ν©λλ€. μ μ ν ARIA μμ±μ μΆκ°νκ³ ν€λ³΄λ μ κ·Όμ±μ ν보νμ¬ λͺ¨λ μ¬μ©μλ₯Ό μν ν¬μ©μ μΈ UIλ₯Ό ꡬμΆν΄μΌ ν©λλ€.
Why Wrong?
μΌλ°μ μΈ HTML μμ(div
, span
λ±)λ‘ μ§μ λ§λ 볡μ‘ν μΈν°λν°λΈ μ»΄ν¬λνΈ(μ: ν, μμ½λμΈ, λͺ¨λ¬, μΊλ¬μ
)λ λ³Έμ§μ μΌλ‘ μλ―Έλ‘ μ μΈ μ 보λ₯Ό κ°μ§κ³ μμ§ μμ΅λλ€. λ¨μν μκ°μ μΌλ‘ λ²νΌμ΄λ νμ²λΌ 보μ΄λλΌλ, μ€ν¬λ¦° 리λμ κ°μ 보쑰 κΈ°μ μ ν΄λΉ μμμ λͺ©μ , νμ¬ μν(μ: μ νλ¨, νμ₯λ¨, μ¨κ²¨μ§), κ·Έλ¦¬κ³ μ¬μ©μμμ μνΈμμ© λ°©μμ μΈμ§ν μ μμ΅λλ€. μ΄λ μκ° μ₯μ μΈ, μ΄λ μ₯μ μΈ λ± λ³΄μ‘° κΈ°μ μ μ¬μ©νλ μ¬μ©μλ€μ΄ μΉ μ ν리μΌμ΄μ
μ μ΄ν΄νκ³ ν¨μ¨μ μΌλ‘ νμνλ©° μνΈμμ©νλ κ²μ λΆκ°λ₯νκ² λ§λλλ€. κ²°κ³Όμ μΌλ‘ μ ν리μΌμ΄μ
μ μ κ·Όμ± μΈ‘λ©΄μμ μ¬κ°ν κ²°ν¨μ κ°μ§κ² λλ©°, μ΄λ μΉ νμ€ μλ°μ΄μ μ¬μ©μ κ²½νμ μ νλ‘ μ΄μ΄μ§λλ€.
μλ₯Ό λ€μ΄, div
λ‘ λ§λ νμ μ€ν¬λ¦° 리λμκ² λ¨μν ν
μ€νΈ λ©μ΄λ¦¬λ‘λ§ μΈμλ λΏ, μ¬μ©μκ° νμμ μΈμ§νκ³ μ νν μ μλ μμλ‘ μ λ¬λμ§ μμ΅λλ€. λν, ν€λ³΄λλ§μΌλ‘ ν κ°μ μ΄λνκ±°λ ν ν¨λμ νμ±ννλ λ±μ λμμ΄ λΆκ°λ₯ν΄μ§λλ€. μλ§¨ν± HTML μμκ° μ 곡νλ λ΄μ₯λ μ κ·Όμ± μ΄μ μ μκ³ , 보쑰 κΈ°μ μ΄ UI μμλ₯Ό μ¬λ°λ₯΄κ² ν΄μνμ§ λͺ»νκ² λ§λλ μ¬κ°ν λ¬Έμ μ
λλ€.
How to Fix?
λͺ¨λ 컀μ€ν
μΈν°λν°λΈ μ»΄ν¬λνΈμλ WAI-ARIA(Web Accessibility Initiative - Accessible Rich Internet Applications)μ μ μ ν role
, state
, κ·Έλ¦¬κ³ property
λ₯Ό μ μ©νμ¬ μλ―Έλ‘ μ μΈ μ 보λ₯Ό λͺ
μμ μΌλ‘ μ 곡ν΄μΌ ν©λλ€. μ΄λ 보쑰 κΈ°μ μ΄ μ»΄ν¬λνΈμ λͺ©μ κ³Ό μνλ₯Ό μ νν ν΄μνκ³ μ¬μ©μμκ² μ λ¬ν μ μλλ‘ λμ΅λλ€.
- μ μ ν
role
λΆμ¬: μ»΄ν¬λνΈμ μ ν(μ: ν λͺ©λ‘μrole="tablist"
, κ° νμrole="tab"
, ν ν¨λμrole="tabpanel"
)μ λ§λ ARIArole
μ ν λΉν©λλ€. aria-
μμ± νμ©: μ»΄ν¬λνΈμ λμ μΈ μν(μ: μ νλ¨aria-selected
, νμ₯λ¨aria-expanded
, μ¨κ²¨μ§aria-hidden
)λ κ΄κ³(μ: μ μ΄νλ μμaria-controls
, λ μ΄λΈaria-labelledby
)λ₯Ό λνλ΄λaria-
μμ±μ μ¬μ©ν©λλ€. μ΄λ¬ν μμ±λ€μ μ€ν¬λ¦° 리λκ° μ¬μ©μμκ² μ νν μ 보λ₯Ό μ λ¬νλ λ° νμμ μ λλ€.- ν€λ³΄λ μ κ·Όμ± ν보: ARIA μμ±κ³Ό ν¨κ» ν€λ³΄λ λ€λΉκ²μ΄μ (Tab, Enter, Space, Arrow keys λ±)μ΄ μ¬λ°λ₯΄κ² μλνλλ‘ JavaScript λ‘μ§μ ꡬννμ¬, λ§μ°μ€ μμ΄λ μ»΄ν¬λνΈμ λͺ¨λ κΈ°λ₯μ μ¬μ©ν μ μλλ‘ ν΄μΌ ν©λλ€. λ¨μν ARIAλ§μΌλ‘λ ν€λ³΄λ λμμ μ 곡νμ§ μμΌλ―λ‘, μ΄ λμ νμ ν¨κ» κ³ λ €λμ΄μΌ ν©λλ€.
tabindex
μ μ€ν μ¬μ©: ν¬μ»€μ€ κ°λ₯ν μμμλ§tabindex="0"
μ μ¬μ©νκ³ , μ€ν¬λ¦½νΈλ‘ ν¬μ»€μ€ μ΄λμ μ μ΄νλ κ²½μ°tabindex="-1"
μ μ μ ν νμ©νμ¬ λ Όλ¦¬μ μΈ ν¬μ»€μ€ μμλ₯Ό μ μ§ν©λλ€.
μ΄λ κ² WAI-ARIAλ₯Ό μ μ©νλ©΄, 보쑰 κΈ°μ μ¬μ©μλ μ»΄ν¬λνΈμ ꡬ쑰μ κΈ°λ₯μ λͺ νν μ΄ν΄νκ³ ν¨κ³Όμ μΌλ‘ μνΈμμ©ν μ μκ² λ©λλ€. μ΄λ λ¨μν κΈ°μ μ μΈ μꡬμ¬νμ λμ΄, λͺ¨λ μ¬μ©μλ₯Ό μν ν¬μ©μ μΈ μΉμ λ§λλ ν΅μ¬μ μΈ μ€μ² λ°©λ²μ λλ€.
Before Code (Bad)
// Before: 컀μ€ν
ν μ»΄ν¬λνΈ (ARIA μμ± μμ)
import React, { useState } from 'react';
function CustomTabs() {
const [activeTab, setActiveTab] = useState('tab1');
return (
<div className="tab-container">
<div className="tab-list">
<div
className={`tab-item ${activeTab === 'tab1' ? 'active' : ''}`}
onClick={() => setActiveTab('tab1')}
// ν€λ³΄λ μ΄λ²€νΈ νΈλ€λ§ λ° ARIA μμ± λΆμ¬λ‘ μ κ·Όμ± λ¬Έμ λ°μ
>
ν 1
</div>
<div
className={`tab-item ${activeTab === 'tab2' ? 'active' : ''}`}
onClick={() => setActiveTab('tab2')}
>
ν 2
</div>
</div>
<div className="tab-content">
{activeTab === 'tab1' && (
<div className="panel">
<p>ν 1μ λ΄μ©μ
λλ€.</p>
</div>
)}
{activeTab === 'tab2' && (
<div className="panel">
<p>ν 2μ λ΄μ©μ
λλ€.</p>
</div>
)}
</div>
</div>
);
}
export default CustomTabs;
After Code (Good)
// After: ARIA μμ±κ³Ό ν€λ³΄λ μ κ·Όμ±μ΄ μ μ©λ 컀μ€ν
ν μ»΄ν¬λνΈ
import React, { useState, useRef, useEffect } from 'react';
function AccessibleTabs() {
const [activeTab, setActiveTab] = useState('tab1');
const tabRefs = useRef({});
// ν€λ³΄λ λ€λΉκ²μ΄μ
(μ’μ° νμ΄ν ν€λ‘ ν μ΄λ, Enter/Spaceλ‘ ν μ ν)
const handleKeyDown = (e, tabId) => {
const tabIds = ['tab1', 'tab2'];
const currentIndex = tabIds.indexOf(tabId);
let newIndex = currentIndex;
if (e.key === 'ArrowRight') {
newIndex = (currentIndex + 1) % tabIds.length;
} else if (e.key === 'ArrowLeft') {
newIndex = (currentIndex - 1 + tabIds.length) % tabIds.length;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); // μ€νμ΄μ€λ°μ κΈ°λ³Έ λμ λ°©μ§ (μ€ν¬λ‘€ λ±)
setActiveTab(tabId);
} else if (newIndex !== currentIndex) {
setActiveTab(tabIds[newIndex]);
// μ νμ± νμ ν¬μ»€μ€ μ΄λνμ¬ ν€λ³΄λ μ¬μ©μκ° μΈμ§νλλ‘ ν¨
tabRefs.current[tabIds[newIndex]].focus();
}
};
useEffect(() => {
// νμ± νμ΄ λ³κ²½λ λ ν΄λΉ νμ ν¬μ»€μ€λ₯Ό 보μ₯ (μ ν μ¬νμ΄λ UX κ°μ μ λμ)
if (tabRefs.current[activeTab]) {
tabRefs.current[activeTab].focus();
}
}, [activeTab]);
return (
<div className="tab-container">
{/* role="tablist": ν κ·Έλ£Ήμμ λͺ
μ, aria-label: κ·Έλ£Ήμ λͺ©μ μ μ€ν¬λ¦° 리λμ μλ¦Ό */}
<div role="tablist" aria-label="Example Tabs" className="tab-list">
<div
id="tab1-header" // ν ν¨λκ³Όμ κ΄κ³λ₯Ό μν κ³ μ ID
role="tab" // μμκ° νμμ λͺ
μ
aria-controls="tab1-panel" // μ΄ νμ΄ μ μ΄νλ ν¨λμ IDλ₯Ό λͺ
μ
aria-selected={activeTab === 'tab1'} // νμ μ ν μνλ₯Ό λͺ
μ
tabIndex={activeTab === 'tab1' ? 0 : -1} // νμ± νλ§ ν¬μ»€μ€ κ°λ₯νλλ‘ μ€μ
className={`tab-item ${activeTab === 'tab1' ? 'active' : ''}`}
onClick={() => setActiveTab('tab1')}
onKeyDown={(e) => handleKeyDown(e, 'tab1')}
ref={(el) => (tabRefs.current['tab1'] = el)}
>
ν 1
</div>
<div
id="tab2-header"
role="tab"
aria-controls="tab2-panel"
aria-selected={activeTab === 'tab2'}
tabIndex={activeTab === 'tab2' ? 0 : -1}
className={`tab-item ${activeTab === 'tab2' ? 'active' : ''}`}
onClick={() => setActiveTab('tab2')}
onKeyDown={(e) => handleKeyDown(e, 'tab2')}
ref={(el) => (tabRefs.current['tab2'] = el)}
>
ν 2
</div>
</div>
{activeTab === 'tab1' && (
<div
id="tab1-panel" // ν ν€λμμ κ΄κ³λ₯Ό μν κ³ μ ID
role="tabpanel" // μμκ° ν ν¨λμμ λͺ
μ
aria-labelledby="tab1-header" // μ΄ ν¨λμ΄ μ΄λ€ ν ν€λμ μν΄ λ μ΄λΈλ§λλμ§ λͺ
μ
tabIndex={0} // ν¨λ λ΄μ©μ μ§μ ν¬μ»€μ€ μ§μ
κ°λ₯νλλ‘ (μ ν μ¬ν)
className="panel"
>
<p>ν 1μ λ΄μ©μ
λλ€.</p>
</div>
)}
{activeTab === 'tab2' && (
<div
id="tab2-panel"
role="tabpanel"
aria-labelledby="tab2-header"
tabIndex={0}
className="panel"
>
<p>ν 2μ λ΄μ©μ
λλ€.</p>
</div>
)}
</div>
);
}
export default AccessibleTabs;