Angular'da Accessible Component'ler: Angular Aria, CDK a11y, Material ve Kendi Combobox'ını Yazmamak
Combobox, tabs gibi karmaşık widget'lar için sıra şudur: native element, sonra Angular Material, sonra headless Angular Aria (22.0.0'dan beri stabil), en son CDK primitive'leri. Bookstore'a Angular Aria ile arama önerileri ve kitap sekmeleri ekliyor, kütüphanenin bize bıraktığı üç kararı ve gizli panel CSS'ini ölçüyor, Signal Forms'un aria-invalid yazmadığını gösterip hata bağlantısını kuruyoruz.
Sürüm notu: Bu yazıdaki örnekler WCAG (Web Content Accessibility Guidelines; web içeriği için accessibility kılavuzu) 2.2, Angular 22.2 (TypeScript 6.0), Angular Aria 22.2, 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çı) arama kutusuna öneri listesi eklemek istiyoruz: "the" yazınca altında "The Hobbit", "The Great Gatsby" gibi başlıklar çıksın. APG'nin (ARIA Authoring Practices Guide; widget'ların klavye ve ARIA davranışını tarif eden rehber) combobox pattern'ine göre bunun için kaç ARIA attribute'u ve kaç tuş davranışı yazman gerekir? İkinci soru: Angular 21 ile gelen Angular Aria, Angular 22'de hâlâ developer preview mı? Üçüncüsü: Signal Forms'un [formField] directive'i hatalı bir alana aria-invalid yazar mı?
Karar: Native, Material, Angular Aria, CDK
Serinin önceki yazılarında Bookstore'un her etkileşimini native element'lerle ya da birkaç ARIA attribute'uyla çözdük: kategori seçimi radio button, filtre paneli bir disclosure, silme onayı native dialog. Bir combobox, tabs ya da tree gerektiğinde ise iş değişiyor. Bu pattern'lerin APG'deki tarifleri sayfalarca; role, state, id bağlantıları, focus yönetimi ve onlarca tuş kombinasyonu içeriyor ve her birinin screen reader'larla test edilmesi gerekiyor. Bunu her projede sıfırdan yazmak yerine hazır bir çözüm seçmek için şu sırayı kullanıyoruz:

