WAI-ARIA'yı Doğru Kullanmak: Role, State, Property, Accessible Name ve "Kötü ARIA'dan Hiç ARIA İyidir"
ARIA bir element'in accessibility tree'deki adını, role'ünü ve durumunu değiştirir ama davranış eklemez; native element varsa önce o kullanılır. Chromium'dan accessible name kaynaklarını ölçüyor, aynı adı taşıyan butonlara 2.5.3'e uygun bağlam veriyor, aria-hidden, aria-expanded, aria-current, aria-invalid ve aria-describedby'ı Bookstore'da uyguluyor ve state ile ekranın ayrıştığı bir CSS hatasını yakalıyoruz.
Sürüm notu: Bu yazıdaki örnekler ARIA (WAI-ARIA 1.2; HTML element'lerine role, state ve property ekleyen standart), Accessible Name and Description Computation 1.2, WCAG (Web Content Accessibility Guidelines; web içeriği için accessibility kılavuzu) 2.2, Angular 22.2 (TypeScript 6.0), Node.js 24, Playwright 1.65 (Chromium 158) ve axe-core 4.14 ile denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) kataloğunda yirmi tane "Add to cart" butonu var ve screen reader'ın buton listesinde hepsi aynı görünüyor. Bir geliştirici bunu düzeltmek için şunu yazıyor:
<button aria-label="Add Dune to cart">Add to cart</button>
Screen reader artık ne duyurur? Sesle kontrol kullanan biri ekranda "Add to cart" yazısını görüp "Click Add to cart" dediğinde ne olur? İkinci soru: <div role="button" tabindex="0">Add to cart</div> Tab ile focus alıyor ve screen reader onu "button" diye duyuruyor; Enter'a ya da Space'e basınca tıklanır mı? axe bunu ihlal sayar mı?
ARIA Ne Yapar, Ne Yapmaz
WAI-ARIA (güncel sürümü Haziran 2023 tarihli 1.2 Recommendation'ı) tek bir şey yapar: element'in accessibility tree'deki görüntüsünü değiştirir. Role, element'in ne olduğunu söyler (button, tab, dialog). State, kullanıcı etkileşimiyle değişen durumdur (aria-expanded, aria-checked, aria-invalid). Property, element'in daha kalıcı bir özelliğidir (aria-labelledby, aria-controls, aria-describedby). Hiçbiri davranış eklemez: klavye desteği, focus, tıklama ve görünüm hâlâ senin kodunun işi.
W3C'nin (web standartlarını yayımlayan kuruluş) "Using ARIA" notu yıllarca bunu dört kurala bağladı (Şubat 2026'da "discontinued" olarak kapatıldı ve okurları APG'ye (ARIA Authoring Practices Guide; widget'ların klavye ve ARIA davranışını tarif eden W3C rehberi) yönlendiriyor): ihtiyacın olan anlam ve davranış native bir element'te varsa onu kullan; native semantiği gerekmedikçe değiştirme; ARIA ile tanımladığın her etkileşimli kontrol klavyeyle kullanılabilmeli; focus alabilen bir element'i aria-hidden ya da role="presentation" ile gizleme. APG de rehberinin başında aynı uyarıyı yapıyor: yanlış ARIA, hiç ARIA olmamasından daha kötüdür, çünkü screen reader'a yalan söyler. Tahminin ikinci kısmını ölçen satırlar:
div-button button "Add to cart" <- contents
div-button: Enter + Space -> clicks=0
axe: button-name (#icon-only); label-content-name-mismatch (#all, #aria-label); target-size (#div-button)
div tree'de kusursuz bir button "Add to cart"; ama Enter ve Space'ten sonra tıklama sayısı sıfır. Native button bu iki tuşta click event'i üretir, div üretmez; bunu keydown ile kendin yazman gerekir. axe'ın listesinde de bununla ilgili bir ihlal yok: role ve ad var, aracın göremediği şey davranış. Görünen tek bulgu target-size, çünkü stilsiz div küçük ve yanındaki element'lere çok yakın.
Accessible Name Nasıl Hesaplanır?
Screen reader'ın bir element için okuduğu ad, W3C'nin Accessible Name and Description Computation (accname) spesifikasyonundaki bir algoritmayla hesaplanır. Pratikte bilmen gereken, kaynakların sırası ve ilk dolu kaynağın kazanması:

Bunu ezberlemek yerine Chromium'a sorabilirsin. Chrome DevTools Protocol'ün Accessibility.getFullAXTree komutu her node için adın hangi kaynaktan geldiğini ve hangi kaynakların yok sayıldığını da veriyor. DevTools'un Accessibility sekmesi aynı bilgiyi "Computed Properties" altında gösteriyor; Elements paneli Browser DevTools serisinin "Chrome DevTools'a Giriş ve Elements Paneli" yazısında. On bir durumu tek sayfaya koyup ölçen script:
<button id="all" aria-labelledby="all-label" aria-label="Label" title="Tip">Content</button>
<span id="all-label">Labelledby</span>
<button id="aria-label" aria-label="Add Dune to cart">Add to cart</button>
<button id="hidden-text">Add to cart<span class="visually-hidden">: Dune</span></button>
<button id="described" aria-describedby="dune-title">Add to cart</button>
<span id="dune-title">Dune</span>
<input id="title-only" type="search" title="Search books" placeholder="Title or author">
<input id="placeholder-only" type="email" placeholder="E-mail">
<button id="icon-only"><svg aria-hidden="true" width="16" height="16"><path d="M2 2 L14 14 M14 2 L2 14" stroke="currentColor"/></svg></button>
<button id="icon-labelled" aria-label="Remove Dune"><svg aria-hidden="true" width="16" height="16"><path d="M2 2 L14 14 M14 2 L2 14" stroke="currentColor"/></svg></button>
<a id="arrow" href="/">← Back to catalog</a>
<a id="arrow-hidden" href="/"><span aria-hidden="true">←</span> Back to catalog</a>
<div id="div-button" role="button" tabindex="0" onclick="this.dataset.clicks = Number(this.dataset.clicks ?? 0) + 1">Add to cart</div>
// demos/names.html'deki her element'in accessible name'ini Chromium'un nasıl hesapladığını ve adın nereden geldiğini gösterir
// Kullanım: node names.mjs
import { readFileSync } from 'node:fs';
import { chromium } from 'playwright';
import { AxeBuilder } from '@axe-core/playwright';
const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
await page.setContent(readFileSync(new URL('./demos/names.html', import.meta.url), 'utf8'));
const cdp = await page.context().newCDPSession(page);
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
const label = s => s.attribute ?? s.nativeSource ?? s.type;
for (const id of ['all', 'aria-label', 'hidden-text', 'described', 'title-only', 'placeholder-only',
'icon-only', 'icon-labelled', 'arrow', 'arrow-hidden', 'div-button']) {
const { node } = await cdp.send('DOM.describeNode', { objectId: (await cdp.send('Runtime.evaluate',
{ expression: `document.getElementById('${id}')` })).result.objectId });
const ax = nodes.find(n => n.backendDOMNodeId === node.backendNodeId);
const sources = ax.name?.sources ?? [];
const used = sources.find(s => s.value?.value && !s.superseded);
const ignored = sources.filter(s => s.value?.value && s.superseded).map(label);
const description = ax.description?.value ? ` description="${ax.description.value}"` : '';
console.log(`${id.padEnd(17)} ${ax.role.value} "${ax.name?.value ?? ''}" <- ${used ? label(used) : '(none)'}` +
(ignored.length ? ` (ignored: ${ignored.join(', ')})` : '') + description);
}
// role="button" element'in adını ve türünü değiştirir, davranışını değil
await page.focus('#div-button');
await page.keyboard.press('Enter');
await page.keyboard.press('Space');
console.log(`div-button: Enter + Space -> clicks=${await page.locator('#div-button').getAttribute('data-clicks') ?? 0}`);
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'experimental']).analyze();
console.log('axe:', axe.violations.map(v => `${v.id} (${v.nodes.map(n => n.target.join(' ')).join(', ')})`).join('; ') || 'no violations');
await browser.close();
all button "Labelledby" <- aria-labelledby (ignored: aria-label, contents, title) description="Tip"
aria-label button "Add Dune to cart" <- aria-label (ignored: contents)
hidden-text button "Add to cart : Dune" <- contents
described button "Add to cart" <- contents description="Dune"
title-only searchbox "Search books" <- title (ignored: placeholder, title)
placeholder-only textbox "E-mail" <- placeholder
icon-only button "" <- (none)
icon-labelled button "Remove Dune" <- aria-label
arrow link "← Back to catalog" <- contents
arrow-hidden link " Back to catalog" <- contents
div-button button "Add to cart" <- contents
div-button: Enter + Space -> clicks=0
axe: button-name (#icon-only); label-content-name-mismatch (#all, #aria-label); target-size (#div-button)
Tahminin ilk kısmının cevabı ikinci satırda: aria-label kazandı, ad "Add Dune to cart" ve butonun içindeki "Add to cart" yok sayıldı. axe'ın deneysel label-content-name-mismatch kuralı tam bunu yakaladı. Bu 2.5.3'ün (Label in Name) konusu: görünen bir metni olan kontrolün adı o metni içermeli. "Add to cart" ekranda görünüyor ama "Add Dune to cart" bu metni bitişik olarak içermiyor; ekranda gördüğünü söyleyen sesle kontrol kullanıcısının komutu eşleşmeyebilir. Aynı sorun ilk satırda da var: görünen "Content", ad "Labelledby".
Diğer satırlar da birer ders. Görsel olarak gizlenmiş metin ad hesabına giriyor, ama Chromium position: absolute'lu span'in önüne bir boşluk koydu: "Add to cart : Dune". aria-describedby adı değiştirmiyor, description olarak ekleniyor; screen reader onu addan ve role'den sonra, bazen kısa bir beklemeyle okuyor ve kullanıcı description okumayı kapatabiliyor. title, placeholder'ı yeniyor; placeholder yalnız hiçbir şey kalmadığında ad oluyor. İkon-only buton adsız kaldı ve axe button-name ile işaretledi; aria-label verilince "Remove Dune" oldu. Son olarak ok karakteri: "← Back to catalog" adın bir parçası ve screen reader ayarına göre onu bir sembol adıyla okuyabilir; aria-hidden="true" verilen span'deki ok addan çıktı.
Bookstore'da Aynı Adı Taşıyan Butonlar
Katalogdaki yirmi "Add to cart" ve sepetteki "Remove" butonları teknik olarak bir WCAG ihlali sayılmayabilir, çünkü her buton kendi kitabının kartında ya da satırında duruyor ve bağlam programatik olarak belirlenebiliyor. Ama screen reader'ın element listesinde ya da sesle kontrolde yirmi tane aynı ad kullanıcının işini zorlaştırıyor. Üç seçeneği ölçtük; Bookstore için adın görünen metinle başlayan bir aria-label ile tamamlanmasını seçtik:
<a [routerLink]="['/books', book.isbn]">{{ book.title }}</a>
<span class="muted">{{ book.author }} · {{ book.publishedYear }}</span>
<span class="muted">{{ book.category }}</span>
<strong>{{ book.price | currency: 'TRY' : 'symbol-narrow' }}</strong>
<!-- Ad görünen metinle başlıyor; sesle kontrolde "Click Add to cart" çalışmaya devam eder (WCAG 2.5.3) -->
<button type="button" [attr.aria-label]="'Add to cart: ' + book.title" (click)="cartStore.add(book.isbn)">Add to cart</button>
<td><button type="button" [attr.aria-label]="'Remove ' + line.title" (click)="askToRemove(line)">Remove</button></td>
node tree.mjs catalog .book-card
node tree.mjs cart "tbody tr"
- listitem:
- link "1984":
- /url: /books/9780451524935
- text: George Orwell · 1949 Dystopian
- strong: ₺279.00
- 'button "Add to cart: 1984"': Add to cart
- row "1984 ₺279.00 Quantity of 1984 ₺279.00 Remove 1984":
- cell "1984":
- link "1984":
- /url: /books/9780451524935
- cell "₺279.00"
- cell "Quantity of 1984":
- text: Quantity of 1984
- spinbutton "Quantity of 1984": "1"
- cell "₺279.00"
- cell "Remove 1984":
- button "Remove 1984": Remove
Ad "Add to cart: 1984"; görünen metin adın başında olduğu için "Click Add to cart" diyen kullanıcının komutu yine eşleşiyor. node scan.mjs experimental ile deneysel kuralları da çalıştırdım; dört sayfada label-content-name-mismatch dahil ihlal çıkmadı. Görünmez metin yerine aria-label'ı seçmemizin iki nedeni var: ölçtüğümüz boşluk sorunu ve adın tek bir yerde, okunur biçimde durması. Karşı argüman da var: otomatik sayfa çevirisi araçları attribute'ları görünen metin kadar güvenilir çevirmeyebilir. Bookstore'un arayüzü tek dilli olduğu için bu bizi etkilemiyor; çok dilli bir sitede görünmez metin daha güvenli olabilir.
aria-hidden'ı Doğru Yerde Kullanmak
Kitap sayfasındaki geri link'inde ok karakteri yalnız görsel bir işaret. Onu aria-hidden ile tree'den çıkardık:
<a routerLink="/"><span aria-hidden="true">←</span> Back to catalog</a>
- link "← Back to catalog":
- /url: /
- link "Back to catalog":
- /url: /
aria-hidden'ın doğru kullanımı bu: görsel olarak anlamı tekrar eden, etkileşimsiz içerik (ikonlar, dekoratif karakterler, metni olan bir butonun yanındaki ikon). Yanlış kullanımı, içinde focus alabilen bir şey olan bölümü gizlemek; focus görünmeyen bir element'e gider ve kullanıcı ne olduğunu duyamaz. Bu tuzak ve inert ile farkı HTML serisinin "Accessible Markup Temelleri" yazısında ölçüldü.
aria-expanded ve aria-controls: Filters Disclosure'ı
Kategori chip'leri dar ekranda yer kaplıyor; kataloğa bir "Filters" butonu koyup paneli açılıp kapanır yaptık. Bu, APG'deki disclosure pattern'i: bir buton, aria-expanded ile kontrol ettiği içeriğin açık olup olmadığını söyler, aria-controls da o içeriğin id'sini gösterir. Pattern'in tarifinde tuşlar da yazıyor: Enter ve Space butonu tetikler; native button bunu zaten yapıyor.
<button type="button" class="filters-toggle" aria-controls="category-filters"
[attr.aria-expanded]="filtersOpen()" (click)="filtersOpen.set(!filtersOpen())">
Filters
</button>
<fieldset id="category-filters" class="chips" [hidden]="!filtersOpen()">
if (mode === 'filters') {
// Filters butonu bir disclosure: aria-expanded kontrol ettiği panelin açık olup olmadığını söyler
await open(page, 'catalog');
await tabUntil('button "Filters"');
console.log(`focus: ${await focused()}`);
const toggle = page.getByRole('button', { name: 'Filters' });
await page.keyboard.press('Enter');
// Angular signal değişikliğini asenkron render eder: tree'yi okumadan önce attribute'u bekle
await toggle.and(page.locator('[aria-expanded="false"]')).waitFor();
console.log(`Enter: ${await focused()}, radio group visible=${await page.getByRole('group', { name: 'Category' }).isVisible()}`);
await page.keyboard.press('Space');
await toggle.and(page.locator('[aria-expanded="true"]')).waitFor();
console.log(`Space: ${await focused()}, radio group visible=${await page.getByRole('group', { name: 'Category' }).isVisible()}`);
}
İlk ölçüm şaşırttı:
focus: button "Filters" [expanded]
Enter: button "Filters", radio group visible=true
Space: button "Filters" [expanded], radio group visible=true
Enter'dan sonra buton "kapalı" diyor ([expanded] kayboldu), ama panel hâlâ görünür. Neden: .chips class'ı display: flex veriyor ve bu kural tarayıcının hidden attribute'u için koyduğu display: none'ı eziyor. Screen reader kullanıcısı "collapsed" duyuyor, gören kullanıcı paneli açık görüyor: state ile ekran birbirini tutmuyor. Düzeltme global bir kural:
/* display: flex hidden attribute'unu ezerdi; gizli element'ler gizli kalsın */
[hidden] { display: none !important; }
focus: button "Filters" [expanded]
Enter: button "Filters", radio group visible=false
Space: button "Filters" [expanded], radio group visible=true
Bu disclosure için ARIA'sız bir yol da vardı: details ve summary. Açık/kapalı durumunu tarayıcı yönetir ve tree'ye kendisi yazar; HTML serisinin "Native Interaktif Element'ler 1" yazısında anlatıldı. Butonu panelden ayrı bir yere koymak ya da stili tamamen kontrol etmek istediğinde aria-expanded'lı buton gerekir. Sırada APG'de tabs ve combobox pattern'leri var: tabs'ta ok tuşları ve aria-selected, combobox'ta aria-activedescendant, aria-autocomplete ve popup yönetimi. Bunları sıfırdan yazmak hata yapmaya açık; Angular Aria ve CDK (Component Dev Kit; Angular ekibinin davranış library'si) ile hazır kullanmak serinin Angular yazısının konusu. Tablo sıralamasında da aynı mantık geçerli: sıralanabilir bir tabloda yalnız o an sıralı olan sütunun th'si aria-sort="ascending" ya da "descending" taşır, sıralama butonu başlığın içinde bir button olur.
aria-current ile Navigation
Header'daki "Catalog" ve "Cart" link'lerinden hangisinin bulunduğun sayfa olduğu yalnız görsel olarak (kalın yazı) belli oluyordu. aria-current="page" bunu screen reader'a da söyler; Angular'ın RouterLinkActive'i bunu ariaCurrentWhenActive input'u ile kendisi yönetiyor:
<header class="site-header">
<a routerLink="/" class="brand">Bookstore</a>
<nav>
<a routerLink="/" routerLinkActive="active" ariaCurrentWhenActive="page"
[routerLinkActiveOptions]="{ exact: true }">Catalog</a>
<a routerLink="/cart" routerLinkActive="active" ariaCurrentWhenActive="page" data-testid="cart-link">
Cart ({{ cartStore.itemCount() }})
</a>
</nav>
</header>
node tree.mjs catalog nav html
<nav><a routerlink="/" routerlinkactive="active" ariacurrentwhenactive="page" href="/" class="active" aria-current="page">Catalog</a><a routerlink="/cart" routerlinkactive="active" ariacurrentwhenactive="page" data-testid="cart-link" href="/cart"> Cart (0) </a></nav>
Katalogdayken "Catalog" link'i aria-current="page" taşıyor; screen reader onu "current page" diye duyurur. "Catalog" için exact: true gerekli, yoksa / her route'un öneki olduğu için link her sayfada aktif sayılırdı. Playwright'ın aria snapshot'ı bu attribute'u göstermediği için burada render edilmiş HTML'e baktık; ölçtüğün aracın neyi gösterip neyi göstermediğini bilmek de işin parçası.
Form Hataları: aria-invalid ve aria-describedby
Audit tablosundaki 3.3.1 satırına dönelim. Sipariş formu tarayıcının native doğrulamasına bırakılmıştı. Adı boş, e-postayı "ada" yazıp Enter'a basan klavye kullanıcısının focus'taki alandan aldığı bilgi:
// Sipariş formunu hatalı gönderir ve focus'taki alanda screen reader'ın aldığı bilgiyi yazdırır
// Kullanım: node form.mjs
import { chromium } from 'playwright';
import { focusedAx, open } from './lib.mjs';
const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
await open(page, 'cart');
// Ad boş, e-posta domain'siz; klavye kullanıcısı gibi Enter ile gönder
await page.getByLabel('E-mail').fill('ada');
await page.getByLabel('E-mail').press('Enter');
await page.waitForTimeout(300);
console.log(`after submit: ${await focusedAx(page)}`);
await page.keyboard.press('Tab');
console.log(`next Tab: ${await focusedAx(page)}`);
// İki alanı da düzelt ve tekrar gönder
await page.getByLabel('Full name').fill('Ada Lovelace');
await page.getByLabel('E-mail').fill('ada@example.com');
await page.getByLabel('E-mail').press('Enter');
console.log(`result: ${await page.getByTestId('order-confirmation').textContent({ timeout: 3000 }).then(t => t.trim().replace(/\s+/g, ' '))}`);
await browser.close();
after submit: textbox "Full name"
next Tab: textbox "E-mail" invalid=true
result: Order #2 placed (Placed). Total: ₺279.00
Tarayıcı focus'u ilk geçersiz alana taşıdı ve bir baloncuk gösterdi, ama accessibility tree'de hata metni yok: "Full name" alanı invalid bile değil, e-posta alanında hangi hatanın olduğu yazmıyor. Baloncuğun screen reader'a nasıl ulaştığı tarayıcı ve screen reader ikilisine göre değişiyor. Form'a novalidate verip doğrulamayı kendimiz yaptık; mesajı alanın altına yazıp aria-describedby ile alana bağladık, alanı aria-invalid ile işaretledik:
<form class="checkout" novalidate (submit)="checkout($event, nameBox, emailBox)">
<div class="field">
<label for="customer-name">Full name</label>
<input #nameBox id="customer-name" name="name" autocomplete="name" required
[attr.aria-invalid]="nameError() ? 'true' : null"
[attr.aria-describedby]="nameError() ? 'customer-name-error' : null" />
@if (nameError()) {
<p id="customer-name-error" class="error field-error">{{ nameError() }}</p>
}
</div>
<div class="field">
<label for="customer-email">E-mail</label>
<input #emailBox id="customer-email" name="email" type="email" autocomplete="email" required
[attr.aria-invalid]="emailError() ? 'true' : null"
[attr.aria-describedby]="emailError() ? 'customer-email-error' : null" />
@if (emailError()) {
<p id="customer-email-error" class="error field-error">{{ emailError() }}</p>
}
</div>
<button type="submit">Place order</button>
</form>
protected async checkout(event: Event, nameBox: HTMLInputElement, emailBox: HTMLInputElement): Promise<void> {
event.preventDefault();
this.errorMessage.set('');
// Form'da novalidate var: alanları kendimiz kontrol edip mesajı yanlarında gösteriyoruz (WCAG 3.3.1)
this.nameError.set(nameBox.validity.valueMissing ? 'Enter your full name.' : '');
this.emailError.set(emailBox.validity.valueMissing ? 'Enter your e-mail address.'
: emailBox.validity.typeMismatch ? 'Enter an e-mail address like name@example.com.' : '');
const firstInvalid = this.nameError() ? nameBox : this.emailError() ? emailBox : null;
if (firstInvalid) {
// Focus'u bir sonraki render'dan sonra ver; o anda aria-invalid ve aria-describedby alanda olur
afterNextRender(() => firstInvalid.focus(), { injector: this.injector });
return;
}
after submit: textbox "Full name" description="Enter your full name." invalid=true
next Tab: textbox "E-mail" description="Enter an e-mail address like name@example.com." invalid=true
result: Order #3 placed (Placed). Total: ₺279.00
Focus'taki alan artık üç bilgiyi birlikte taşıyor: adı, geçersiz olduğu ve hatanın ne olduğu. Bir screen reader bu üç bilgiyi alandan okur; gerçek bir screen reader oturumu bu yazıda çalıştırılmadı, ölçtüğümüz şey tree'nin verdiği bilgi. Mesaj ne olduğunu ve nasıl düzeltileceğini söylüyor (3.3.3 Error Suggestion). Focus'u afterNextRender ile veriyoruz: hemen versek, aria-invalid ve aria-describedby henüz render edilmemiş olur ve screen reader alanı hatasız okur. Sunucudan dönen hatalar (ör. stok yetmezliği) hâlâ formun altında sessizce görünüyor; birden çok hatanın özeti ve bunların duyurulması bir sonraki yazının konusu. Beyan sayfasındaki satırı da buna göre güncelledik.
Üretimde Dikkat
ARIA eklemeden önce native bir element ara. Bu yazıdaki işlerin çoğunda ARIA, native bir element'in eksik kalan tek bir parçasını tamamladı: butona bağlam, link'e "current", alana hata metni. Role değiştirmeye nadiren ihtiyaç oldu.
ARIA state'leri, ekrandaki durumla aynı kaynaktan beslenmeli. Bookstore'da aria-expanded ile panelin hidden'ı aynı signal'den geliyor; buna rağmen bir CSS kuralı ikisini ayırdı. State'i test ederken yalnız attribute'a değil görünürlüğe de bak.
Ad testlerini otomatikleştir. Playwright'ın getByRole('button', { name: 'Add to cart: Dune' }) gibi locator'ları accessible name üzerinden çalışıyor; testlerin kullanıcının duyduğu adı kullanırsa ad bozulduğunda test de kırılır. Bu yaklaşım Frontend Testleri serisinin "Angular Testing Library ile Kullanıcı Gibi Test Etmek" yazısında.
aria-label'ı çeviri ve içerik yönetiminden geçen metinlerde dikkatli kullan. Görünen metin çevrilip aria-label çevrilmezse 2.5.3 ihlali tek bir dil için ortaya çıkar.
Sık Düşülen Tuzaklar
Birincisi, div'e role="button" verip Enter ve Space'i unutmak. axe yakalamaz; klavye testi yakalar.
İkincisi, görünen metni içermeyen bir aria-label. Ekranda "Add to cart" yazan butonun adı "Add Dune to cart" olunca sesle kontrol kullanıcısı butonu adıyla seçemeyebilir.
Üçüncüsü, her yere aria-label koymak. Zaten iyi bir adı olan link ya da butona eklenen aria-label içeriği ezer, çoğu zaman da daha kötü bir ad verir.
Dördüncüsü, aria-expanded'ı güncelleyip görsel durumu ayrı bir yerden yönetmek ya da tersini yapmak. Screen reader bir şey, ekran başka bir şey söyler.
Beşincisi, hata mesajını yazıp alana bağlamamak. aria-describedby olmadan mesaj, screen reader kullanıcısı için yalnız sayfanın bir yerinde duran bir paragraf.
Altıncısı, focus'u attribute'lar render edilmeden önce taşımak. Angular'da durum değişikliğinden sonra focus vereceksen afterNextRender kullan.
Küçük Bir Deneme
★☆☆ demos/names.html'e <input id="both" aria-label="Quantity" placeholder="1"> ekle, id'yi names.mjs'teki listeye koy ve çalıştır. Placeholder neden "ignored" listesinde?
★★☆ Kitap detayı sayfasındaki "Add to cart" butonuna da katalogdaki gibi bağlam ver. Sayfada tek bir kitap olduğu için buna gerek var mı? tree.mjs book ile iki sürümü karşılaştır ve kararını gerekçelendir.
★★★ Kitap detayı sayfasına "Description" ve "Details" adında iki sekme ekle; önce APG'deki tabs pattern'ini oku. role="tablist", tab ve tabpanel, aria-selected, aria-controls, roving tabindex ve ok tuşlarını yaz; keyboard.mjs'e bir mod ekleyip ölç. Sonra aynı şeyin Angular Aria ile kaç satır olacağını serinin Angular yazısında karşılaştıracağız.
Sepete bir kitap eklediğinde ekranda "Added." yazıyor, katalogda filtre değişince kitap sayısı güncelleniyor, sunucu bir hata dönünce formun altında bir mesaj beliriyor. Bunların hiçbiri focus'u taşımıyor ve screen reader hiçbirini duyurmuyor. Bir sonraki yazıda dinamik içeriği duyurmanın yollarına bakıyoruz: live region'lar, role="status" ve alert, yeni ariaNotify(), hata özeti ve loading state'leri.