"μ€λ¬΄μ λ°λ‘ μ μ©νλ μΉ μ κ·Όμ± κ°μ΄λλΆ" μ± μ μ½κ³ μκ² λ λ΄μ©λ€, λμμ΄ λλ λ΄μ©λ€, μ€λ¬΄μ λ°λ‘ μ μ©ν μ μλ λ΄μ©λ€, λμ€μ μκ° μ λ λ λ³Ό λ΄μ©λ€μ μ 리 ν΄λ³΄λ €κ³ νλ€.
νλ‘ νΈμλ μ±μ© κ³΅κ³ λ€μ μ΄ν΄λ³΄λ©΄ "μ κ·Όμ±"μ΄λΌλ ν€μλκ° λ€μ΄μλ κ²λ€μ μμ£Ό λ΄€μ κ²μ΄λ€. 보ν΅μ μ κ·Όμ±μ λν ν€μλμ ν¬κ² μ€μνλ€ μκ°νμ§ μκ³ , λ€λ₯Έ ν€μλλ€μ λ μ§μ€νλ κ²½ν₯μ΄ μλ€. (μ μ΄λ λλ κ·Έλ¬λ€.)
μ΄ μ±
μ μ½κΈ° μ μ μ κ·Όμ±μ μκ°νλ©΄μ κ°λ°μ μ μ©ν κ²λ€μ meta νκ·Έ μ¬μ©νκΈ°, aria-label
λ¬κΈ°, img νκ·Έμ alt
μ°κΈ° μ κ°μ΄ λ¨μν κ²λ€μ΄μλ€.
μ§κΈμμ μκ°ν΄λ³΄λ©΄ μ κ·Όμ±μ μκ³ κ°λ°μ μ¬μ©ν κ²μ΄ μλλΌ 'κ·Έλ₯' κ°λ° νλ κ² κ°λ€. μ κ·Όμ±μ λν μ 리μ μ€μ μ€ν¬λ¦° 리λκ° μ΄λ»κ² μ½λμ§ μμμ ν¨κ» μ 리νλ €κ³ νλ€.
λ€μν μ 체μ /νκ²½μ 쑰건과 κ΄κ³μμ΄, μΉ μ κ·Όμ±μ λͺ¨λ μ¬μ©μκ° μΉμμ μ 곡νλ μ½ν μΈ (μ 보)μ μ΄λ €μ μμ΄ μ κ·Ό κ°λ₯νλλ‘ ν¨μ μλ―Ένλ€.
μ κ·Όμ±μ μ§μΌμΌ νλ μ΄μ ?
μ κ·Όμ±μ μ§μΌμΌ νλ μ΄μ κ° ν¬κ² μλΏμ§ μμ μ μλλ° μ΄ κΈμ μ½μ΄λ³΄λ©΄ μ½κ² μ΄ν΄ν μ μλ€.
μλ©ν± νκ·Έλ₯Ό μ§ν€λ κ²λ§μΌλ‘λ μ κ·Όμ±μ μ§ν€λλ° λμμ΄ λλ€. λ¨μν λμμΈμμΌλ‘ λμΌνκ² νλ©΄μ ꡬμ±νλ κ²μ΄ μλλΌ "μλ―Έμ λ§λ νκ·Έ"λ₯Ό μ°λ κ²μ κ³ λ―Όν΄μΌ νλ€.
μ΄ κ·Έλ¦Όμ μμ μ μ²μ HTML 곡λΆν λ λ΄μλ μ¬μ§μ΄λ€. μ΄ νκ·Έλ€μ κ°λ°ν λ λͺ¨λ μλ―Έλ‘ μ μΌλ‘ "μ νν" μλ©΄μ μ°λκ°? (μΌλ¨ λλ μλμλ€.)
<article>
vs <section>
article
νκ·Έμ section
νκ·Έλ μ½ν
μΈ μμμ ꡬλΆνλ λ° μ¬μ©λλ€. λ νκ·Έμ κ°μ₯ ν° μ°¨μ΄μ μ article
μ μμ ν λ
립μ μΈ μμμΌλ‘ μ¬μ©λκ³ , seciton
μ μΉνμ΄μ§ λ΄ μ£Όμ μ£Όμ μ μΌλΆ μμμ΄λΌλ μ μ΄λ€.
<!-- article: λ
립μ μΈ κ²μκΈ -->
<article>
<h2>React 19 μΆμ!</h2>
<p>React νμμ React 19μ μ μ λ²μ μ 곡κ°νμ΅λλ€. λ§μ μλ‘μ΄ κΈ°λ₯μ΄ μΆκ°λμμ΅λλ€.</p>
</article>
<!-- section: νμ΄μ§ λ΄ νΉμ μ£Όμ λ₯Ό λ¬Άλ κ΅¬κ° -->
<section>
<h2>νμ κ°μ
λ°©λ²</h2>
<p>μ΄ μλΉμ€μ κ°μ
νλ €λ©΄ μ΄λ¦κ³Ό μ΄λ©μΌμ΄ νμν©λλ€.</p>
</section>
<p>
vs <span>
vs <div>
μ΄ νκ·Έλ€μ λ§€μ° μμ£Ό μ°μ΄λ νκ·Έλ€μΈλ°, κ° νκ·Έλ€μ μ©λλ₯Ό κΈ°μ΅νλ©΄ ꡬλΆμ΄ μ½λ€.
<strong>
vs <b/>
strong
νκ·Έμ b
νκ·Έ λλ€ κ΅΅μ ν
μ€νΈλ‘ νλ©΄μ 그리μ§λ§ λ€λ₯Έ μλ―Έλ‘ μ¬μ©νλ€.
strong
νκ·Έ: νλ©΄μμ μ€μν μλ―Έλ₯Ό κ°μ§λ ν
μ€νΈμ μ¬μ©λλ νκ·Έb
νκ·Έ: λ¨μν λ
μμ μ£Όμλ₯Ό λκΈ° μν ν
μ€νΈμ μ¬μ©λλ©° μ€μλλ₯Ό λΆμ¬νλ κ²μ΄ μλ!<p>
κ²°μ λ <strong>보μ μ°κ²°(HTTPS)</strong>μ ν΅ν΄ μ΄λ£¨μ΄μ ΈμΌ ν©λλ€.
</p>
<!-- b: λ¨μ μκ°μ κ°μ‘° -->
<p>
μ€λμ μΆμ² λ©λ΄: <b>μΉμ¦λ²κ±° μΈνΈ</b>
</p>
<em>
vs <i/>
em, i νκ·Έ λͺ¨λ italic μμ±μ΄ μΆκ°λ ν μ€νΈκ° νμλλ€. μ΄ λμ νκ·Έλ μν μ΄ λ€λ₯΄λ€.
em
: strong νκ·Έμ λΉμ·νκ² μ€μν μλ―Έλ₯Ό κ°μ§λ ν
μ€νΈμ μ¬μ©λλ νκ·Έi
: κ΄μ©μ λλ κΈ°μ μ ννμ ν
μ€νΈλ₯Ό λνλ΄κΈ° μν΄ μ¬μ©λλ νκ·Έ<!-- em: λ¬Έλ§₯μ κ°μ‘° -->
<p>
μ λ <em>μ λ§λ‘</em> νΌμλ₯Ό μ’μν©λλ€.
</p>
<!-- i: κ΄μ©μ /κΈ°μ μ νν -->
<p>
μ΄ν리μμ΄λ‘ "μ¬λ"μ <i>amore</i> μ
λλ€.
</p>
<br>
vs <hr>
brμ Line Breakλ₯Ό μλ―Ένλ©° ν μ€νΈλ₯Ό μ€λ°κΏν λ μ¬μ©λλ€. μ΄λ λ¨μν μ€λ°κΏμλ§ μν₯μ μ£Όλ κ²μ΄ μλλΌ μ€ν¬λ¦° 리λμλ μν₯μ μ€λ€.(μ€ν¬λ¦° 리λκ° brμ λ§λλ©΄ 'μ€λ°κΏ', 'λΉμ΄μμ' μ΄λΌκ³ μλ΄νλ κ²½μ°λ μλ€.) μ€νμΌμ μΈ κ°κ²©μ μ£ΌκΈ° μν μ©λλ‘ μ¬μ©νλ©΄ μ€ν¬λ¦° 리λκ° μλͺ» μ½κ² λλ€.
<p>
μ΄κ²μ μ€λ°κΏ
<br/>
ν
μ€νΈμ΄λ€.
</p>
// π’ μ€ν¬λ¦° 리λ: μ΄κ²μ μ€λ°κΏ (λ©μΆ€) κ·Έλ£Ήμ΄ λΉμ΄ μμ (λ©μΆ€) ν
μ€νΈμ΄λ€.
// => μ€ν¬λ¦° 리λκ° μ€κ°μ νλ² λμ΄μ μ½κ² λ¨
hrμ μ£Όμ κ° μ νλλ μμ μ¬μ΄μ ꡬλΆμ λνλ΄κΈ° μν΄ μ¬μ©λλ€. μ€ν¬λ¦° 리λκ° hr νκ·Έλ₯Ό λ§λκ² λλ©΄ 'μν λΆν μ ', 'ꡬλΆμ 'λ‘ μλ΄νλ κ²½μ°λ μμ΄μ μ€νμΌμ μΌλ‘λ§ μ¬μ©νλ©΄ μλλ€.
<table>
<caption>
μΌλ‘ μμ±νλ€.<thead>
, <tbody>
, <tfoot>
μΌλ‘ ꡬλΆνλ€.<td>
)κ³Ό ꡬλΆλλ ν€λ μ
(<th>
) μ μ¬λ°λ₯΄κ² μ¬μ©ν΄μΌ νλ€.<th>
μλ scope="col"
(μ΄ ν€λ), scope="row"
(ν ν€λ)λ₯Ό μ§μ νλ€.<table>
μ μ¬μ©νμ§ μλλ€. (CSS Flex/Grid νμ© κΆμ₯)<table>
μ λ°μ΄ν° νλ‘ μΈμνκΈ° λλ¬Έμ μλ―Έ μλ μ¬μ©μ μ€νλ € μ κ·Όμ±μ ν΄μΉλ€.<table>
<caption>2025λ
9μ μ¬λ΄ κ΅μ‘ μΌμ </caption>
<thead>
<tr>
<th scope="col">λ μ§</th>
<th scope="col">μ£Όμ </th>
<th scope="col">κ°μ¬</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">9μ 3μΌ</th>
<td>React κ³ κΈ ν¨ν΄</td>
<td>κΉκ°λ°</td>
</tr>
<tr>
<th scope="row">9μ 10μΌ</th>
<td>TypeScript μ€λ¬΄</td>
<td>μ΄νλ‘ νΈ</td>
</tr>
</tbody>
</table>
<button>
button
νκ·Έλ κΈ°λ³Έμ μΌλ‘ Space
, Enter
λ‘ νμ±νκ° κ°λ₯νλ€. button μλ type μμ±μ μ§μ ν μ μλλ° κΈ°λ³Έμ μΌλ‘ 'submit'μ΄ μ§μ λλ€. (μ΄λ κΈ° λλ¬Έμ λ²νΌ μ¬μ©μ type="button"μ΄λΌκ³ λͺ
μνλ κ²)
type="submit"
: μ¬μ©μλ‘λΆν° μ
λ ₯λ°μ λ°μ΄ν°λ₯Ό μ μΆν μ μλ κΈ°λ₯type="reset"
: μ¬μ©μλ‘λΆν° μ
λ ₯λ°μ λ°μ΄ν°λ₯Ό μ΄κΈ°ν νλ κΈ°λ₯type="button"
: μΌλ°μ μΈ λ²νΌ κΈ°λ₯μλ§¨ν± νκ·Έλ€λ‘λ§ λͺ¨λ κ²μ μ μνκΈ°μλ μΉμ΄ μ μ 볡μ‘ν΄μ‘κ³ , μ΄λ₯Ό 보μνκΈ° μν΄μ μμ± κ°μΌλ‘ μνλ₯Ό λνλ΄λκ² νμνλ€. κ·Έκ²μ΄ λ°λ‘ W3Cμμ μμ±ν κΈ°μ λ¬ΈμμΈ, WAI-ARIA(web accessibility initiavtie - accessible rich internet application)μ΄λ€.
κ° νκ·Έλ€μκ² μν μ λ§λ μνλ μμ±μ μ§μ νλ©΄, μΉλΈλΌμ°μ λ μ κ·Όμ± νΈλ¦¬λ‘ λ³ννκ² λλ€. μμ±λ€μ λΆμ¬ν κ²½μ° μ€μ λμμλ μλ¬΄λ° μν₯μ μ£Όμ§ μκΈ° λλ¬Έμ μ΄λ₯Ό μ μν΄μΌ νλ€.
κ°λ°μ λꡬμμ μ κ·Όμ± νΈλ¦¬λ₯Ό νμΈν μ μλ€.
π€: aria-xx λν΄μ 곡λΆνκ³ μΆμλλ°, κΉλνκ² μ 리λ λ¬Έμλ₯Ό λͺ» μ°Ύμμλλ° μ± μ μ½μΌλ©΄μ λ§μ΄ μκ² λ κ² κ°λ€.
β λμ μμ (λΆνμν ARIA)
<!-- role="button" μΌλ‘ spanμ λ²νΌμ²λΌ μ°λ κ²½μ° -->
<span role="button" aria-pressed="false">ν΄λ¦</span>
β
μ’μ μμ (κΈ°λ³Έ HTML μμ μ¬μ©)
<!-- κΈ°λ³Έ button μμ μ¬μ© -->
<button>ν΄λ¦</button>
β λμ μμ
<!-- h1μ role="presentation" μΌλ‘ μλ―Έ μ κ±° -->
<h1 role="presentation">곡μ§μ¬ν</h1>
β
μ’μ μμ
<h1>곡μ§μ¬ν</h1>
β λμ μμ (ν€λ³΄λ μ κ·Ό λΆκ°)
<div role="button">μ μΆ</div>
β
μ’μ μμ (ν€λ³΄λ μ΄λ²€νΈ μΆκ°)
// λ²νΌμ spaceμ Enterν€λ‘ λμμ΄ κ°λ₯ν΄μΌ νλ€.
<div
role="button"
tabindex="0"
onclick="alert('μ μΆλ¨')"
onkeydown="if(event.key === 'Enter' || event.key === ' ' || event.key === 'Space') alert('μ μΆλ¨')"
>
μ μΆ
</div>
β
νμ§λ§ λ μ’μ λ°©λ²μ κ·Έλ₯ buttonμ μ¬μ©νλ κ²!
<button onclick="alert('μ μΆλ¨')">μ μΆ</button>
role=presentation
λλ aria-hidden="true"
μμ±μ μ¬μ©νμ§ μλλ€.β λμ μμ
<a href="/home" aria-hidden="true">νμΌλ‘</a>
β μκ°μ μΌλ‘λ 보μ΄λλ° μ€ν¬λ¦°λ¦¬λμμ μ½νμ§ μμ μ κ·Όμ± λ¬Έμ λ°μ
β
μ’μ μμ
<a href="/home">νμΌλ‘</a>
β λμ μμ (λ μ΄λΈ μμ)
<button></button>
β
μ’μ μμ (ν
μ€νΈ μ 곡)
<button>κ²μ</button>
β
μμ΄μ½ λ²νΌμΈ κ²½μ° aria-label μ 곡
<button aria-label="κ²μ">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
<circle cx="6" cy="6" r="5" stroke="black" fill="none"/>
<line x1="10" y1="10" x2="15" y2="15" stroke="black"/>
</svg>
</button>
λνν μμμ μ€ν¬λ¦° 리λκ° μ½λ μμλ "μΈν°λν°λΈ μμμ μ΄λ¦ λΆμ΄κΈ°" κΈμ μΆμ²νλ€.
νμ±ν λ νμ νλͺ©μ 보쑰 κΈ°μ (μ€ν¬λ¦° 리λ)μ ν΅ν΄ μ λ¬νκΈ° μν΄ μ¬μ©λλ€.
β
μ’μ μμ
<div role="listbox" tabindex="0" aria-activedescendant="item-2">
<div id="item-1" role="option">μ΅μ
1</div>
<div id="item-2" role="option" aria-selected="true">μ΅μ
2</div>
<div id="item-3" role="option">μ΅μ
3</div>
</div>
β λμ μμ
<div role="listbox" tabindex="0" aria-activedescendant="item-99">
<!-- id="item-99" μμκ° μ‘΄μ¬νμ§ μμ -->
</div>
aria-live μμμ λ΄μ©μ΄ λ³κ²½λ λ μ€ν¬λ¦° 리λκ° μ½μ΄μ€ μμμ λ²μλ₯Ό κ²°μ νκΈ° μν΄ μ¬μ©λλ€.
<div aria-live="polite" aria-atomic="true">
<span>νμ¬ μν: </span><span>λκΈ° μ€</span>
</div>
μ¬μ©μ μ λ ₯κ°μ λ°λΌ μλ μμ± μ§μ μ¬λΆμ μλ μμ±λλ λ¨μ΄λ€μ μ΄λ€ ννλ‘ μ 곡ν μ§λ₯Ό λνλ΄κΈ° μν΄ μ¬μ©λλ€.
<input type="text" role="combobox" aria-autocomplete="list" aria-owns="suggestions" />
<ul id="suggestions" role="listbox">
<li role="option">μ¬κ³Ό</li>
<li role="option">λ°λλ</li>
</ul>
νμ¬ μμμ '체ν¬λ μν'λ₯Ό λνλ΄κΈ° μν΄ μ¬μ©λλ€. (checkbox, radio λ±μ μμμ μμ£Ό μ¬μ©λλ€.)
<div role="checkbox" aria-checked="true" tabindex="0">λμν©λλ€</div>
checkbox
, radio
μμλ₯Ό μ¬μ©νλ κ² μ’λ€.νμ¬ μμμ μν΄ μ μ΄λλ μμλ₯Ό μλ³νκΈ° μν΄ μ¬μ©λλ€. νΉμ μμμ μ μ΄λλ μμλ₯Ό μλ³νμ¬ μμ κ° κ΄κ³λ₯Ό λνλΈλ€.
β οΈ λλΆλΆμ μ€ν¬λ¦° 리λμμ μ΄ μμ±μ μ§μνμ§ μμ λ Όμμ€μΌλ‘ 보μΈλ€.
<button aria-controls="menu" aria-expanded="false">λ©λ΄ μ΄κΈ°</button>
<ul id="menu" hidden>
<li>νλͺ©1</li>
<li>νλͺ©2</li>
</ul>
μ°κ΄λ μμ κ·Έλ£Ή λ΄μμ νμ¬ νλͺ©μ ν΄λΉνλ μμλ₯Ό λνλ΄κΈ° μν΄ μ¬μ©λλ€. κ°μ κ·Έλ£Ή λ΄ βνλμ μμβμλ§ aria-current="true"κ° μμ΄μΌ νλ€.
page
: λ€λΉκ²μ΄μ
λ§ν¬ λ©λ΄μμ νμ¬ νμ΄μ§λ₯Ό λνλΌ λ μ¬μ©νλ€.step
: λ¨κ³κ° μλ UIμμ νμ¬ λ¨κ³λ₯Ό λνλΌ λ μ¬μ©νλ€.location
: κ³μΈ΅ ꡬ쑰μ UIμμ νμ¬ μμΉλ₯Ό λνλΌ λ μ¬μ©νλ€.date
: μ¬λ¬ λ μ§ μ€μμ νμ¬ λ μ§λ₯Ό λνλΌ λ μ¬μ©νλ€.time
: μ¬λ¬ μκ° μ€μμ νμ¬ μκ°μ λνλΌ λ μ¬μ©νλ€.true|false
: λ€λ₯Έ κ°λ€λ‘ νΉμ ν μ μλ νμ¬ νλͺ©μ λνλΌ λ μ¬μ© | νμ¬ νλͺ©μ λνλ΄μ§ μλλ€.<nav>
<a href="/home">ν</a>
<a href="/about" aria-current="page">μκ°</a>
<a href="/contact">λ¬Έμ</a>
</nav>
νμ¬ μμμ λν μμΈν μ€λͺ μ λ€λ₯Έ μμλ₯Ό ν΅ν΄ μ 곡νκΈ° μν΄ μ¬μ©λλ€.
display:none
, aria-hidden="true"
μ κ°μ΄ μ€ν¬λ¦° 리λκ° μΈμ§ν μ μμΌλ©΄ μ€λͺ
μ μ 곡νμ§ λͺ»νλ€.<button aria-describedby="tip1">μμ </button>
<p id="tip1" class="sr-only">μ΄ λ²νΌμ μ νν νλͺ©μ μμ ν©λλ€.</p>
νμ¬ μμκ° μ κ·Ό κ°λ₯νμ§λ§ μλ―Έμ μΌλ‘ λΉνμ±νλ μνμμ μ리기 μν΄ μ¬μ©νλ€.
μ¬κΈ°μ μ€μν μ μ μμκ° μ κ·Ό κ°λ₯νκΈ° λλ¬Έμ μκ°μ μΌλ‘ 보μ΄κ³ , λͺ¨λ κΈ°λ₯μ΄ μ μμ μΌλ‘ λμνλ€. κ·Έλ¬λ―λ‘ μ€μ κΈ°λ₯μ λκΈ° μν΄μλ disabled
μμ±μ μ¬μ©ν΄μΌ νλ€.
<!-- μ μ₯ λ²νΌμ μμ§ λμνμ§ μμ§λ§ TabμΌλ‘ νμ κ°λ₯ -->
<button aria-disabled="true" tabindex="0" onclick="alert('μ€νλμ§ μμ')">
μ μ₯
</button>
<!-- ꡬ맀νκΈ° λ²νΌμ μμ§ λ§μλμ§λ§, ν΄λ¦νλ©΄ μλ΄ λ©μμ§ νμ -->
<button aria-disabled="true" onclick="alert('ꡬ맀λ λ‘κ·ΈμΈ ν κ°λ₯ν©λλ€.')">
ꡬ맀νκΈ°
</button>
<!-- role="button" μΌλ‘ λνν μμλ₯Ό λ§λ€κ³ , aria-disabledλ‘ λΉνμ±ν νμ -->
<div role="button" aria-disabled="true" tabindex="0" onclick="alert('μ€νλμ§ μμ')">
컀μ€ν
λ²νΌ
</div>
νμ¬ μμμ νμ₯/μΆμ μ¬λΆλ₯Ό λνλ΄κΈ° μν΄ μ¬μ©νλ€. μ΄λ μ€μν μ μ μ΄λ―Έ ν μ€νΈλ‘ 'νμ₯/μΆμ μ¬λΆ'λ₯Ό λνλ΄κ³ μλ€λ©΄ μ€λ³΅μΌλ‘ μμ±μ λΆμ¬νμ§ μμλ λλ€.
<button type="button" aria-expanded={isExpanded} aria-controls="detail">
μμΈ λ³΄κΈ°
// νλ©΄μμλ νκΈ°νμ§ μμ
<span className="visually-hidden">{isExpanded ? "νμ₯λ¨" : "μΆμλ¨"}</span>
</button>
<div id="detail" hidden>μμΈ λ΄μ©</div>
// isExpanded="true"μΈ κ²½μ° π’ μ€ν¬λ¦° 리λ: "μμΈλ³΄κΈ°: νμ₯λ¨,νμ₯λ¨, λ²νΌ"
// isExpanded="false"μΈ κ²½μ° π’ μ€ν¬λ¦° 리λ: "μμΈλ³΄κΈ°: μΆμλ¨,μΆμλ¨, λ²νΌ"
μ΄λ κ² μ€ν¬λ¦° 리λμμ λλ² λ°λ³΅λμ΄ λμ€κΈ° λλ¬Έμ λ³λλ‘ ν μ€νΈλ₯Ό λ£μ νμκ° μλ€.
λ λ€λ₯Έ μμλ‘λ
<ul>
<li>μ§λ¬Έ</li>
...
{isExpanded && <li></li>}
</ul>
<button type="button" onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? "μ κΈ°" : "λ보기"}
</button>
μ΄λ―Έ λ²νΌμ ν μ€νΈλ‘ "μ κΈ°/λ보기"λ₯Ό μ 곡ν΄μ£ΌκΈ° λλ¬Έμ aria μμ±μ λ£μ νμκ° μλ€.
νμ¬ μμμ μν΄ μ΄λ€ μ νμ λνν νμ
μμκ° λ
ΈμΆλ κ²μΈμ§λ₯Ό λνλ΄κΈ° μν΄ μ¬μ©λλ€.
μ€ν¬λ¦° 리λ μ¬μ©μμκ² μ΄λ€ νμ
μμκ° λ
ΈμΆλ κ²μΈμ§ 미리 μλ €μ£Όλ μν μ νλ κ²μ΄λ€.
-menu
, listbox
, tree
, grid
, dialog
, true
|false
μ κ°μ μμ±λ€μ΄ μλ€.
μ€ν¬λ¦° 리λλ₯Ό μ¬μ©μ€μΈ μ¬μ©μλ€μκ² νμ¬ μμλ₯Ό μ¨κΈ°κΈ° μν΄ μ¬μ©νλ€.
aria-hidden="true"
λ‘ λμ΄ μμ κ²½μ° μμ μμλ λͺ¨λ μ½μ μ μλ€.μμ μμμ aria-hiddenμ λ£μ΄λ μ΄μ κ°λ₯ν μμκ° μμμ μλ€λ©΄ Tabν€λ‘ μ κ·Όμ΄ κ°λ₯νλ€.
β λμ μμ
// λ²νΌ νκ·Έλ focusκ° κ°λ₯
<div aria-hidden="true">
<button type="button">...</button>
</div>
// tabIndexκ° 0μ΄λ©΄ focusκ° κ°λ₯
<div aria-hidden="true">
<a tabIndex={0}>...</a>
</div>
κ·Έλ¬λ―λ‘ νμ μμμ μ΄μ κ°λ₯ν μμκ° μλ€λ©΄ μ κ±°νκ±°λ μ΄μ κ°λ₯ν μμλ₯Ό μ΄μ λΆκ°νκ² λ§λ€μ΄μΌ νλ€.
// μ΄μ μ΄ λΆκ°νλλ‘ -1λ‘ μ§μ
<div aria-hidden="true">
<a tabIndex={-1}>...</a>
</div>
// cssλ‘ μ¨κΈ°κΈ°
<div aria-hidden="true">
<button type="button" style={{ display: "none" }}>...</button>
</div>
// disabled μμ± μ§μ
<div aria-hidden="true">
<button type="button" disabled>...</button>
</div>
νμ¬ μμμ μ΄λ¦μ λ¬Έμμ΄ ννλ‘ μ 곡νμ¬ μ€ν¬λ¦° 리λλ₯Ό ν΅ν΄ λͺ©μ μ μΈμν μ μλλ‘ νκΈ° μν΄ μ¬μ©νλ€.
aria-lablledby
μμ±μ μ¬μ©νλ©΄ λλ€.aria-label
, aria-labelledby
λ₯Ό λμμ μ¬μ©νλ©΄ labelledbyκ° μ°μ μΌλ‘ μ μ©λλ€.
// μ€ν¬λ¦° 리λ: "μ΄κΈ°, λ²νΌ"
<button type="button" aria-label="λ«κΈ°" aria-labelledby="button-label">
<img src="close.png" alt="" />
</button>
<span id="button-label">μ΄κΈ°</span>
νμ¬ μμμ λ μ΄λΈμ λ€λ₯Έ μμλ₯Ό ν΅ν΄ μ μνκΈ° μν΄ μ¬μ©νλ€.
// μ€ν¬λ¦° 리λ: "κ²°μ λΉλ°λ²νΈ μ«μ 6μ리, λΉλ°λ²νΈ μ
λ ₯μ°½"
<span id="payment-label">κ²°μ λΉλ°λ²νΈ</span>
<span id="payment-hint">(μ«μ 6μ리)</span>
<input type="password" aria-labelledby="payment-label payment-hint" />
κ³μΈ΅ ꡬ쑰 λ΄μμ νμ¬ μμμ κ³μΈ΅μ μμ€μ λνλ΄κΈ° μν΄ μ¬μ©νλ€.
νΉμ μμμ λ΄μ©μ΄ λμ μΌλ‘ λ³κ²½λ λ, μ€ν¬λ¦° 리λ μ¬μ©μμκ² μ리기 μν΄ μ¬μ©λλ€.
νμ¬ μμμ λͺ¨λ¬ μ¬λΆλ₯Ό λνλ΄κΈ° μν΄ μ¬μ©νλ€.
<div role="dialog" aria-modal="true">
<h2>λ‘κ·ΈμΈ</h2>
<form>...</form>
</div>
νμ¬ νλͺ©μ μ ν κ°λ₯ν νμ νλͺ©μ 2κ° μ΄μ λμμ μ νν μ μλμ§λ₯Ό λνλ΄κΈ° μν΄ μ¬μ©νλ€.
<ul role="listbox" aria-multiselectable="true">
<li role="option" aria-selected="true">μ¬κ³Ό</li>
<li role="option" aria-selected="true">λ°λλ</li>
<li role="option" aria-selected="false">체리</li>
</ul>
νμ¬ μμμ λ°©ν₯μ΄ κ°λ‘μΈμ§ μΈλ‘μΈμ§λ₯Ό λνλ΄λ λ° μ¬μ©νλ€.
<!-- μΈλ‘ ν UI -->
<div role="tablist" aria-orientation="vertical">
<button role="tab" aria-selected="true">ν 1</button>
<button role="tab" aria-selected="false">ν 2</button>
<button role="tab" aria-selected="false">ν 3</button>
</div>
λ²νΌμ ν κΈ λ²νΌ μν λ‘ λ³κ²½μν€κ³ , ν κΈ λ²νΌμ΄ νμ¬ λλ¦° μνμΈμ§ λνλ΄κΈ° μν΄ μ¬μ©νλ€.
export const PlayPauseButton = () => {
const [playing, setPlaying] = useState(false);
const handleClick = () => {
setPlaying(prev => !prev);
};
return (
<button
onClick={handleClick}
aria-pressed={playing} // νμ¬ μνλ₯Ό μ€ν¬λ¦° 리λμ μ λ¬
aria-label={playing ? "μΌμμ μ§" : "μ¬μ"} // λ μ΄λΈλ‘ μν μ 곡
>
{playing ? "βΈοΈ μΌμμ μ§" : "βΆοΈ μ¬μ"}
</button>
);
};
// μ¬μ μ π’ μ€ν¬λ¦° 리λ: "μ¬μ, λ²νΌ, λλ¦¬μ§ μμ"
// μ¬μ μ€ π’ μ€ν¬λ¦° 리λ: "μΌμμ μ§, λ²νΌ, λλ¦Ό"
λ μ΄λΈκ³Ό μν λ³νλ₯Ό λμμ μ£Όλ©΄ μ€ν¬λ¦° 리λκ° μ½μ λ΄μ©μ΄ ν·κ°λ¦΄ μ μλ€.
μ¦, labelμ κ³ μ ν΄μ μ€ν¬λ¦° 리λκ° μ½μλ λ΄μ©μ΄ ν·κ°λ¦¬μ§ μκ² ν΄μΌ νλ€.
export const PlayPauseButton = () => {
const [playing, setPlaying] = useState(false);
const handleClick = () => {
setPlaying(prev => !prev);
};
return (
<button
onClick={handleClick}
aria-pressed={playing} // μνλ aria-pressedλ‘λ§ μ λ¬
aria-label="μ¬μ/μΌμμ μ§" // λ μ΄λΈμ κ³ μ
>
{/* μκ°μ νμ: μ ν μ¬ν */}
{playing ? "βΈοΈ" : "βΆοΈ"}
</button>
);
};
// μ¬μ μ π’ μ€ν¬λ¦° 리λ: "μ¬μ/μΌμμ μ§, λ²νΌ, λλ¦¬μ§ μμ"
// μ¬μ μ€ π’ μ€ν¬λ¦° 리λ: "μ¬μ/μΌμμ μ§, λ²νΌ, λλ¦Ό"
νμ¬ μμκ° μ½κΈ° μ μ© μνμ(νΈμ§ λΆκ°λ₯)μ λνλ΄κΈ° μν΄ μ¬μ©νλ€.
λ€μκ³Ό κ°μ΄ div νκ·Έλ₯Ό inputμ²λΌ μ¬μ©νκ³ readonlyλ‘ μ°κ³ μΆμλλ μλ μ½λμ κ°μ΄ μ¨μΌ νλ€.
<div role="textbox" aria-readonly="true" contenteditable="true" id="readonly-box">
μ½κΈ° μ μ© κ°
</div>
<script>
const readonlyBox = document.getElementById("readonly-box");
// ν€ μ
λ ₯ μ°¨λ¨
readonlyBox.addEventListener("keydown", (e) => {
e.preventDefault();
});
// λ§μ°μ€λ‘ λΆμ¬λ£κΈ° μ°¨λ¨
readonlyBox.addEventListener("paste", (e) => {
e.preventDefault();
});
</script>
μμ μ μΆ μ μ μ¬μ©μ μ λ ₯μ΄ νμν μμμμ λνλ΄κΈ° μν΄ μ¬μ©νλ€.
<form id="signup-form">
<label for="email">μ΄λ©μΌ</label>
<input type="email" id="email" aria-required="true" />
<button type="submit">κ°μ
</button>
</form>
<script>
document.getElementById("signup-form").addEventListener("submit", (e) => {
const email = document.getElementById("email");
if (!email.value.trim()) {
e.preventDefault();
alert("μ΄λ©μΌμ νμ μ
λ ₯ νλͺ©μ
λλ€.");
email.focus();
}
});
</script>
(μ΄λ κ² ν κ±°λ©΄ required μμ±μ μ°λκ² λ μ’λ€.)
νμ¬ μμκ° μ νλμλμ§ μ¬λΆλ₯Ό λνλ΄κΈ° μν΄ μ¬μ©νλ€.
λ²μλ₯Ό λνλ΄λ νμ¬ μμμμ νμ©λλ μ΅λκ°/μ΅μκ°/νμ¬κ°μ λνλ΄κΈ° μν΄ μ¬μ©νλ€.
λ²μλ₯Ό λνλ΄λ νμ¬ μμμμ νμ¬ κ°μ μνλ ν μ€νΈ νμμΌλ‘ λνλ΄κΈ° μν΄ μ¬μ©νλ€.
<div role="slider"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="30"
aria-valuetext="30% μλ£">
</div>
μ΄λ κ² μ 리νκ³ λ³΄λ©΄ HTMLμμ μ 곡ν΄μ£Όλ κΈ°λ³Έμ μΈ νκ·Έλ€μ΄ μλ€λ©΄ μ΄λ₯Ό νμ©νλκ² κ°μ₯ μ’κ³ , μ€ν¬λ¦° 리λκ° μ½νμ λλ₯Ό κ³ λ €νμ¬ aria-xx μμ±μ λΆμ¬νλκ² κ°μ₯ μ€μνλ€.
μ± μμλ Accordion, Carousel, Dialog λ± νλ‘ νΈμλ κ°λ°μ νλ€λ³΄λ©΄ μμ£Ό λ§μ£ΌμΉκ² λλ UIμ λν΄μ μ κ·Όμ±μ μ§ν€λ©΄μ κ°λ°νλ λ°©λ²λ€μ μλ €μ€λ€.
μ΄ μ€μ κ°μ₯ μμ£Ό μ°μ΄μ§λ§ κ·Έλ§νΌ ꡬν λ°©μμ΄ λ무λλ λ€μν Dialog μ»΄ν¬λνΈλ₯Ό λ―μ΄λ³΄κ³ , Radix/uiμ Dialog μ»΄ν¬λνΈλ₯Ό λ―μ΄λ³΄λ €κ³ νλ€.
role
& aria-modal
)aria-haspopup
)aria-labelledby
, aria-describedby
)μμ κ·μΉλ€μ νλμ© μ§ν€λ©΄μ ꡬνν΄λ³΄μ.
dialog μν μ μΈμ§ν μ μλλ‘ λͺ μ
aria-haspopup
μ μ¬μ©νμ¬ dialogκ° μ΄λ¦΄ κ²μ΄λΌλ κ²μ νκΈ°νλ€.role="dialog"
,aria-modal="true"
λ₯Ό ν΅ν΄ dialog ννμΈ κ²μ λͺ
μνλ€. (μ΄λ aria-modal
λ λͺ¨λ¬ννμΈ κ²μ μλ―Ένλ€.)function Dialog() {
const dialogRef = useRef(null);
const contentRef = useRef(null);
const [showDialog, setShowDialog] = useState(false);
return (
<>
{/* Dialogλ₯Ό νλ©΄μ λνλκ² νλ νΈλ¦¬κ±° μμ */}
<button
type="button"
// νμ¬ μ΄λ¦¬κ² λ κ²μ΄ dialogμΈ κ²μ λͺ
μνλ€.
aria-haspopup="dialog"
onClick={() => setShowDialog(true)}
className={cx("button")}
>
Dialog λ
ΈμΆ
</button>
{showDialog && (
<div
ref={dialogRef}
// dialog μν κ³Ό modalμμ λͺ
μνλ€.
role="dialog"
aria-modal="true"
className={cx("dialog")}
>
<div ref={contentRef} tabIndex={-1} className={cx("content")}>
<button
type="button"
aria-label="λ«κΈ°"
onClick={() => setShowDialog(false)}
className={cx("button-close")}
/>
<h2 className={cx("title")}>
λ€μ΄μΌλ‘κ·Έ νμ΄ν
</h2>
<p className={cx("desc")}>
λ€μ΄μΌλ‘κ·Έ μ€λͺ
</p>
<button
type="button"
onClick={() => setShowDialog(false)}
className={cx("button-confirm")}
>
νμΈ
</button>
</div>
</div>
)}
</>
);
}
λ€μ΄μΌλ‘κ·Έλ€μ κ° μμμ μ€λͺ
μ λνλΈλ€.
aria-labelledby
,aria-describedby
λ₯Ό μ¬μ©νμ¬ μ λͺ©κ³Ό μ€λͺ
μ idκ°μ μ°Έμ‘°νλ€.
function Dialog() {
const dialogRef = useRef(null);
const contentRef = useRef(null);
const [showDialog, setShowDialog] = useState(false);
return (
<>
{/* Dialogλ₯Ό νλ©΄μ λνλκ² νλ νΈλ¦¬κ±° μμ */}
<button
type="button"
aria-haspopup="dialog"
onClick={() => setShowDialog(true)}
className={cx("button")}
>
Dialog λ
ΈμΆ
</button>
{showDialog && (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
// λͺ¨λ¬ μμμ μ€λͺ
μ μν΄ idλ₯Ό μΆκ°νλ€.
aria-labelledby="dialog-title-id"
aria-describedby="dialog-description-id"
className={cx("dialog")}
>
<div ref={contentRef} tabIndex={-1} className={cx("content")}>
<button
type="button"
aria-label="λ«κΈ°"
onClick={() => setShowDialog(false)}
className={cx("button-close")}
/>
// idλ₯Ό λ§€ννλ€.
<h2 id="dialog-title-id" className={cx("title")}>
λ€μ΄μΌλ‘κ·Έ νμ΄ν
</h2>
<p id="dialog-description-id" className={cx("desc")}>
λ€μ΄μΌλ‘κ·Έ μ€λͺ
</p>
<button
type="button"
onClick={() => setShowDialog(false)}
className={cx("button-confirm")}
>
νμΈ
</button>
</div>
</div>
)}
</>
);
}
dialog λ΄λΆμμ μ΄μ μ΄ μ΄λλμ΄μΌ νλ€.
const INTERACTIVE_ELEMENTS =
"a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled])";
function Dialog() {
...
useEffect(() => {
if (!showDialog) {
return;
}
const dialogContent = contentRef.current;
/* μ΄λ¦΄ λ νμ±νλ μμ μ μ₯ */
const prevFocusRef = document.activeElement;
/* μ΄λ¦΄ λ λ΄λΆλ‘ μ΄μ μ΄λ */
dialogContent.focus();
/* Dialog λ΄ μ΄μ κ°λ₯ν μμλ€ */
const focusableElements =
dialogContent.querySelectorAll(INTERACTIVE_ELEMENTS);
const firstFocusableElement = focusableElements[0];
const lastFocusableElement =
focusableElements[focusableElements.length - 1];
/* λ΄λΆ μ΄μ μν */
const focusTrap = (event) => {
const currentFocusElement = document.activeElement;
const isFirstFocusableElementActive =
currentFocusElement === firstFocusableElement;
const isLastFocusableElementActive =
currentFocusElement === lastFocusableElement;
if (event.code === "Tab") {
/* 첫λ²μ§Έ μμμμ 'Shift + Tab'ν€ λμ μ λ§μ§λ§ μμλ‘ μ΄μ μ΄λ */
if (event.shiftKey && isFirstFocusableElementActive) {
event.preventDefault();
lastFocusableElement.focus();
}
/* λ§μ§λ§ μμμμ 'Tab'ν€ λμ μ 첫λ²μ§Έ μμλ‘ μ΄μ μ΄λ */
if (isLastFocusableElementActive) {
event.preventDefault();
firstFocusableElement.focus();
}
}
};
dialogContent.addEventListener("keydown", focusTrap);
return () => {
/* λ«ν λ μ΄μ λ³΅κ· */
prevFocusRef.focus();
dialogContent.removeEventListener("keydown", focusTrap);
};
}, [showDialog]);
return (
<>...</>
);
}
λ‘μ§μ μ΄ν΄λ³΄λ©΄ μ΄μ μ΄ κ°λ₯ν μμλ€μμ 첫λ²μ§Έ/λ§μ§λ§ μμλ₯Ό μ°Ύμ λ΄λΆμμλ§ tabν€λ‘ focus λλλ‘ κ°μ νκ³ μλ€. κ·Έλ¦¬κ³ λ«νλ©΄ μ΄μ μ focusλμ΄ μλ μμλ‘ λ€μ focus μν€κ² λλ€.
μ΄μΈμλ μΆκ°μ μΌλ‘ λ€μ΄μΌλ‘κ·Έκ° μ΄λ¦΄ λ μΈλΆ μμλ€μλ aria-hidden="true"
λ₯Ό μ μ©ν΄μΌ μ€ν¬λ¦° 리λκ° λ€μ΄μΌλ‘κ·Έλ₯Ό μ λλ‘ μΆμ ν μ μλ€.
const INTERACTIVE_ELEMENTS =
"a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled])";
function Dialog() {
...
useEffect(() => {
if (!showDialog) {
return;
}
// ...
/* Dialog νμ μμλ€ */
const siblingNodes = dialogRef.current.parentNode.childNodes;
Array.from(siblingNodes).forEach((child) => {
if (child !== dialogRef.current) {
child.setAttribute("aria-hidden", "true");
}
});
//...
dialogContent.addEventListener("keydown", focusTrap);
return () => {
/* λ«ν λ μ΄μ λ³΅κ· */
prevFocusRef.focus();
dialogContent.removeEventListener("keydown", focusTrap);
};
}, [showDialog]);
return (
<>...</>
);
}
λͺ¨λ νμ λ€μ μ°Ύμμ aria-hidden="true"
μμ±μ λΆμ¬νλ μ½λκ° μΆκ°λμλ€.
λͺ¨λ μ½λλ μ¬κΈ°μ νμΈμ΄ κ°λ₯νλ€!
μ€ν 리λΆλ μ 곡νλλ°, μ¬κΈ°μ νμΈμ΄ κ°λ₯νλ€.
λμμΈ λΌμ΄λΈλ¬λ¦¬λ€λ μ κ·Όμ±μ μ΄μ¬ν μ§ν€κ³ μλ νλ² μ΄ν΄λ³΄μ
Shadcn/ui
shadcn/uiλ Radix UIλ₯Ό κΈ°λ°μΌλ‘ λ§λ€μ΄μ‘κ³ , λμμΈκ³Ό μ€νμΌμ μΆκ°ν μ»΄ν¬λνΈ λͺ¨μμ΄λ€. (Radix UIλ μ»΄ν¬λνΈμ κΈ°λ₯λ§μ λ΄μ μ 곡νκ³ , λμμΈμ μΈ μμλ λͺ¨λ λΉ μ§ ν€λ리μ UI μ»΄ν¬λνΈ λΌμ΄λΈλ¬λ¦¬μ΄λ€.)λ€μ΄μΌλ‘κ·Έ μ»΄ν¬λνΈ λ¬Έμ:
https://ui.shadcn.com/docs/components/dialog
μ€μ λ‘ shadcn-ui μ½λμλ radix-uiλ₯Ό μν¬νΈν΄ λ ννκ³ μλ€.
=> https://github.com/shadcn-ui/ui/blob/main/apps/www/registry/default/ui/dialog.tsx
μμμ μ§μ ꡬνν μ»΄ν¬λνΈλ₯Ό λμμΈ λΌμ΄λΈλ¬λ¦¬μμλ μΆμννμ¬ νμ΄λκ° κ²λ€μ΄ λͺκ° μλ€.
μμμλ λ΄λΆ μ΄μ μ΄λμ λ°λ³΅ μν€κΈ° μν΄ focus λλ μμλ€μ μ°Ύμ loop
μμΌ°μλ€.
κ·Έλ¬λ μ¬κΈ°μλ FocusScope
λΌλ μΆμνλ μ»΄ν¬λνΈλ₯Ό μ¬μ©νμ¬ μ μΈμ μΌλ‘ μ¬μ©μ€μ΄λ€.
// https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/dialog.tsx#L384C1-L424C3
const DialogContentImpl = React.forwardRef<DialogContentImplElement, DialogContentImplProps>(
(props: ScopedProps<DialogContentImplProps>, forwardedRef) => {
const { __scopeDialog, trapFocus, onOpenAutoFocus, onCloseAutoFocus, ...contentProps } = props;
const context = useDialogContext(CONTENT_NAME, __scopeDialog);
const contentRef = React.useRef<HTMLDivElement>(null);
const composedRefs = useComposedRefs(forwardedRef, contentRef);
// Make sure the whole tree has focus guards as our `Dialog` will be
// the last element in the DOM (because of the `Portal`)
useFocusGuards();
return (
<>
<FocusScope
asChild
loop
trapped={trapFocus}
onMountAutoFocus={onOpenAutoFocus}
onUnmountAutoFocus={onCloseAutoFocus}
>
<DismissableLayer
role="dialog"
id={context.contentId}
aria-describedby={context.descriptionId}
aria-labelledby={context.titleId}
data-state={getState(context.open)}
{...contentProps}
ref={composedRefs}
onDismiss={() => context.onOpenChange(false)}
/>
</FocusScope>
{process.env.NODE_ENV !== 'production' && (
<>
<TitleWarning titleId={context.titleId} />
<DescriptionWarning contentRef={contentRef} descriptionId={context.descriptionId} />
</>
)}
</>
);
}
);
const DialogContentImpl = React.forwardRef<DialogContentImplElement, DialogContentImplProps>(
(props: ScopedProps<DialogContentImplProps>, forwardedRef) => {
const { __scopeDialog, trapFocus, onOpenAutoFocus, onCloseAutoFocus, ...contentProps } = props;
const context = useDialogContext(CONTENT_NAME, __scopeDialog);
const contentRef = React.useRef<HTMLDivElement>(null);
const composedRefs = useComposedRefs(forwardedRef, contentRef);
// Make sure the whole tree has focus guards as our `Dialog` will be
// the last element in the DOM (because of the `Portal`)
useFocusGuards();
return (
<>
<FocusScope
asChild
loop
trapped={trapFocus}
onMountAutoFocus={onOpenAutoFocus}
onUnmountAutoFocus={onCloseAutoFocus}
>
<DismissableLayer
role="dialog"
id={context.contentId}
aria-describedby={context.descriptionId}
aria-labelledby={context.titleId}
data-state={getState(context.open)}
{...contentProps}
ref={composedRefs}
onDismiss={() => context.onOpenChange(false)}
/>
</FocusScope>
</>
);
}
);
κ·ΈλΌ FocusScope
μ μ½λλ μ΄λ»κ² λμ΄ μμκΉ?
// https://github.com/radix-ui/primitives/blob/main/packages/react/focus-scope/src/focus-scope.tsx
interface FocusScopeProps extends PrimitiveDivProps {
loop?: boolean; // Tab μν μ¬λΆ
trapped?: boolean; // μ€μ½ν μΈλΆλ‘ ν¬μ»€μ€κ° λκ°μ§ μλλ‘ κ°μ
onMountAutoFocus?: (event: Event) => void; // ν¬μ»€μ± μ΄λ²€νΈμ λν νΈλ€λ¬
onUnmountAutoFocus?: (event: Event) => void;
}
μ»΄ν¬λνΈκ° λ§μ΄νΈ/μΈλ§μ΄νΈλ λ μ²λ¦¬νλ ν¬μ»€μ± μ΄λ²€νΈ μ²λ¦¬λ μ§ννλ€.
// μλ ν¬μ»€μ± λ‘μ§
// https://github.com/radix-ui/primitives/blob/main/packages/react/focus-scope/src/focus-scope.tsx#L132-L170
React.useEffect(() => {
if (container) {
focusScopesStack.add(focusScope);
const previouslyFocusedElement = document.activeElement as HTMLElement | null;
// ν¬μ»€μ€κ° κ°λ₯ν ν보λ€
const hasFocusedCandidate = container.contains(previouslyFocusedElement);
// ν¬μ»€μ€κ° κ°λ₯νλ€λ©΄ 컀μ€ν
μ΄λ²€νΈλ‘ λ§μ΄νΈ λ λ ν¬μ»€μ€ μν΄
if (!hasFocusedCandidate) {
const mountEvent = new CustomEvent(AUTOFOCUS_ON_MOUNT, EVENT_OPTIONS);
container.addEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
container.dispatchEvent(mountEvent);
if (!mountEvent.defaultPrevented) {
focusFirst(removeLinks(getTabbableCandidates(container)), { select: true });
if (document.activeElement === previouslyFocusedElement) {
focus(container);
}
}
}
return () => {
// μ΄λ²€νΈ μ κ±° λ‘μ§
container.removeEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
// We hit a react bug (fixed in v17) with focusing in unmount.
// We need to delay the focus a little to get around it for now.
// See: https://github.com/facebook/react/issues/17894
setTimeout(() => {
const unmountEvent = new CustomEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS);
container.addEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
container.dispatchEvent(unmountEvent);
if (!unmountEvent.defaultPrevented) {
focus(previouslyFocusedElement ?? document.body, { select: true });
}
// we need to remove the listener after we `dispatchEvent`
container.removeEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
focusScopesStack.remove(focusScope);
}, 0);
};
}
}, [container, onMountAutoFocus, onUnmountAutoFocus, focusScope]);
κ·Έλ¦¬κ³ Tabν€λ‘ λ€μ΄μΌλ‘κ·Έ λ΄μμ μ΄λν λμλ focusκ° λ²μ΄λμ§ μκ² μ²λ¦¬νλ κ²λ ν¬ν¨λμ΄ μλ€.
// https://github.com/radix-ui/primitives/blob/main/packages/react/focus-scope/src/focus-scope.tsx#L173C9-L201
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (!loop && !trapped) return;
if (focusScope.paused) return;
// νν€λ§ λλ μ κ²½μ°
const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey;
// νμ±νλ μμ
const focusedElement = document.activeElement as HTMLElement | null;
if (isTabKey && focusedElement) {
const container = event.currentTarget as HTMLElement;
const [first, last] = getTabbableEdges(container);
const hasTabbableElementsInside = first && last;
// shift + tab ν€λ μ΄μ νμ±ν μμ, tab ν€λ λ€μ νμ±ν μμλ‘ λλλ‘ μ²λ¦¬
// λ¨, loop μ΅μ
μ΄ μμλλ loopλ‘ focusλλλ‘ μ²λ¦¬
// we can only wrap focus if we have tabbable edges
if (!hasTabbableElementsInside) {
if (focusedElement === container) event.preventDefault();
} else {
if (!event.shiftKey && focusedElement === last) {
event.preventDefault();
if (loop) focus(first, { select: true });
} else if (event.shiftKey && focusedElement === first) {
event.preventDefault();
if (loop) focus(last, { select: true });
}
}
}
},
[loop, trapped, focusScope.paused]
);
μ΄μΈμλ focusλ μμμ κ΄λ ¨λ λ€μν ν¬νΌ ν¨μλ€λ κ°μ΄ μ μ λμ΄ μμΌλ μ½λ μ΄ν΄λ³΄λ κ²λ μΆμ²νλ€.
μ€ν¬λ¦° 리λκ° λ€μ΄μΌλ‘κ·Έ λ΄λΆλ§ νμΈμ΄ κ°λ₯νλλ‘ aria-hidden="true"
μ²λ¦¬λ₯Ό ν΄μΌ νλ€κ³ νλ€.
λΌμ΄λΈλ¬λ¦¬μμλ 'aria-hidden λΌμ΄λΈλ¬λ¦¬'λ₯Ό ν΅ν΄ λ΄λΆ μμλ₯Ό μ μΈνκ³ λͺ¨λ hidden μ²λ¦¬λ₯Ό νκ³ μλ€.
// https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/dialog.tsx#L265-L269
// aria-hide everything except the content (better supported equivalent to setting aria-modal)
React.useEffect(() => {
const content = contentRef.current;
if (content) return hideOthers(content);
}, []);
μ§μ ꡬννμ λμλ aria-describedby
, aria-haspopup
λ± μ¬λ¬κ°μ§ μμ±λ€μ μ¬μ©νλλ° λ΄λΆμλ μμμ§ μ΄ν΄λ³΄μ
μ½λμμ κ²μν΄λ³΄λ©΄ μ΄ 12κ°κ° λ¬λ€.
// https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/dialog.tsx#L106-L108
const DialogTrigger = React.forwardRef<DialogTriggerElement, DialogTriggerProps>(
(props: ScopedProps<DialogTriggerProps>, forwardedRef) => {
// ...
return (
<Primitive.button
type="button"
aria-haspopup="dialog"
aria-expanded={context.open}
aria-controls={context.contentId}
data-state={getState(context.open)}
{...triggerProps}
ref={composedTriggerRef}
onClick={composeEventHandlers(props.onClick, context.onOpenToggle)}
/>
);
}
);
λΉμ·νκ² haspopup
μμ±λ λΆμ¬νμκ³ , μ΄μΈμλ expanded
, controls
μμ±λ μ¬μ© μ€μΈκ² 보μΈλ€.
κ°λ°μ λꡬμμ μ κ·Όμ± νΈλ¦¬λ₯Ό νμΈν΄λ³΄λ©΄, μμ μ½λμ²λΌ λΆμ¬λ κ²μ νμΈν μ μλ€.
μ¬κΈ°μ aria-controls
λ λ€μ΄μΌλ‘κ·Έκ° μ΄λ¦° 컨ν
μΈ μ λΆμ¬λμ΄ μλ€.
λΉμ°νκ²λ describedby
, labelledby
λ μ½λμμ μ 곡μ€μ΄λ€.
https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/src/dialog.tsx#L407-L409
const DialogContentImpl = React.forwardRef<DialogContentImplElement, DialogContentImplProps>(
(props: ScopedProps<DialogContentImplProps>, forwardedRef) => {
//...
return (
<>
<FocusScope
asChild
loop
trapped={trapFocus}
onMountAutoFocus={onOpenAutoFocus}
onUnmountAutoFocus={onCloseAutoFocus}
>
<DismissableLayer
role="dialog"
id={context.contentId}
aria-describedby={context.descriptionId}
aria-labelledby={context.titleId}
data-state={getState(context.open)}
{...contentProps}
ref={composedRefs}
onDismiss={() => context.onOpenChange(false)}
/>
</FocusScope>
</>
);
}
);
κ° μμλ€μ id κ°λ€μ dialogμ title, subtitleμ κ°κ° λ§€νμ΄ λμ΄ μλ€.
div, a, button νκ·Έλ€μ λͺ¨λ λ²νΌμΌλ‘ μ¬μ© κ°λ₯ν νκ·Έλ€μ΄λ€. μ κ·Όμ±μ λ°°μ μΌλ κ° νκ·Έλ€μ λ²νΌμΌλ‘ μ¬μ©νλ μ½λλ₯Ό μ΄ν΄λ³΄μ
// 1. div νκ·Έ μ¬μ©
<div role="button" style={{ cursor: pointer }}>λ²νΌ</div>
// 2. a νκ·Έ μ¬μ©
<a href="#" role="button">λ²νΌ</a>
// 3. λ²νΌ νκ·Έ μ¬μ©
<button type="button">λ²νΌ</button>
μ 3κ°μ§λ λͺ¨λ μ κ·Όμ± νΈλ¦¬μμλ λ²νΌμΌλ‘ μΈμλλ€. κ·Έλ¬λ λ³Έμ§μ μΈ κΈ°λ₯μλ μλ¬΄λ° μν₯μ λ―ΈμΉμ§ μλλ€. μ°μΈ‘ ν΄λ¦μ νμ λ κ°κΈ° λ€λ₯Έ κΈ°λ₯μ΄ λνλλ€.
div νκ·Έ | a νκ·Έ | button νκ·Έ |
---|---|---|
![]() |
![]() |
![]() |
λ§ν¬λ‘ λ²νΌμ λ§λ€ κ²½μ° λ²νΌμ μν μ΄ μλ λ§ν¬μ κΈ°λ₯μ΄ λ
ΈμΆλλ€. λ λ²νΌμ Space/Enter
ν€λ‘ λμμ΄ λλλ°, λ§ν¬λ Space
λ‘λ λμνμ§ μλλ€.
ul
κ³Ό ol
νκ·Έλ‘ λͺ©λ‘ μ»΄ν¬λνΈλ₯Ό ꡬνν λ λμμΈμλ κΈ°λ³Έμ μΈ list-styleμ μ¬μ©νμ§ μλκ² λλΆλΆμ΄λ€. κ·Έλμ reset/nomalize cssλ₯Ό μ¬μ©νμ¬ μ€νμΌμ μ΄κΈ°ννμ¬ λμμΈμ νλκ² λλΆλΆμ΄λ€.
list-style-type: none
κ·Έλ¬λ μ΄λ κ² μ€νμΌμ μ§μ νκ² λλ©΄, ν΄λΉ μμκ° λ μ΄μ 'λͺ©λ‘'μΌλ‘ μΈμλμ§ μλ λ¬Έμ κ° λ°μνλ€.
μ΄λ₯Ό ν΄κ²°νκΈ° μν μ¬λ¬κ°μ§ λ°©λ²λ€μ΄ μλ€.
role μμ± λΆμ¬νκΈ°
<ul role="list">
<li role="listitem">νλͺ©1</li>
</ul>
css marker or before κ°μμμ μ¬μ©
marker
: none μμ±μ μ¬μ©νμ§ μκ³ font-sizeλ₯Ό μ€μ¬ κΈλ¨Έλ¦¬ κΈ°νΈλ₯Ό μ¨κΈ°λ λ°©λ²before
: content μμ±μ λΉ κ°μ΄ μλ κ²μ ν λΉνμ¬ κΈλ¨Έλ¦¬ κΈ°νΈλ‘ μΈμνλλ‘ νλ€.<ul>
<li>νλͺ©1</li>
</ul>
// marker μ¬μ©
li:marker {
font-size: 0;
}
// before μ¬μ©
ul {
list-style-type: none;
}
li:before {
content: "\200B"
}
μκ°μ μΌλ‘ 보λ μ΄λͺ¨ν°μ½κ³Ό μ€ν¬λ¦° 리λκ° μ½λ μ΄λͺ¨ν°μ½μ λ°λΌμ ν΄μμ΄ λ¬λΌμ§ μ μλ€.
β λμ μμ
<!-- μ€ν¬λ¦° 리λ: μλ¦Ό μμ, μ’
λͺ¨μ -->
<span aria-live="polite">
μλ¦Ό μμ π
</span>
β
μ’μ μμ
<!-- μ€ν¬λ¦° 리λ: μλ¦Όμ΄ λμ°©νμ΅λλ€ -->
<span role="img" aria-label="μλ¦Όμ΄ λμ°©νμ΅λλ€">
π
</span>
μ μμμμ μ’
λͺ¨μμ μ½κ² λλ©΄μ μ΄ν΄νλλ° λ°©ν΄λ₯Ό ν μ μλ€. μλ―Έ μλ μ΄λͺ¨ν°μ½μ΄λΌλ©΄ aria-label
μ ν΅ν΄ μ€λͺ
μ μ κ±°λ, aria-hidden
μ μ¬μ©νλ κ²λ νκ°μ§ λ°©λ²μ΄λ€.
νλ¦Ώνλ μΉ μ κ·Όμ± μ§μμ΄ μ 리λλ©΄μ, μ΄μ λ aria-x μμ±μ 'κ·Έλ₯' μ¬μ©νλ κ²μ΄ μλλΌ "μ€ν¬λ¦° 리λλ μ΄λ»κ² μ½μκΉ?"λΌλ μ§λ¬Έμ μμ§ μκ³ κ°λ°ν΄μΌκ² λ€.
μ κ·Όμ± κ³΅λΆμ λμλλ κΈλ€