Angular Material, Material Design görünümüyle hazır component'ler verir; görünüm işine geliyorsa en kısa yol. Angular Aria ise headless: davranışı, klavyeyi, ARIA attribute'larını ve focus'u yönetir, görünümü tamamen sana bırakır. Bookstore'un kendi CSS'i olduğu için bizim yolumuz bu. CDK primitive'leri en alttaki katman: Angular Aria'nın kendisi de @angular/cdk'ya bağlı (paketin peer dependency'si @angular/cdk 22.2.7), Material da CDK üzerine kurulu.
İkinci tahmin sorusunun cevabı: hayır. Angular Aria Angular 21'de developer preview olarak geldi; angular/components deposunun CHANGELOG'una göre 3 Haziran 2026'da çıkan 22.0.0'da developer preview etiketi kaldırıldı. Aynı sürüm bir breaking change de getirdi: eski combobox ve autocomplete kaldırıldı, "simple combobox" adıyla geliştirilen sürüm Combobox adıyla stabil oldu. 22.2.7'nin type tanımlarında da @developerPreview etiketi yok. 21'den geçiyorsan combobox kodunu yeniden yazmaya hazırlıklı ol.
npm install @angular/aria@22.2.7
Angular Aria ile Arama Önerileri
Arama kutusunu bir combobox yaptık: input'a ngCombobox, popup'a ngComboboxPopup ve içindeki listeye ngListbox ile ngComboboxWidget. Popup açıkken focus input'ta kalıyor, aktif seçenek aria-activedescendant ile gösteriliyor:
<search>
<form (submit)="applySearch($event)">
<div class="search-field">
<!-- Angular Aria: role="combobox", aria-expanded, aria-controls ve aria-activedescendant ngCombobox'tan gelir -->
<input ngCombobox #combobox="ngCombobox" type="search" placeholder="Search by title, author or ISBN"
aria-label="Search books" [(value)]="query" />
<ng-template ngComboboxPopup [combobox]="combobox">
<!-- Focus input'ta kalır; aktif seçenek aria-activedescendant ile gösterilir -->
<div ngListbox ngComboboxWidget #listbox="ngListbox" class="suggestions" aria-label="Suggestions"
focusMode="activedescendant" selectionMode="explicit" [tabindex]="-1"
[activeDescendant]="listbox.activeDescendant()"
[(value)]="picked" (click)="pickSuggestion()" (keydown.enter)="pickSuggestion()">
<!-- İlk seçenek yazılan metnin kendisi; yazdıktan hemen sonra Enter yine onu arar -->
<div ngOption [value]="query().trim()" [label]="'Search for ' + query().trim()">Search for "{{ query().trim() }}"</div>
@for (title of suggestions(); track title) {
<div ngOption [value]="title" [label]="title">{{ title }}</div>
}
</div>
</ng-template>
</div>
<button type="submit">Search</button>
</form>
</search>
// Arama kutusunun altındaki öneriler için bütün başlıklar, bir kez
private readonly titles = httpResource<PagedResult<BookSummary>>(() => ({ url: '/api/books', params: { pageSize: 50 } }));
protected readonly hasQuery = computed(() => this.query().trim().length >= 2);
protected readonly suggestions = computed(() => {
const text = this.query().trim();
return (this.titles.value()?.items ?? []).map(book => book.title)
.filter(title => title !== text && title.toLowerCase().includes(text.toLowerCase())).slice(0, 8);
});
constructor() {
// ngCombobox her tuş vuruşunda popup'ı açar; iki karakter olana kadar kapalı tut
effect(() => {
if (this.combobox().expanded() && !this.hasQuery()) {
this.combobox().expanded.set(false);
}
});
}
protected applySearch(event: Event): void {
event.preventDefault();
this.combobox().expanded.set(false);
this.search.set(this.query().trim());
}
// Enter ya da bir seçeneğe tıklama: seçeneğin değeri aranacak metindir (yazılan metin ya da bir başlık)
protected pickSuggestion(): void {
const text = this.picked()[0];
if (text) {
this.query.set(text);
this.search.set(text);
}
this.picked.set([]);
this.combobox().expanded.set(false);
// Tıklamadan sonra popup ve seçenekleri yok olur; focus'u input'a geri ver
this.combobox().element.focus();
}
Davranışı ölçmek için klavyeyle kullanan ve her tuştan sonra tree'nin söylediğini yazan bir script yazdık (node widgets.mjs combobox):
type "t" combobox "Search books" value="t" expanded=false
type "he" combobox "Search books" value="the" expanded=true active="Search for "the""
ArrowDown combobox "Search books" value="the" expanded=true active="The Count of Monte Cristo"
ArrowDown combobox "Search books" value="the" expanded=true active="The Great Gatsby"
Escape combobox "Search books" value="the" expanded=false
ArrowDown combobox "Search books" value="the" expanded=true active="Search for "the""
ArrowUp combobox "Search books" value="the" expanded=true active="Wuthering Heights"
Enter combobox "Search books" value="Wuthering Heights" expanded=false
result: 1 book for "Wuthering Heights"
click "The Hobbit": 1 book for "The Hobbit", focus combobox "Search books"
- combobox "Search books" [expanded]: the
- listbox "Suggestions":
- option "Search for \"the\""
- option "The Count of Monte Cristo"
- option "The Great Gatsby"
- option "The Hobbit"
- option "The Left Hand of Darkness"
- option "Wuthering Heights"
axe (popup open): no violations, passed rules=17
Input combobox role'üyle ve expanded durumuyla tree'de; açılan liste listbox, seçenekler option. Ok tuşları seçenekler arasında geziyor (ilk seçenekte yukarı ok listenin sonuna gidiyor), Escape kapatıyor, Enter seçiyor. Focus hiçbir adımda input'tan ayrılmıyor; screen reader aria-activedescendant'ın gösterdiği seçeneği okur. axe popup açıkken de ihlal bulmadı. Bunların hiçbiri için tek satır ARIA ya da keydown kodu yazmadık.
Üç karar ise bize kaldı ve üçü de ilk denemede yanlıştı. Birincisi: Angular Aria'nın listbox'ı açılır açılmaz ilk seçeneği aktif yapıyor; "the" yazıp Enter'a basan kullanıcı, yazdığını değil ilk öneriyi ("The Count of Monte Cristo") arıyordu. Listenin başına yazılan metnin kendisini bir seçenek olarak koyduk ("Search for "the""); Enter artık yazılanı arıyor, öneriye ok tuşuyla gidiliyor. İkincisi: ngCombobox her tuş vuruşunda popup'ı açıyor; tek harfte boş bir listbox'ı "expanded" duyurmamak için iki karakterden azında kapatıyoruz. Bunu ilk önce kendi signal'imize bağlı [(expanded)] ve bir effect() ile denedik; effect signal'i false yaptı ama binding'in son değeri zaten false olduğu için Angular directive'e yeni değer göndermedi ve popup açık kaldı. Combobox'ın kendi expanded model'ini doğrudan kapatmak sorunu çözdü. Üçüncüsü fareyle seçimdi; ilk ölçüm şuydu:
click "The Hobbit": 1 book for "The Hobbit", focus (nothing focused)
Tıklanan seçenek popup'la birlikte DOM'dan (Document Object Model; sayfanın tarayıcıdaki element ağacı) çıkınca focus <body>'ye düştü. Angular dokümanındaki örnek de seçimden sonra focus'u input'a geri veriyor; pickSuggestion()'ın son satırı bunu yapıyor.
Kendi Combobox'ını Yazsaydın
Tahminin ilk sorusuna ölçtüğümüz kadarıyla cevap verelim. Bu küçük öneri listesi için Angular Aria'nın bizim yerimize yaptıkları:
- Input'ta
role="combobox",aria-autocomplete="list",aria-haspopup="listbox",aria-expanded,aria-controlsvearia-activedescendant; listede ve seçeneklerde role, id,aria-selected. - Her render'da üretilen benzersiz id'ler ve aralarındaki bağlantılar (listbox'ın id'si değişince
aria-controlsda değişir). - Aşağı ve yukarı ok, uçlarda başa ya da sona dönme, Home, End, PageUp ve PageDown'ın listeye aktarılması, Escape, Enter, fareyle seçim.
- Focus input'tan çıkınca popup'ın kapanması ve popup içindeki tıklamanın bunu tetiklememesi.
- Seçenek etiketinin (
label) görünen metinden ayrı verilebilmesi veselectionModeile seçimin oklarla mı yoksa yalnız Enter ile mi yapılacağı.
Bunlar input başına birkaç düzine satır keydown mantığı ve id yönetimi demek; daha önemlisi her biri bir screen reader'da yanlış çalışabilecek bir ayrıntı. Kendi combobox'ını yazdığında bu listeyi baştan test etmen gerekir; Angular Aria'yı kullandığında yalnız kendi kararlarını (yukarıdaki üçü gibi) test edersin.
Kitap Sayfası: Angular Aria Tabs
Serinin ARIA yazısındaki ★★★ alıştırma, kitap sayfasına "Description" ve "Details" sekmelerini APG'ye göre elle yazmaktı. Angular Aria ile aynı iş template'te birkaç directive:
<!-- Angular Aria tabs: role'ler, aria-selected, aria-controls, roving tabindex, oklar, Home ve End -->
<div ngTabs class="book-tabs">
<div ngTabList selectedTab="description" aria-label="About this book">
<div ngTab value="description">Description</div>
<div ngTab value="details">Details</div>
</div>
<div ngTabPanel value="description">
<ng-template ngTabContent><p>{{ b.description }}</p></ng-template>
</div>
<div ngTabPanel value="details">
<ng-template ngTabContent>
<p>ISBN {{ b.isbn }}</p>
<p>Published in {{ b.publishedYear }} · {{ b.category }}</p>
</ng-template>
</div>
</div>
- tablist "About this book":
- tab "Description" [selected]
- tab "Details"
- tabpanel "Description":
- paragraph: A desert planet, a noble family and the most valuable substance in the universe.
- tabpanel "Details"
Tab tab "Description" panel: A desert planet, a noble family and the most val
ArrowRight tab "Details" panel: ISBN 9780441172719 Published in 1965 · Science F
ArrowRight tab "Description" panel: A desert planet, a noble family and the most val
End tab "Details" panel: ISBN 9780441172719 Published in 1965 · Science F
Home tab "Description" panel: A desert planet, a noble family and the most val
Tab tabpanel "Description" panel: A desert planet, a noble family and the most val
Shift+Tab tab "Description" panel: A desert planet, a noble family and the most val
Tab ile sekme listesine tek bir durakla giriliyor (roving tabindex), ok tuşları sekmeler arasında geçiyor ve sekmeyi aynı anda seçiyor (selectionMode'un varsayılanı "follow"), Home ve End uçlara gidiyor; bir sonraki Tab panele gidiyor, çünkü panel tabindex="0" taşıyor. Bu davranışların hepsi APG'nin tabs tarifinde var. Tree'de ise bir fazlalık var: seçili olmayan "Details" paneli boş bir tabpanel olarak görünüyor. Angular Aria seçili olmayan paneli inert yapıyor ve içeriğini DOM'dan kaldırıyor, ama gizlemiyor; headless bir library'de gizlemek stylesheet'in işi:
/* Angular Aria seçili olmayan paneli inert yapar; gizlemek stylesheet'in işi */
[ngTabPanel][inert] { display: none; }
- tablist "About this book":
- tab "Description" [selected]
- tab "Details"
- tabpanel "Description":
- paragraph: A desert planet, a noble family and the most valuable substance in the universe.
Panel artık tree'de yok. "Headless" sözünün bedeli bu: davranış hazır, ama seçili sekmenin görünümü, gizli panelin gizlenmesi ve aktif seçeneğin işaretlenmesi (data-active attribute'u üzerinden) senin CSS'inde. Bu CSS'i atlarsan klavye çalışır, tree yanlış ya da ekran yanıltıcı olur.
CDK a11y Primitive'leri
Angular Aria'da karşılığı olmayan bir etkileşim için @angular/cdk/a11y'ye inersin. Paketin 22.2.7'deki önemli parçaları şunlar: cdkTrapFocus ve FocusTrap (native dialog kullanamadığın bir overlay'de focus'u içeride tutmak), FocusMonitor (focus'un klavyeden mi, fareden mi, programdan mı geldiğini söyler), ListKeyManager ve ondan türeyen FocusKeyManager ile ActiveDescendantKeyManager (bir listede ok tuşu, Home, End ve typeahead mantığı), LiveAnnouncer (bir önceki yazıda ariaNotify()'ın yedeği olarak kullandık) ve InteractivityChecker (bir element'in focus alıp alamayacağını söyler). Bunlar pattern değil yapı taşı: role'leri, id bağlantılarını ve tuşların anlamını yine sen kurarsın. Bu yüzden CDK'ya indiğinde APG'deki ilgili pattern'i açıp attribute ve tuş listesini adım adım uygulaman gerekir.
Angular'a Özgü Tuzaklar
Birincisi host element. <app-book-card> gibi bir component'in host'u tarayıcı için anlamsız bir element'tir; <ul>'un içinde <li> yerine durursa liste semantiği bozulur. Çözüm attribute selector'lar (li[appBookCard]) ya da host binding'le role vermek; HTML serisinin "Accessible Markup Temelleri" yazısında ölçüldü, burada tekrar etmiyoruz.
İkincisi (click) bağlanmış div. Template'te bir div'e (click) eklemek kolay ve klavye kullanıcısı için görünmez bir buton üretir; serinin ARIA yazısında ölçtüğümüz div role="button" sorunu. angular-eslint'in template accessibility kuralları bunu yakalar; CI'a bağlanması serinin son yazısının konusu.
Üçüncüsü ARIA binding'leri. Angular 22.2'de ölçtüğümüz kadarıyla [aria-label] de [attr.aria-label] de aynı attribute'u yazıyor ve değer null olunca ikisi de attribute'u kaldırıyor; Angular dokümanının örnekleri attr. önekini kullanıyor. Asıl tuzak değerin kendisi: aria-describedby'ı, render edilmemiş bir mesajın id'sine bağlamak (bu yüzden Bookstore'da mesaj yokken null veriyoruz) ya da [attr.aria-expanded]'a undefined gönderip attribute'u hiç yazmamak.
Dördüncüsü Signal Forms. Sipariş formunu Signal Forms'a taşıdık: model bir signal, kurallar ve mesajları schema'da, alan template'e [formField] ile bağlanıyor. İlk denemede ARIA binding'lerini kaldırıp yalnız [formField] bıraktık ve formu boş gönderdik:
before submit: <input id="customer-name" autocomplete="name" name="ng.form0.name" required="">
after submit: <input id="customer-name" autocomplete="name" name="ng.form0.name" required="">
focus: textbox "Full name"
Üçüncü tahmin sorusunun cevabı: hayır. [formField] değeri, required'ı ve bir name yazıyor, ama alan geçersizken aria-invalid ya da mesaja bağlanan bir aria-describedby yazmıyor; focus'taki alan tree'de hatasız görünüyor. Hata durumunu ve mesajı önceki yazılardaki gibi kendimiz bağladık:
// Signal Forms: model bir signal, kurallar ve mesajları schema'da
protected readonly customer = signal({ name: '', email: '' });
protected readonly checkoutForm = form(this.customer, path => {
required(path.name, { message: 'Enter your full name.' });
required(path.email, { message: 'Enter your e-mail address.' });
email(path.email, { message: 'Enter an e-mail address like name@example.com.' });
});
// Mesajlar kullanıcı yazarken değil, ilk submit'ten sonra görünür
private readonly submitted = signal(false);
protected readonly nameError = computed(() => this.submitted() ? this.firstError(this.checkoutForm.name().errors()) : '');
protected readonly emailError = computed(() => this.submitted() ? this.firstError(this.checkoutForm.email().errors()) : '');
<!-- [formField] değeri, required'ı ve name'i yazar; hata durumunu ve mesajını kendimiz bağlarız -->
<input id="customer-name" autocomplete="name" [formField]="checkoutForm.name"
[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>
}
before submit: <input id="customer-name" autocomplete="name" name="ng.form0.name" required="">
after submit: <input id="customer-name" autocomplete="name" name="ng.form0.name" required="" aria-invalid="true" aria-describedby="customer-name-error">
focus: textbox "Full name" description="Enter your full name." invalid=true
Mesajları ilk submit'ten sonra gösteriyoruz; kullanıcı daha yazmaya başlamadan her alanın altında kırmızı bir hata görmek hem rahatsız edici hem de screen reader'da her alanda hata okunması demek. Hata özeti, focus ve sunucu hataları bir önceki yazıdaki gibi çalışıyor; announce.mjs checkout aynı sonucu veriyor.
Razor ve MVC Tarafı
Bookstore'un sunucu tarafı bir MVC (Model-View-Controller; ASP.NET Core'un sunucuda HTML üreten modeli) ya da Razor Pages arayüzüyse aynı sorular Tag Helper'lar için geçerli: asp-for label'ı ve input'u bağlıyor mu, validation mesajı alana aria-describedby ile bağlı mı, hata olduğunda aria-invalid basılıyor mu? HTML serisinin "Accessible Markup Temelleri" yazısında Bookstore checkout'unun e-posta alanı Razor Pages ile yazılıp Tag Helper çıktısı tek tek kontrol edildi; kısa cevap, label bağlantısı hazır geliyor, hata bağlantıları gelmiyor. Bu yazıdaki ölçüm script'leri sunucuda render edilen sayfalarda da aynen çalışır, çünkü tree'ye bakarlar, framework'e değil.
Üretimde Dikkat
Headless bir library'yi güncellemeyi bir UI değişikliği gibi test et. Angular Aria'nın 22.0.0 breaking change'i combobox API'sini değiştirdi; sürüm yükselttiğinde widgets.mjs gibi klavye script'lerini tekrar çalıştır.
Design system'de davranışı tek yerde tut. Combobox'ı bir kez Bookstore'un kendi component'i olarak sar (app-search-box gibi), Angular Aria'yı yalnız o component kullansın; üç sayfada üç farklı combobox kararı (ilk seçenek, açılma eşiği, focus dönüşü) kullanıcıyı şaşırtır.
Angular Aria'nın test harness'lerini kullan. 22.0.0 ile accordion, combobox, tabs, listbox, menu, tree, grid ve toolbar için harness'ler geldi; component testlerinde DOM sorgusu yerine bunlarla çalışmak, library'nin iç yapısı değiştiğinde testlerini korur. Component test'lerinin temeli Frontend Testleri serisinde.
Bir pattern Angular Aria'da yoksa önce tasarımı sorgula. Bookstore'un kategori seçimi için combobox yerine radio grubu daha az kod, daha az test ve daha iyi screen reader desteği demek.
Sık Düşülen Tuzaklar
Birincisi, headless library'nin görünümle ilgili state'leri de yöneteceğini sanmak. Gizli panelin gizlenmesi ve aktif seçeneğin işaretlenmesi CSS'te.
İkincisi, combobox'ta ilk seçeneğin otomatik aktif olmasını gözden kaçırmak. Kullanıcının yazdığı metni aramak istediği bir kutuda Enter'ın ne yaptığını mutlaka ölç.
Üçüncüsü, seçimden sonra focus'u unutmak. Popup kapanınca focus kaybolursa klavye kullanıcısı sayfanın başından başlar.
Dördüncüsü, iki yönlü binding'i bir effect ile düzeltmeye çalışmak. Binding'in son değeri değişmediyse directive yeni değeri almaz; directive'in kendi model'ini kullan.
Beşincisi, form library'sinin accessibility'yi de hallettiğini varsaymak. Signal Forms değeri ve doğrulamayı yönetiyor; hata durumunu ve mesajı tree'ye sen bağlarsın.
Küçük Bir Deneme
★☆☆ Listbox'tan selectionMode="explicit"'i kaldır ve node widgets.mjs combobox'ı tekrar çalıştır. Ok tuşlarıyla gezerken input'taki metin değişiyor mu, ve bu öneri listesini nasıl etkiliyor?
★★☆ Kitap sekmelerine selectionMode="explicit" ver. Ok tuşları artık yalnız focus'u mu taşıyor? widgets.mjs tabs'a Enter ve Space adımları ekleyip ölç; hangi davranış Bookstore'un iki sekmesi için daha uygun?
★★★ Kategori radio'larını Angular Aria'nın Listbox'ı ile tek seçimli bir listeye çevir, sonra elements.mjs catalog ve sr-script.mjs ile iki sürümü karşılaştır. Hangisi daha az kod, hangisi screen reader için daha anlaşılır? Sonucu bu yazıdaki karar akışıyla gerekçelendir.
Bu yazıya kadar her düzeltmeyi bir script'le ölçtük, ama script'leri elle çalıştırdık. Serinin son yazısında bu kontrolleri otomatiğe bağlıyoruz: angular-eslint, Playwright testlerinde axe, Lighthouse, mevcut borcu baseline'layan bir CI kapısı ve otomasyonun yakalayamadıkları.