Dinamik İçerik ve Screen Reader Bildirimleri: Live Region'lar, ariaNotify(), Form Hataları, Toast ve Loading State'leri
Focus'u taşımayan mesajlar (sonuç sayısı, "sepete eklendi", hata sayısı) screen reader'a live region ya da ariaNotify() ile bildirilir ve live region değişiklikten önce DOM'da olmalıdır. Bookstore'da @if içindeki role="status" tuzağını ölçüyor, sonuç sayısını aria-busy ile duyuruyor, ariaNotify()'ı CDK LiveAnnouncer yedeğiyle kullanıyor, hata özetine ve sipariş onayına focus veriyor, toast'ları 2.2.1 ve 4.1.3 açısından değerlendiriyoruz.
Sürüm notu: Bu yazıdaki örnekler WCAG (Web Content Accessibility Guidelines; web içeriği için accessibility kılavuzu) 2.2, ARIA (WAI-ARIA 1.2; HTML element'lerine role, state ve property ekleyen standart), Angular 22.2 (TypeScript 6.0), Angular CDK (Component Dev Kit; Angular ekibinin davranış library'si) 22.2, 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çı) kitap sayfasında "Add to cart"a basınca butonun altında "Added. Go to cart" yazıyor. Bir geliştirici screen reader da duysun diye paragrafa tek bir attribute ekliyor:
@if (added()) {
<p class="success" role="status">Added. <a routerLink="/cart">Go to cart</a></p>
}
Screen reader artık "Added" der mi? Kullanıcı aynı butona ikinci kez basarsa ne duyar? Katalogda kategori değişince "3 books" yazan satır için ne yapmak gerekir? Cevabı vermeden önce bir şeyi açık edelim: bu yazıda da gerçek bir screen reader oturumu çalıştırılmadı (bu, serinin bir sonraki yazısının konusu). Ölçtüğümüz şey tarayıcının accessibility tree'ye ve live region'lara yazdığı bilgi.
Status Message Nedir? 4.1.3 ve Live Region'lar
WCAG 2.2'nin 4.1.3 Status Messages kriteri (AA) şunu ister: focus'u taşımayan ve kullanıcıya bir işlemin sonucunu, bekleme durumunu, ilerlemeyi ya da bir hatanın varlığını bildiren mesajlar, role ya da property ile programatik olarak belirlenebilmeli; böylece screen reader onları focus'a gerek kalmadan okuyabilir. W3C'nin (web standartlarını yayımlayan kuruluş) Understanding sayfası örnek olarak tam bizim durumlarımızı sayıyor: arama sonrası sonuç sayısı, sepete ekleyince güncellenen adet, form gönderiminden sonra hata sayısı, "busy" göstergesi. Focus'u taşıyan değişiklikler (yeni sayfa, açılan dialog) bu kriterin konusu değil, çünkü screen reader focus'u zaten duyurur.
Bunun aracı live region: içeriği değişince screen reader'ın, kullanıcı oraya gitmeden okuduğu bir bölge. aria-live="polite" konuşmanın bitmesini bekler, assertive o anki konuşmayı keser. Hazır role'ler bu ayarları kendileri taşır: role="status" polite, role="alert" assertive, role="log" polite ve yalnız eklenen satırları okur. aria-atomic="true" değişiklikte bölgenin tamamının okunmasını, aria-relevant de hangi tür değişikliğin (ekleme, silme, metin) sayılacağını söyler. Understanding sayfası bir uyarı da yapıyor: önemsiz mesajlar için alert ya da assertive kullanmak kullanıcıyı sürekli böler ve başlı başına bir hata sayılır.
Screen reader olmadan ne ölçebiliriz? İki şey: Chromium'un tree'de bir node'a hangi live property'leri verdiği ve sayfanın gerçekten bir live region'ın içindeki metni değiştirip değiştirmediği. İkincisi için küçük bir kaydedici yazdık. Sayfaya enjekte edilen script ariaNotify() çağrılarını ve live region'lardaki metin değişikliklerini kaydediyor; değişiklikten önce DOM'da (Document Object Model; sayfanın tarayıcıdaki element ağacı) olmayan bir region'ı "missed" diye işaretliyor:
// Bookstore'un focus'u taşımadan screen reader'a söylemeye çalıştığını kaydeder: ariaNotify() çağrıları
// ve live region'lardaki metin değişiklikleri. Bu, ARIA kurallarının bir modeli; screen reader değil.
// Kullanım: node announce.mjs <book|catalog|busy|checkout|order> [no-arianotify]
import { chromium } from 'playwright';
import { base, focusedAx, open } from './lib.mjs';
function recorder() {
window.__announcements = [];
const log = entry => window.__announcements.push(entry);
for (const proto of [Document.prototype, Element.prototype]) {
const original = proto.ariaNotify;
if (!original) continue;
proto.ariaNotify = function (text, options) {
log(`ariaNotify ${options?.priority ?? 'normal'}: "${text}"`);
return original.call(this, text, options);
};
}
const liveSelector = '[aria-live]:not([aria-live="off"]), [role="status"], [role="alert"], [role="log"]';
const politeness = el => el.getAttribute('aria-live') ?? (el.getAttribute('role') === 'alert' ? 'assertive' : 'polite');
// Region değişikliği yalnız değişiklikten önce de DOM'daysa bildirir
const known = new WeakSet();
const lastText = new WeakMap();
const textOf = el => el.textContent.trim().replace(/\s+/g, ' ');
const remember = () => document.querySelectorAll(liveSelector).forEach(el => {
if (!known.has(el)) lastText.set(el, textOf(el));
known.add(el);
});
const observer = new MutationObserver(mutations => {
const changed = new Set();
for (const m of mutations) {
const node = m.target.nodeType === Node.ELEMENT_NODE ? m.target : m.target.parentElement;
const region = node?.closest(liveSelector);
if (region) changed.add(region);
// Container'da aria-busy="false": içindeki region'lar meşgulken değişeni bildirir
if (m.attributeName === 'aria-busy') m.target.querySelectorAll(liveSelector).forEach(el => changed.add(el));
for (const added of m.addedNodes) {
if (added.nodeType === Node.ELEMENT_NODE && added.matches(liveSelector) && !known.has(added) && added.textContent.trim()) {
log(`missed (region inserted with its text): ${added.getAttribute('role') ?? 'aria-live'} "${textOf(added)}"`);
}
}
}
for (const region of changed) {
const text = textOf(region);
if (known.has(region) && text && text !== lastText.get(region) && !region.closest('[aria-busy="true"]')) {
lastText.set(region, text);
log(`live ${politeness(region)}${region.getAttribute('aria-atomic') === 'true' ? ' atomic' : ''}: "${text}"`);
}
}
remember();
});
document.addEventListener('DOMContentLoaded', () => {
remember();
observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ['aria-busy'] });
});
}
Bu bir model: ARIA'nın kurallarını uyguluyor, ama bir screen reader'ın konuşma kuyruğunu, ayarlarını ya da tarayıcıyla arasındaki hataları bilmiyor. Bookstore'un mevcut hâlinde dört akışı çalıştırdım; script lab'ın a11y/ klasöründe, önceki yazılardaki gibi mock API 127.0.0.1:52411'de çalışırken:
node announce.mjs book
node announce.mjs catalog
node announce.mjs checkout
node announce.mjs order
Enter on "Add to cart"
(nothing announced)
focus: button "Add to cart"
category: Science Fiction
(nothing announced)
focus: radio "Science Fiction"
submit: name empty, e-mail "ada"
(nothing announced)
focus: textbox "Full name" description="Enter your full name." invalid=true
submit: valid form, 4 x War and Peace (3 in stock)
(nothing announced)
focus: textbox "E-mail"
submit: valid order
(nothing announced)
focus: (nothing focused)
Hiçbir akışta bir şey duyurulmuyor. Kitap sepete ekleniyor ama focus butonda kalıyor ve "Added." yalnız gören kullanıcıya ulaşıyor. Kategori değişince sonuç sayısı da öyle. Hatalı formda focus ilk alana gidiyor ve alanın hatası okunuyor (önceki yazının işi), ama kaç hata olduğu bilinmiyor; stok yetmezliğinde (409) focus e-posta alanında kalıyor ve sunucunun mesajı formun altında sessizce duruyor. Başarılı siparişten sonra form kaybolduğu için focus hiçbir yerde değil.
Live Region Önceden DOM'da Olmalı
Tahmindeki tek satırlık düzeltmeyi uygulayıp ölçtüm:
page loaded
(nothing announced)
focus: (nothing focused)
Enter on "Add to cart"
missed (region inserted with its text): status "Added. Go to cart"
focus: button "Add to cart"
Enter again
(nothing announced)
focus: button "Add to cart"
Kaydedici region'ı "missed" diye işaretledi. Nedeni Angular'ın @if'i: added() true olunca paragraf, role="status" ve metniyle birlikte tek seferde DOM'a ekleniyor. Screen reader'lar live region'daki değişikliği duyurur; metniyle birlikte gelen yeni bir region'ın okunup okunmadığı tarayıcı ve screen reader ikilisine göre değişir. MDN'in (Mozilla'nın web dokümantasyonu) live region rehberi de bunu söylüyor: region'ı önce boş olarak kur, tree'ye yansıması için zaman tanı, sonra içini değiştir; en güvenilir yol region'ın ilk markup'ta olması. İkinci satır ikinci bir sorunu gösteriyor: buton ikinci kez basılınca metin aynı kaldığı için değişiklik yok, bildirim de yok.
Angular'da bu tuzağa sık düşülür, çünkü @if, @for ve @switch element'leri yaratıp yok eder. Kural: live region'ı control flow bloğunun dışında, her zaman render edilen bir element olarak koy; değişen şey yalnız içindeki metin olsun.
Katalog: Sonuç Sayısı ve aria-busy
Katalogdaki "20 books" satırı tam bir status message. Eskiden books.isLoading() sırasında @if'in başka dalı render edildiği için satır her yüklemede yok olup yeniden yaratılıyordu. Satırı @if'in dışına aldık, metnini bir computed signal'den ürettik ve listeyi saran bölgeye aria-busy verdik:
<!-- Yeni sonuç yüklenirken aria-busy; içindeki status liste hazır olunca okunur -->
<section class="results" [attr.aria-busy]="books.isLoading()">
<!-- Hep DOM'da: screen reader metnindeki değişikliği duyurur, metniyle birlikte beliren region'ı değil (WCAG 4.1.3).
aria-live, role="status"un zaten söylediğini eski tarayıcı ve screen reader ikilileri için tekrarlar. -->
<p class="muted" role="status" aria-live="polite" data-testid="book-count" [class.error]="books.error()">{{ resultText() }}</p>
@if (books.isLoading()) {
<p class="muted">Loading books…</p>
} @else if (books.value(); as result) {
// Status mesajının metni: listenin şu an gösterdiği ve onu üreten filtreler
protected readonly resultText = computed(() => {
if (this.books.error()) {
return 'The catalog could not be loaded. Is Bookstore.Api running?';
}
const result = this.books.value();
if (!result) {
return '';
}
const category = this.categories.value().find(c => c.slug === this.category())?.name;
return `${result.total} ${result.total === 1 ? 'book' : 'books'}` +
(category ? ` in ${category}` : '') + (this.search() ? ` for "${this.search()}"` : '');
});
page loaded
live polite: "20 books"
focus: (nothing focused)
category: Science Fiction
live polite: "3 books in Science Fiction"
focus: radio "Science Fiction"
search: tolkien
live polite: "1 book for "tolkien""
focus: searchbox "Search books"
search: asimov tolkien
live polite: "0 books for "asimov tolkien""
focus: searchbox "Search books"
Sayfa açılırken bir kez, sonra her filtre değişiminde yeni metin okunuyor. Metne filtreyi de koymamızın nedeni ikinci tahmin sorusu: "1 book"tan sonra başka bir arama yine 1 sonuç verirse metin değişmez ve hiçbir şey duyurulmaz; "1 book for "tolkien"" ise her aramada farklı. Sıfır sonuçta da bir mesaj var, çünkü "hiçbir şey olmadı" ile "hiç sonuç yok" screen reader kullanıcısı için aynı sessizlik. Sayfa açılışındaki duyuru da bir tercih: region ilk render'da boş, liste gelince doluyor. İstemiyorsan ilk değeri boş bırakmanın bir yolunu bul, ama kullanıcının sayfanın başında sonucu duyması çoğu zaman işe yarar.
Yavaş bir ağda ne olduğunu görmek için kaydediciye kitap listesi yanıtlarını Playwright'ın page.route'u ile bir saniye geciktiren bir mod ekledim. Chromium'un tree'de verdiği property'ler:
while loading:
generic "" busy=1
status "" live=polite atomic=true relevant=additions text
loaded:
status "" live=polite atomic=true relevant=additions text
announcements
live polite: "1 book in Fantasy"
focus: radio "Fantasy"
Yükleme sırasında container'da busy var; status node'u da aria-atomic yazmadığımız hâlde atomic=true taşıyor, yani role="status"'un varsayılanları tree'ye gerçekten yazılıyor. aria-live="polite"'ı ise kendimiz ekledik: role zaten bunu söylüyor, ama MDN eski tarayıcı ve screen reader ikilileriyle uyum için tekrar yazmayı öneriyor (role="alert"'te tersine, iOS'taki VoiceOver'da çift okumaya yol açtığı için önermiyor). ARIA'ya göre aria-busy="true" olan bir bölgedeki değişiklikleri screen reader bitene kadar bekletebilir; kaydedicimiz de öyle davranıyor ve yükleme bitince tek bir mesaj okuyor. Gerçek screen reader'ların aria-busy desteği eşit değil; bu yüzden asıl güvenceyi kodda verdik: yükleme sırasında metin boş, yükleme bitince tek seferde son hâline geçiyor. "Loading books…" görünen bir gösterge olarak kalıyor ama live region'ın içinde değil; her filtre değişiminde iki mesaj (önce "Loading", sonra sonuç) duymak gereksiz.
ariaNotify(): Live Region'sız Bildirim
Kitap sayfasında durum farklı: duyurmak istediğimiz metin ("Dune added to your cart. Cart: 2 items.") ekranda yazanla aynı değil, ikinci tıklamada da duyurulmalı. Bunun için görünmez bir live region tutmak yerine yeni ariaNotify()'ı kullanabiliriz. web-features verisine göre Chrome ve Edge 141'de (Eylül/Ekim 2025), Firefox 150'de (Nisan 2026) ve Safari 27'de geldi ve 14 Eylül 2026'da Baseline Newly available oldu. MDN'e göre document.ariaNotify(text, { priority }) metni screen reader'ın kuyruğuna koyar; "normal" (varsayılan) kabaca polite'a, "high" assertive'e karşılık gelir; ses <html lang>'e göre seçilir; arka arkaya iki çağrıda bazı screen reader'lar yalnız sonuncusunu okur, o yüzden ilgili mesajları tek cümlede birleştirmek gerekir. Permissions Policy ile engellenmişse hata da vermeden sessizce hiçbir şey yapmaz.
Seçenek iki: her şeyi live region'la yapmak ya da metni DOM'da olmayan bildirimler için ariaNotify()'ı tercih edip desteklemeyen tarayıcıda live region'a düşmek. İlkinin avantajı yıllardır her screen reader'da denenmiş olması; dezavantajı, görünmez region'ı her component'te doğru zamanda doğru yerde tutmak zorunda olman. İkincisinde DOM'a dokunmadan tek satırla bildirim yaparsın, ama API yeni: tarayıcı desteği bir yıl önce tamamlandı ve screen reader tarafındaki davranışı bu yazıda ölçemedik. Bookstore için ikincisini seçtik ve yedeği Angular CDK'nın LiveAnnouncer'ı yaptık:
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { Injectable, inject } from '@angular/core';
// TypeScript 6.0'ın lib.dom'unda ariaNotify() henüz yok (Eylül 2026'dan beri Baseline)
declare global {
interface Document {
ariaNotify?(announcement: string, options?: { priority?: 'normal' | 'high' }): void;
}
}
// Focus'u taşımadan screen reader'a bir değişikliği bildirir (WCAG 4.1.3)
@Injectable({ providedIn: 'root' })
export class Announcer {
// CDK aria-live element'ini oluşturulur oluşturulmaz <body>'ye ekler; ilk mesajdan önce DOM'da olur
private readonly liveAnnouncer = inject(LiveAnnouncer);
announce(message: string, priority: 'normal' | 'high' = 'normal'): void {
if (document.ariaNotify) {
document.ariaNotify(message, { priority });
return;
}
// ariaNotify() olmayan tarayıcılar: aynı mesaj CDK'nın live region'ı üzerinden
void this.liveAnnouncer.announce(message, priority === 'high' ? 'assertive' : 'polite');
}
}
protected async addToCart(book: BookDetails, quantity: number): Promise<void> {
await this.cartStore.add(book.isbn, quantity);
this.added.set(true);
// Görünen "Added." ikinci tıklamada değişmez; bildirim kitabın adını ve yeni adedi söyler
const count = this.cartStore.itemCount();
this.announcer.announce(`${book.title} added to your cart. Cart: ${count} ${count === 1 ? 'item' : 'items'}.`);
}
page loaded
(nothing announced)
focus: (nothing focused)
Enter on "Add to cart"
ariaNotify normal: "Dune added to your cart. Cart: 1 item."
focus: button "Add to cart"
Enter again
ariaNotify normal: "Dune added to your cart. Cart: 2 items."
focus: button "Add to cart"
İki ayrıntı. TypeScript 6.0'ın lib.dom'unda ariaNotify henüz yok; method'u optional olarak kendimiz tanımladık, bu da feature detection'ı tip seviyesinde zorunlu kılıyor. Bildirimi bir effect() içinde itemCount'u izleyerek değil, butonun handler'ında yapıyoruz: effect sepet sayfa açılırken API'den yüklendiğinde de çalışır ve kimsenin istemediği bir "Cart: 2 items" okutur. Bildirim, kullanıcının yaptığı işin sonucu olduğu yerde üretilmeli.
Yedek yolu denemek için kaydediciyi ariaNotify'ı silen bir init script'le çalıştırdım (node announce.mjs book no-arianotify):
page loaded
(nothing announced)
focus: (nothing focused)
Enter on "Add to cart"
live polite atomic: "Dune added to your cart. Cart: 1 item."
focus: button "Add to cart"
Enter again
live polite atomic: "Dune added to your cart. Cart: 2 items."
focus: button "Add to cart"
.cdk-live-announcer-element on screen: 1x1 px
LiveAnnouncer, servis ilk inject edildiğinde <body>'nin sonuna aria-live="polite" ve aria-atomic="true" taşıyan bir div ekliyor; announce() önce içini boşaltıp yaklaşık 100 ms sonra yeni metni yazıyor. Bu yüzden aynı mesaj ikinci kez de duyuruluyor ve region mesajdan önce DOM'da hazır oluyor. Son satırdaki 1x1 piksel ise CDK'nın a11y CSS'i sayesinde; angular.json'a eklemeden önce aynı ölçüm şunu verdi:
.cdk-live-announcer-element on screen: 1264x18 px
Element sayfanın en altında, tam genişlikte, mesajı herkese gösteren bir satırdı. .cdk-visually-hidden class'ının stili @angular/cdk/a11y-prebuilt.css'te; Angular Material kullanan projelerde tema bunu zaten getirir, CDK'yı tek başına kullananlar unutur:
"styles": [
"node_modules/@angular/cdk/a11y-prebuilt.css",
"src/styles.css"
]
Form Hataları: Hata Özeti ve Focus
Sipariş formunda iki tür hata var: istemcinin bulduğu alan hataları ve API'nin döndüğü hatalar (400 doğrulama, 409 stok yetmezliği). Burada da iki yol var. Birincisi focus'u ilk hatalı alana taşımak ve hata sayısını bir live region'la duyurmak. İkincisi İngiltere hükümetinin ortak web sitesinin (GOV.UK) Design System'indeki error summary: formun üstünde "There is a problem" başlıklı, her hatayı ilgili alana bağlayan bir kutu; submit'ten sonra focus bu kutuya gider. Birincisi daha az markup ister, ama alana bağlanamayan sunucu hataları için yine ayrı bir yer gerekir. İkincisi kullanıcıya önce resmin tamamını verir, sonra link'lerle tek tek alanlara götürür; focus taşındığı için live region'a da gerek kalmaz (Understanding sayfası role="alert"'ün yalnız focus taşınmıyorsa gerektiğini söylüyor). Bookstore'da ikincisini seçtik; alanların yanındaki aria-invalid ve aria-describedby önceki yazıdaki gibi kalıyor:
@if (errorSummary().length) {
<!-- Başarısız submit'ten sonra focus'u alır: heading adı, liste description'ı olur -->
<section #summary class="error-summary" tabindex="-1" aria-labelledby="error-summary-title"
aria-describedby="error-summary-list" data-testid="error-summary">
<h2 id="error-summary-title">There is a problem</h2>
<ul id="error-summary-list">
@for (item of errorSummary(); track item.message) {
<li>
@if (item.fieldId) {
<a [href]="'#' + item.fieldId" (click)="focusField($event, item.fieldId)">{{ item.message }}</a>
} @else {
{{ item.message }}
}
</li>
}
</ul>
</section>
}
// Özet için tek liste: önce form sırasıyla alan hataları, sonra API'den gelen hata
protected readonly errorSummary = computed<SummaryItem[]>(() => [
...(this.nameError() ? [{ message: this.nameError(), fieldId: 'customer-name' }] : []),
...(this.emailError() ? [{ message: this.emailError(), fieldId: 'customer-email' }] : []),
...(this.serverError() ? [{ message: this.serverError(), fieldId: null }] : []),
]);
if (this.errorSummary().length) {
this.focusAfterRender(this.summary);
return;
}
try {
this.placedOrder.set(await this.store.placeOrder(nameBox.value, emailBox.value));
this.focusAfterRender(this.confirmation);
} catch (error) {
this.serverError.set(this.describe(error));
this.focusAfterRender(this.summary);
}
}
// <base href="/"> varken href="#customer-name" sepetten çıkarırdı; focus'u alana kendimiz taşıyoruz
protected focusField(event: Event, fieldId: string): void {
event.preventDefault();
this.host.nativeElement.querySelector<HTMLElement>(`#${fieldId}`)?.focus();
}
// Focus'u bir sonraki render'dan sonra ver; o anda element var ve metni tamam
private focusAfterRender(target: () => ElementRef<HTMLElement> | undefined): void {
afterNextRender(() => target()?.nativeElement.focus(), { injector: this.injector });
}
// API ProblemDetails döner: doğrulama hatasında 400, stok yetmezse 409
private describe(error: unknown): string {
const problem = error instanceof HttpErrorResponse ? error.error : null;
if (error instanceof HttpErrorResponse && error.status === 409) {
// API kitapları ISBN ile listeler; kullanıcı onları adıyla bilir
const titles = this.store.cart()?.items.filter(line => problem?.detail?.includes(line.isbn)).map(line => line.title) ?? [];
if (titles.length) {
return `Not enough stock for ${titles.join(', ')}. Lower the quantity and try again.`;
}
}
return problem?.detail ?? problem?.title ?? 'The order could not be placed. Try again in a few minutes.';
}
submit: name empty, e-mail "ada"
(nothing announced)
focus: region "There is a problem" description="Enter your full name. Enter an e-mail address like name@example.com."
submit: valid form, 4 x War and Peace (3 in stock)
(nothing announced)
focus: region "There is a problem" description="Not enough stock for War and Peace. Lower the quantity and try again."
Focus'taki kutu artık bir region: adı heading'den, description'ı listeden geliyor. Bir screen reader focus'u aldığında adı, role'ü ve description'ı okuyacak bilgiye sahip; özette kaç hata olduğu ve ne yapılması gerektiği tek seferde var. Tab ile özetteki ilk link'e, oradan Enter ile alana gidiliyor. Link'lerde önceki yazılardan tanıdık bir tuzak var: <base href="/"> olduğu için href="#customer-name" sepetten çıkıp /#customer-name'e giderdi; skip link'te yaptığımız gibi focus'u kendimiz taşıyoruz.
409 mesajı da değişti. API stok sorununu ISBN (kitapların uluslararası standart numarası) listesiyle dönüyor: "Not enough stock for: 9781400079988." Bu metin teknik olarak bir hata bildirimi, ama 3.3.3 Error Suggestion'ın beklediği gibi neyin nasıl düzeltileceğini söylemiyor. Sepetteki satırlardan kitap adını bulup "Lower the quantity and try again" ekledik. GOV.UK ayrıca sayfa başlığının başına "Error: " koymayı öneriyor; Bookstore'da bunu henüz yapmadık, Küçük Bir Deneme'de var.
Başarılı siparişte form ve "Place order" butonu yok oluyordu ve focus <body>'ye düşüyordu. Onay satırını tabindex="-1" taşıyan bir h2 yaptık ve render'dan sonra focus'u ona verdik:
@if (placedOrder(); as order) {
<!-- Siparişten sonra focus'u alır: form ve butonu kayboldu, yoksa focus <body>'ye düşerdi -->
<h2 #confirmation tabindex="-1" class="success" data-testid="order-confirmation">
Order #{{ order.id }} placed ({{ order.status }}). Total: {{ order.total | currency: 'TRY' : 'symbol-narrow' }}
</h2>
<a routerLink="/">Continue shopping</a>
submit: valid order
(nothing announced)
focus: heading "Order #2 placed (Placed). Total: ₺279.00"
Bu düzeltmelerle accessibility statement sayfasındaki iki açık satır (duyurulmayan mesajlar ve sunucu hataları, 3.3.1 ve 4.1.3) kalktı; liste boşken "No known limitations at the moment" yazıyor. node scan.mjs dört sayfada yine sıfır WCAG ihlali veriyor; yalnız axe'ın bu yazıdaki hiçbir sorunu ilk hâlinde de bulmadığını unutma, live region'ın zamanlaması statik bir taramanın göremeyeceği bir şey.
Toast'lar ve WCAG'in Zaman Kriterleri
Birçok uygulama "Added to cart" gibi mesajları birkaç saniye sonra kaybolan bir toast olarak gösterir. Burada iki kriter devreye giriyor. 2.2.1 Timing Adjustable (A), içeriğin koyduğu süre sınırlarının kapatılabilmesini, ayarlanabilmesini ya da uzatılabilmesini ister. Understanding sayfasındaki örnek tam bir toast: yeni e-postayı beş saniyelik bir toast ile bildiren bir istemci, kullanıcı aynı bilgiye Inbox'tan da ulaşabildiği için bu toast'u süre sınırı saymıyor; bilginin başka bir yolu yoksa mesaj kriteri karşılamalı. İkincisi 4.1.3: toast'un metni bir live region'dan geçmiyorsa screen reader kullanıcısı mesajı hiç duymaz, duysa bile toast kaybolduktan sonra ona geri dönüp okuyamaz.
Pratik kurallar: toast'taki bilgi başka bir yerde de olsun (sepet adedi header'da güncelleniyor); toast içinde bir eylem varsa (ör. "Undo") süreyle kaybolmasın ya da focus alana kadar beklesin; mouse üzerindeyken ya da focus içindeyken kapanmasın; metni live region ya da ariaNotify() ile ayrıca duyur. Bookstore'da görünür "Added." mesajı kaybolmuyor; bildirim ayrı bir kanaldan gidiyor. CDK'nın announce()'unun üçüncü parametresi olan duration da yalnız region'ı temizler, görünen bir şeyi kaldırmaz.
Üretimde Dikkat
Bildirimleri az ve anlamlı tut. Her tuş vuruşunda sonuç sayısını duyuran bir "arama yaparken filtrele" kutusu, kullanıcının yazdığını duymasını engeller. Böyle durumlarda metni kısa bir debounce ile güncelle ya da yalnız Enter'da duyur; Bookstore'un araması zaten submit ile çalışıyor.
Bildirimin dilini sayfanın diliyle aynı tut. ariaNotify() sesi <html lang>'e göre seçiyor; İngilizce bir sayfada Türkçe bildirim, yanlış telaffuzla okunur. Çok dilli bir uygulamada mesajlar da çeviriden geçmeli.
assertive'i yalnız kullanıcının hemen bilmesi gereken şeylere sakla. Oturumun düşmek üzere olması assertive olabilir; "Saved" polite yeter.
Testlerde bildirimin metnini de doğrula. Kaydedicinin yaptığını Playwright testlerinde role="status" locator'ı ve ariaNotify spy'ı ile yapabilirsin; bu testlerin CI'a nasıl bağlanacağı serinin son yazısının konusu.
Sık Düşülen Tuzaklar
Birincisi, live region'ı metniyle birlikte DOM'a eklemek. @if içindeki role="status" çoğu zaman okunmaz; region önceden durmalı.
İkincisi, aynı metni tekrar yazıp duyurulmasını beklemek. Metin değişmezse değişiklik yoktur; ya metni farklılaştır ya da CDK gibi önce temizleyip sonra yaz.
Üçüncüsü, her şeyi role="alert" yapmak. Assertive mesajlar o anki konuşmayı keser; sık kullanıldığında kullanıcı sayfayı takip edemez.
Dördüncüsü, CDK'nın LiveAnnouncer'ını a11y CSS'i olmadan kullanmak. Mesaj sayfanın altında herkese görünür.
Beşincisi, başarılı ya da başarısız bir submit'ten sonra focus'u kendi hâline bırakmak. Tıklanan buton yok olduysa focus <body>'ye düşer ve kullanıcı sayfanın başından başlar.
Altıncısı, bildirimi bir effect()'e bağlamak. Signal yalnız kullanıcının işlemiyle değil, yükleme ve yenilemeyle de değişir; bildirim kullanıcının eyleminin olduğu yerde üretilmeli.
Küçük Bir Deneme
★☆☆ Katalogdaki resultText'ten filtre kısmını çıkar ve node announce.mjs catalog'u çalıştır. Aynı sayıda sonuç veren iki arama arasında ne duyuruluyor?
★★☆ Hata özeti göründüğünde sayfa başlığının başına "Error: " ekle, hatalar kalkınca geri al. Title servisini ve errorSummary signal'ini kullan; announce.mjs checkout'a page.title() çıktısını ekleyip ölç.
★★★ Sepet sayfasında adet değişince satır toplamını ve sepet toplamını duyur ("War and Peace: 2 copies. Cart total: ₺1,378.00." gibi). Her ok tuşu basışında bir bildirim mi, yoksa değişiklik bitince tek bir bildirim mi daha iyi? change ile input event'leri arasındaki farkı kaydediciyle ölç.
Bu yazıda bildirimlerin tree'ye ve live region'lara doğru yazıldığını ölçtük, ama bir screen reader'ın onları ne zaman, hangi sırayla ve nasıl okuduğunu duymadık. Bir sonraki yazıda NVDA (NonVisual Desktop Access; Windows için ücretsiz screen reader), VoiceOver ve TalkBack'in temel komutlarına, browse mode ile focus mode farkına ve accessibility tree'yi screen reader çıktısıyla karşılaştırmaya bakıyoruz.