WCAG 2.2'yi Geliştirici Gözüyle Okumak: En Sık Düşülen Kriterler ve Bookstore Audit'i
Lighthouse'un 100 accessibility puanı yalnız otomatik ölçülebilen kısmın temiz olduğunu söyler; non-text contrast, autocomplete, placeholder'ın label sayılması ve focus yönetimi gibi sorunları ancak manuel audit bulur. WebAIM Million 2026 verisiyle en sık düşülen sorunları, 100 puanlı Bookstore'da bulunan on iki sorunu, ilk düzeltmeleri, WCAG 2.2'nin yeni kriterlerinin ölçümünü ve bir accessibility statement sayfası taslağını anlatıyor.
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), Node.js 24, Playwright 1.65 (Chromium 158), axe-core 4.14 ve Lighthouse 13.6 ile denenmiştir.
Önce Tahmin Et
Bir önceki yazıda sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) üç sayfasını axe-core ile taradık ve tek bir WCAG ihlali çıktı. Bugün aynı sayfaları bir de Lighthouse'un accessibility kategorisinden geçireceğiz. Lighthouse'un accessibility puanı da axe kurallarına dayanıyor; katalog ve kitap detayı sayfası 100 üzerinden kaç alır?
Sonra aynı sayfalara bir insan gibi bakacağız: kenarlıkları ölçeceğiz, formları dolduracağız, sayfayı 320 piksele daraltacağız. Lighthouse 100 verdiyse manuel audit kaç sorun bulur: hiç, bir iki, ya da on civarı mı? Cevap, otomatik araçların neyi neden kaçırdığını da gösterecek.
Gerçek Dünyada En Sık Düşülen Sorunlar
WebAIM (Utah State University'deki web accessibility merkezi) her yıl en çok ziyaret edilen bir milyon sitenin ana sayfasını otomatik olarak tarıyor. Şubat 2026'da toplanan verilere göre ana sayfaların %95,9'unda otomatik olarak tespit edilebilen en az bir WCAG hatası var, sayfa başına ortalama 56,1 hata. Altı hata türü öne çıkıyor: düşük contrast'lı metin (sayfaların %83,9'u), alt text'i olmayan görseller (%53,1), label'ı olmayan form input'ları (%51), boş link'ler (%46,3), boş butonlar (%30,6) ve belirtilmemiş document dili (%13,5). WebAIM'in kendisi de rapora bir uyarı koyuyor: tespit edilen hatanın olmaması sayfanın erişilebilir olduğunu göstermez.
Bu altı başlığın Bookstore'daki karşılığına bakalım. Metin contrast'ı temiz: axe'ın color-contrast kuralı geçti; gri yardımcı metin #6b7280 beyaz üzerinde 4.83:1 ile AA'nın (çoğu düzenlemenin hedeflediği orta seviye) 4.5:1 sınırının üstünde. Contrast hesabının ayrıntısı ve palet kurmak UI/UX Tasarım İlkeleri serisinin "Color ve Contrast" yazısında. Bookstore'da kapak görseli yok, yani alt text sorunu da yok; görsellerin alt text kuralları HTML serisinin "Image ve Media" yazısında. Label'sız input'u bir önceki yazıda bulduk. Boş link ya da buton yok, html element'inde lang="en" var. Listede olmayan ama sık rastlanan bir şey daha var: heading yapısı. Katalog sayfasında hiç h1 yok; screen reader kullanıcıları sayfayı çoğu zaman heading'ler arasında atlayarak tarar.
Yani Bookstore bu istatistiğin "iyi" tarafında. Ama altı başlığın hepsi otomatik araçla tespit edilebilen türden; audit'in asıl işi, aracın göremediğini bulmak.
Lighthouse'un 100'ü Ne Demek?
Lighthouse'u DevTools panelinden çalıştırmak ve skoru okumak Browser DevTools serisinin "Lighthouse, Rendering ve Coverage" yazısında anlatıldı. Burada CLI ile, yalnız accessibility kategorisini çalıştırıp sonucu JSON olarak kaydediyoruz. Lighthouse makinedeki Chrome'u kullanır:
npx lighthouse http://127.0.0.1:52411/ --only-categories=accessibility --output=json --output-path=reports/catalog-before.json --quiet --chrome-flags="--headless=new"
npx lighthouse http://127.0.0.1:52411/books/9780441172719 --only-categories=accessibility --output=json --output-path=reports/book-before.json --quiet --chrome-flags="--headless=new"
node lh-summary.mjs reports/catalog-before.json reports/book-before.json
// Lighthouse JSON raporlarının accessibility skorunu ve başarısız audit'lerini yazdırır
// Kullanım: node lh-summary.mjs reports/*.json
import { readFileSync } from 'node:fs';
for (const file of process.argv.slice(2)) {
const lhr = JSON.parse(readFileSync(file, 'utf8'));
const category = lhr.categories.accessibility;
const failed = category.auditRefs
.map(ref => lhr.audits[ref.id])
.filter(a => a.score === 0)
.map(a => a.id);
const manual = category.auditRefs.filter(ref => lhr.audits[ref.id].scoreDisplayMode === 'manual').length;
console.log(`${file}: Lighthouse ${lhr.lighthouseVersion} accessibility ${Math.round(category.score * 100)}`);
console.log(` failed: ${failed.join(', ') || 'none'}; manual checks listed: ${manual}`);
}
reports/catalog-before.json: Lighthouse 13.6.0 accessibility 100
failed: none; manual checks listed: 10
reports/book-before.json: Lighthouse 13.6.0 accessibility 100
failed: none; manual checks listed: 10
İki sayfa da 100 aldı. Lighthouse'un accessibility puanı, içindeki axe tabanlı audit'lerin ağırlıklı ortalaması; katalog sayfasında 19 audit uygulanabilir çıktı, 47'si sayfada karşılığı olmadığı için "not applicable" sayıldı. Raporda bir de puana hiç girmeyen 10 manuel kontrol listeleniyor: Tab sırasının mantıklı olması, görsel sıranın DOM (sayfanın element ağacı) sırasıyla aynı olması, focus'un bir yerde hapsolmaması, yeni içerik geldiğinde focus'un yönetilmesi, custom control'lerin doğru role ve adı taşıması gibi. Lighthouse bunları "sen kontrol et" diye bırakıyor. 100, ölçülebilen kısmın temiz olduğu anlamına geliyor; o kadar.
axe'ın Ölçmediklerini Ölçmek
Manuel audit'in bir kısmı da script'le hızlandırılabilir. audit.mjs, axe'ın kontrol etmediği beş şeyi her sayfada ölçüyor: sayfa başlığı ve h1 sayısı, form alanlarının kenarlık contrast'ı, kişisel bilgi isteyen alanlarda autocomplete, 24 pikselden küçük tıklama hedefleri ve 320 piksel genişlikte yatay taşma:
// Manuel audit yardımcısı: Bookstore sayfalarında axe'ın kontrol etmediklerini ölçer
// Kullanım: node audit.mjs
import { chromium } from 'playwright';
import { open, pages } from './lib.mjs';
const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
for (const name of Object.keys(pages)) {
await open(page, name);
const result = await page.evaluate(() => {
const luminance = rgb => {
const [r, g, b] = rgb.match(/\d+(\.\d+)?/g).slice(0, 3).map(Number).map(v => {
v /= 255;
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const ratio = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return Math.floor(((hi + 0.05) / (lo + 0.05)) * 100) / 100;
};
// 1.4.11: metin alanının yerini gösteren tek şey kenarlığı
const fields = [...document.querySelectorAll('input:not([type=hidden]), select')].map(el => {
const style = getComputedStyle(el);
return `${el.name || el.type || el.tagName.toLowerCase()} ${ratio(style.borderTopColor, 'rgb(255, 255, 255)')}:1`;
});
// 1.3.5: kullanıcının kendi bilgisini isteyen alanlar autocomplete token'ı ister
const personal = [...document.querySelectorAll('input[name=name], input[type=email]')]
.map(el => `${el.name}=${el.getAttribute('autocomplete') ?? '(none)'}`);
// 2.5.8: 24x24 CSS pikselden küçük hedefler ve spacing istisnası onları kurtarıyor mu
const targets = [...document.querySelectorAll('a[href], button, input, select')]
.map(el => ({ el, r: el.getBoundingClientRect() })).filter(t => t.r.width > 0);
const isSmall = t => t.r.width < 24 || t.r.height < 24;
const center = t => [t.r.left + t.r.width / 2, t.r.top + t.r.height / 2];
const small = targets.filter(isSmall).map(t => {
const [cx, cy] = center(t);
// hedefin ortasındaki 24px'lik daire başka bir hedefe ya da başka bir küçük hedefin dairesine değmemeli
const crowded = targets.some(o => {
if (o === t) return false;
const nx = Math.max(o.r.left, Math.min(cx, o.r.right)), ny = Math.max(o.r.top, Math.min(cy, o.r.bottom));
const [ox, oy] = center(o);
return Math.hypot(cx - nx, cy - ny) < 12 || (isSmall(o) && Math.hypot(cx - ox, cy - oy) < 24);
});
return { label: (t.el.textContent || t.el.type).trim().slice(0, 20), w: Math.round(t.r.width), h: Math.round(t.r.height), crowded };
});
return { title: document.title, h1: document.querySelectorAll('h1').length, fields, personal, small };
});
// 1.4.10: 320 CSS piksel genişlikte yatay scroll gerekmemeli (data table'lar istisna)
await page.setViewportSize({ width: 320, height: 640 });
const overflow = await page.evaluate(() => {
const doc = document.documentElement;
const wide = [...document.querySelectorAll('main *')].filter(e => e.getBoundingClientRect().right > doc.clientWidth + 1);
return { scroll: doc.scrollWidth - doc.clientWidth, culprits: [...new Set(wide.map(e => e.closest('table') ? 'table' : e.tagName.toLowerCase()))] };
});
await page.setViewportSize({ width: 1280, height: 800 });
console.log(`--- ${name}`);
console.log(` title="${result.title}" h1=${result.h1}`);
console.log(` field borders: ${result.fields.join(', ') || '-'}`);
console.log(` autocomplete: ${result.personal.join(', ') || '-'}`);
const smallest = result.small.toSorted((x, y) => x.w * x.h - y.w * y.h)[0];
const crowded = result.small.filter(s => s.crowded).map(s => s.label);
console.log(` targets < 24px: ${result.small.length}` + (smallest ? ` (smallest "${smallest.label}" ${smallest.w}x${smallest.h}),` +
` without enough spacing: ${crowded.join(', ') || 'none'}` : ''));
console.log(` 320px: horizontal overflow ${overflow.scroll}px ${overflow.culprits.join(',')}`);
}
await browser.close();
--- catalog
title="Bookstore" h1=0
field borders: search 1.47:1, select-one 1.47:1
autocomplete: -
targets < 24px: 23 (smallest "Catalog" 56x18), without enough spacing: none
320px: horizontal overflow 0px
--- book
title="Book · Bookstore" h1=1
field borders: number 1.47:1
autocomplete: -
targets < 24px: 4 (smallest "Catalog" 56x18), without enough spacing: none
320px: horizontal overflow 0px
--- cart
title="Cart · Bookstore" h1=1
field borders: number 1.47:1, name 1.47:1, email 1.47:1
autocomplete: name=(none), email=(none)
targets < 24px: 4 (smallest "1984" 37x18), without enough spacing: none
320px: horizontal overflow 108px table
Kenarlık contrast'ı her sayfada 1.47:1. Metin contrast'ından farklı olarak bu 1.4.11'in (Non-text Contrast) konusu: bir metin alanını tanımlayan tek görsel ipucu kenarlığıysa, kenarlığın arka planla en az 3:1 contrast'ı olmalı. #d1d5db açık gri, az gören bir kullanıcı için arama kutusu ya da adet alanı beyaz zeminde neredeyse kayboluyor. axe bu kuralı hiç kontrol etmiyor.
Sipariş formundaki ad ve e-posta alanlarında autocomplete yok. 1.3.5 (Identify Input Purpose), kullanıcının kendisiyle ilgili bilgi isteyen alanların amacının programatik olarak belirlenebilmesini istiyor; pratikte doğru autocomplete token'ı. Böylece tarayıcı alanı otomatik doldurabilir ve bazı assistive technology'ler alanın yanına tanıdık bir ikon koyabilir. Motor engeli olan ya da hafıza sorunu yaşayan kullanıcı için her yazmadığı karakter bir kazanç. axe'ın autocomplete-valid kuralı yalnız var olan değerin geçerli olup olmadığına bakıyor; attribute hiç yoksa sessiz.
Tıklama hedeflerinde sorun yok: 18 piksel yüksekliğinde link'ler var, ama hepsinin etrafında yeterli boşluk var. 320 piksel genişlikte sepet tablosu 108 piksel taşıyor; 1.4.10 (Reflow) iki boyutlu düzeni anlam taşıyan data table'ları istisna tutuyor, ama tabloyu klavyeyle kaydırılabilir bir wrapper'a almak yine iyi bir fikir. Bunun nasıl yapılacağı HTML serisinin "Table'lar ve Tabular Data" yazısında.
Bookstore Audit Tablosu
Script'in çıktısını, bir önceki yazının taramasını ve klavyeyle yaptığım kısa bir turu tek tabloda topladım. Son sütun, sorunun nasıl test edildiğini ya da serinin hangi yazısında ele alınacağını söylüyor:
| Kriter | Sorun | Etkilenen kullanıcı | Düzeltme | Test |
|---|---|---|---|---|
| 4.1.2 Name, Role, Value | Sepetteki adet input'unun adı yok | Screen reader, sesle kontrol | Görünmez label: "Quantity of 1984" | axe label, accessibility tree |
| 3.3.2 Labels or Instructions | Sipariş formunda yalnız placeholder var | Bilişsel, az gören, screen reader | Görünür label ve for/id | Manuel; axe geçiriyor |
| 1.3.5 Identify Input Purpose | Ad ve e-postada autocomplete yok | Motor, bilişsel | autocomplete="name", "email" | audit.mjs |
| 1.4.11 Non-text Contrast | Form alanı kenarlığı 1.47:1 | Az gören | Kenarlık #6b7280 (4.83:1) | audit.mjs |
| 1.3.1 Info and Relationships | Katalogda h1 yok | Heading ile gezinen screen reader kullanıcısı | Görünür h1: "Catalog" | axe best practice |
| 1.3.1 Info and Relationships | Sepet tablosunda boş sütun başlığı | Tabloyu hücre hücre gezen screen reader kullanıcısı | Görünmez başlık: "Actions" | axe best practice |
| 3.2.6 Consistent Help | Yardım ya da iletişim yolu yok | Bilişsel, herkes | Her sayfada footer'da aynı yerde | Manuel |
| 2.4.2 Page Titled | Her kitabın başlığı "Book · Bookstore" | Screen reader, çok sekmeyle çalışan herkes | Başlıkta kitap adı | Yazı 03 |
| 2.4.3 Focus Order | Sayfa değişince focus eski yerinde kalıyor | Klavye, screen reader | Route değişiminde focus yönetimi | Yazı 03 |
| 4.1.3 Status Messages | "Added." ve kitap sayısı duyurulmuyor | Screen reader | Live region | Yazı 05 |
| 3.3.1 Error Identification | Sipariş hatası alana bağlı değil | Screen reader, bilişsel | Hata mesajını alana bağlamak | Yazı 04 ve 05 |
| 1.4.10 Reflow | 320 pikselde sepet tablosu taşıyor | Zoom kullanan az gören | İstisna kapsamında; kaydırılabilir wrapper | audit.mjs |
Lighthouse'un 100 verdiği uygulamada on iki satır. Hepsi aynı ağırlıkta değil: adet alanının adı yoksa screen reader kullanıcısı sepeti düzenleyemez, kenarlık contrast'ı ise "zor ama mümkün" seviyesinde. Önceliği belirlerken iki soru sor: kullanıcı işi tamamlayabiliyor mu, ve kaç sayfayı etkiliyor? Sipariş süreci her ikisinde de en üstte.
İlk Düzeltmeler
İlk yedi satır küçük HTML ve CSS değişiklikleri. Sepet tablosunda boş başlığa görünmez bir metin, adet alanına da kitabın adını taşıyan görünmez bir label ekledik. Sipariş formunda placeholder'ların yerini görünür label'lar ve autocomplete aldı:
<table class="cart">
<thead>
<tr><th>Book</th><th>Price</th><th>Quantity</th><th>Total</th><th><span class="visually-hidden">Actions</span></th></tr>
</thead>
<tbody>
@for (line of store.cart()!.items; track line.isbn) {
<tr>
<td><a [routerLink]="['/books', line.isbn]">{{ line.title }}</a></td>
<td>{{ line.unitPrice | currency: 'TRY' : 'symbol-narrow' }}</td>
<td>
<label class="visually-hidden" [for]="'qty-' + line.isbn">Quantity of {{ line.title }}</label>
<input [id]="'qty-' + line.isbn" type="number" min="1" max="99" [value]="line.quantity"
(change)="changeQuantity(line.isbn, $any($event.target).valueAsNumber)" />
</td>
<td>{{ line.lineTotal | currency: 'TRY' : 'symbol-narrow' }}</td>
<td><button type="button" (click)="store.remove(line.isbn)">Remove</button></td>
</tr>
}
</tbody>
</table>
<p class="total" data-testid="cart-total">Total: {{ store.cart()!.total | currency: 'TRY' : 'symbol-narrow' }}</p>
<form class="checkout" (submit)="checkout($event, nameBox.value, emailBox.value)">
<div class="field">
<label for="customer-name">Full name</label>
<input #nameBox id="customer-name" name="name" autocomplete="name" required />
</div>
<div class="field">
<label for="customer-email">E-mail</label>
<input #emailBox id="customer-email" name="email" type="email" autocomplete="email" required />
</div>
<button type="submit">Place order</button>
</form>
Adet alanının label'ı neden görünmez? Sütun başlığı "Quantity" görsel olarak zaten aynı bilgiyi veriyor, ama screen reader bir form alanına Tab ile geldiğinde tablo başlığını her zaman okumaz; alanın kendi adı olmalı. Görünmez label için CSS'te bir utility class yazdık. display: none ya da visibility: hidden içeriği accessibility tree'den de çıkarır; bu class ise yalnız ekrandan çıkarıyor. Form alanı kenarlığı da aynı dosyada:
input, select {
padding: 0.4rem;
/* #6b7280 beyaz üzerinde 4.83:1; form alanı kenarlığı en az 3:1 ister (WCAG 1.4.11) */
border: 1px solid #6b7280;
border-radius: 6px;
}
.checkout { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: end; margin-top: 1rem; }
.field { display: flex; flex-direction: column; gap: 0.25rem; }
/* Ekranda görünmez, accessibility tree'de kalır */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
Katalog sayfasının başına bir h1 geldi ve app.html'e her sayfada aynı yerde duran bir footer eklendi:
<h1>Catalog</h1>
<header class="site-header">
<a routerLink="/" class="brand">Bookstore</a>
<nav>
<a routerLink="/">Catalog</a>
<a routerLink="/cart" data-testid="cart-link">Cart ({{ cartStore.itemCount() }})</a>
</nav>
</header>
<main>
<router-outlet />
</main>
<footer class="site-footer">
<p>Need help? Write to <a href="mailto:support@example.com">support@example.com</a></p>
<a routerLink="/accessibility">Accessibility statement</a>
</footer>
Build alıp iki script'i yeniden çalıştırdım. Tarama artık dört sayfayı kapsıyor, çünkü lib.mjs'e aşağıda yazacağımız beyan sayfasını da ekledim:
--- catalog (/)
passed rules=21
--- book (/books/9780441172719)
passed rules=13
--- cart (/cart)
passed rules=17
--- statement (/accessibility)
passed rules=12
axe-core 4.14.0, WCAG violations: 0
--- cart
title="Cart · Bookstore" h1=1
field borders: number 4.83:1, name 4.83:1, email 4.83:1
autocomplete: name=name, email=email
targets < 24px: 6 (smallest "1984" 37x18), without enough spacing: none
320px: horizontal overflow 108px table
- row "1984 ₺279.00 Quantity of 1984 ₺279.00 Remove":
- 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":
- button "Remove"
- text: Full name
- textbox "Full name"
- text: E-mail
- textbox "E-mail"
- button "Place order"
Adet alanı artık spinbutton "Quantity of 1984"; ad görsel label'dan değil, görünmez label'dan geliyor. Form alanlarının adı aynı kaldı ("Full name", "E-mail") ama artık placeholder'dan değil görünür bir label'dan geliyor; kullanıcı yazmaya başladığında da ekranda duruyor. Accessibility tree aynı görünse de kullanıcı deneyimi farklı: tree'nin söylemediğini bilmek de audit'in parçası.
WCAG 2.2'nin Yenileri Bookstore'da
2.5.8 Target Size (Minimum): tıklama hedefi en az 24x24 CSS piksel olmalı. Daha küçükse hedefin ortasına çizilen 24 piksel çaplı bir daire başka bir hedefe ya da başka bir küçük hedefin dairesine değmemeli; audit.mjs tam olarak bunu ölçüyor. Cümle içindeki link'ler, tarayıcının kendi çizdiği kontroller (number input'un ok tuşları gibi) ve boyutu işin özü olan hedefler istisna. Bookstore'un 18 piksel yüksekliğindeki link'leri boşluk sayesinde geçiyor. Bir dikkat: axe'ın target-size kuralı varsayılan taramada kapalı; yalnız wcag22aa tag'ini istediğinde çalışıyor. Bizim scan.mjs bu tag'i istediği için kural çalıştı ve geçti.
2.4.11 Focus Not Obscured (Minimum): klavyeyle focus'a gelen element, sticky header ya da cookie banner gibi yazarın koyduğu bir içerik tarafından tamamen örtülmemeli. Bookstore'un header'ı şu an sticky değil, yani kriter karşılanıyor. Bir sonraki yazıda header'ı sticky yapıp sorunu kendimiz üreteceğiz ve scroll-padding ile düzelteceğiz.
2.5.7 Dragging Movements: sürükleyerek yapılan her işin, sürüklemeden tek bir pointer ile yapılabilen bir yolu olmalı. Bookstore'da sürükle-bırak yok. Bir gün sepetteki kitapları sürükleyerek sıralamak istersek, her satıra "yukarı taşı" ve "aşağı taşı" butonları eklemek bu kriteri karşılar. Sürükleme klavye kullanıcısı için zaten 2.1.1'in konusu; 2.5.7, mouse ya da dokunmatik ekran kullanan ama sürükleme hareketini yapamayan kullanıcılar için.
3.2.6 Consistent Help: bu kriter yardım mekanizması koymayı zorunlu kılmıyor; koyduysan, birden çok sayfada aynı göreli sırada durmasını istiyor. Footer'daki iletişim satırı her sayfada aynı yerde olduğu için karşılanıyor. 3.3.7 Redundant Entry: aynı süreçte daha önce girilen bilgi tekrar istenmemeli; ya otomatik dolmalı ya da seçilebilmeli. Bookstore'un checkout'u tek adımlı olduğu için şu an karşılığı yok. Fatura adresi eklendiğinde "teslimat adresiyle aynı" seçeneği gerekecek. 3.3.8 Accessible Authentication (Minimum) ise giriş adımında bilişsel bir test (şifreyi hatırlamak, bulmaca çözmek) istenirken bir alternatif ya da yardım olmasını şart koşuyor: şifre yapıştırmayı ve password manager'ı engellememek bunun pratik karşılığı. Bu iki form kriteri UI/UX Tasarım İlkeleri serisinin "Form Tasarımı" yazısında ayrıntılı.
Accessibility Statement Sayfası
Accessibility statement (erişilebilirlik beyanı), sitenin hangi standardı hedeflediğini, şu an nerede durduğunu, bilinen sorunları ve kullanıcının bir sorunu nasıl bildireceğini açıkça yazan sayfa. Kamu siteleri için AB'nin (Avrupa Birliği) 2016/2102 sayılı Web Accessibility Directive'i bunu zorunlu tutuyor; W3C'nin (web standartlarını yayımlayan kuruluş) accessibility birimi WAI'nin (Web Accessibility Initiative; W3C'nin accessibility çalışmalarını yürüten birim) de bölümlerini öneren bir rehberi ve oluşturucusu var. Bookstore için beyanı bir Angular sayfası olarak yazdık; bilinen sorunlar bir dizide duruyor ki her düzeltmede yalnız bir satır silinsin:
import { Component } from '@angular/core';
interface KnownLimitation {
problem: string;
criteria: string;
plannedFix: string;
}
// Bu sayfayı audit ile birlikte tut: bir sorun düzeldikçe ya da bulundukça güncelle
@Component({
selector: 'app-accessibility-page',
templateUrl: './accessibility-page.html',
})
export class AccessibilityPage {
protected readonly reviewedOn = '15 November 2026';
protected readonly contactEmail = 'accessibility@example.com';
protected readonly limitations: KnownLimitation[] = [
{
problem: 'After moving to another page, keyboard focus stays where it was and the page title does not name the book.',
criteria: '2.4.2, 2.4.3',
plannedFix: 'December 2026',
},
{
problem: 'Messages such as "Added" and the number of search results are not announced by screen readers.',
criteria: '4.1.3',
plannedFix: 'January 2027',
},
{
problem: 'Order errors are shown below the form but are not linked to the fields they belong to.',
criteria: '3.3.1, 3.3.3',
plannedFix: 'January 2027',
},
];
}
<article class="statement">
<h1>Accessibility statement</h1>
<p>
We want everyone to be able to find, buy and follow up on books at Bookstore, including people who use
a screen reader, only a keyboard, zoom or voice control.
</p>
<h2>Conformance status</h2>
<p>
Our target is WCAG 2.2 level AA. Bookstore is partially conformant: most pages meet the target,
the parts listed below do not yet.
</p>
<p>
Under Presidential Circular No. 2025/10 we also follow the Level A checklist of the Ministry of Family
and Social Services.
</p>
<h2>Known limitations</h2>
<ul>
@for (item of limitations; track item.problem) {
<li>{{ item.problem }} (WCAG {{ item.criteria }}; planned fix: {{ item.plannedFix }})</li>
}
</ul>
<h2>How we checked</h2>
<p>
Self-evaluation: automated checks with axe-core 4.14 and Lighthouse 13.6, then manual checks with the keyboard,
at 320 CSS pixels wide and against the checklist. Screen reader testing is planned.
</p>
<h2>Feedback</h2>
<p>
If something does not work for you, write to <a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a>.
We answer within five working days.
</p>
<p class="muted">Last reviewed on {{ reviewedOn }}.</p>
</article>
Route'u app.routes.ts'e ekledik, sayfa başlığı "Accessibility statement · Bookstore". Beyanın en önemli iki özelliği dürüstlük ve iletişim. "Tam uyumlu" yazmak kolay; audit tablosunda açık satır varken bu bir iddia, ve kullanıcı ilk sorunda beyana güvenmeyi bırakır. "Kısmen uyumlu" yazıp bilinen sorunları, etkilenen kriterleri ve tahmini düzeltme tarihini listelemek hem kullanıcıya hem de bir İnceleme Komisyonu'na neyle karşılaşacağını söyler. Geri bildirim adresinin gerçekten okunan bir kutu olması ve verilen cevap süresinin tutulması da beyanın parçası.
Üretimde Dikkat
Audit'i sayfa listesiyle değil, kullanıcı görevleriyle planla. Bookstore için görevler "kitap bul", "sepete ekle", "sepeti düzenle" ve "sipariş ver". WCAG'in tam süreç kuralı da bunu istiyor: checkout'un tek bir adımı erişilemiyorsa sürecin geri kalanının uyumlu olması bir şey değiştirmez.
Bulguları bir issue tracker'a kriter numarasıyla gir ve önceliği kullanıcı etkisine göre ver. "1.4.11, düşük" ile "4.1.2, sipariş akışını engelliyor" aynı sprint'te yarışmamalı. Hangi bulgunun hangi yazıda ya da sürümde kapandığını beyan sayfasıyla eşleştir.
Lighthouse skorunu bir hedef metriği yapma. Bu yazıda 100 alan sayfada on iki sorun bulduk. Skor ancak gerilediğinde bir uyarı olarak işe yarar; CI'da otomatik kontrollerin nasıl kurulacağı serinin son yazısında.
Renk ve kenarlık gibi değerleri tek yerde tut. Bookstore'da kenarlık rengi styles.css'te tek bir satır olduğu için düzeltme bir satır sürdü; aynı renk yirmi component'in CSS'ine kopyalanmış olsaydı yirmi değişiklik ve yirmi ayrı risk olurdu.
Sık Düşülen Tuzaklar
Birincisi, label'ı görünmez yapmak için display: none kullanmak. Element accessibility tree'den de çıkar ve input yine adsız kalır.
İkincisi, placeholder'ı axe geçirdi diye label saymak. Ad hesaplaması placeholder'ı son çare olarak kullanıyor, ama kullanıcı yazmaya başladığında ipucu kayboluyor.
Üçüncüsü, yalnız metin contrast'ını ölçmek. Form alanı kenarlığı, ikonlar, focus göstergesi ve grafiklerdeki çizgiler 1.4.11 kapsamında ve otomatik araçların çoğu bunlara bakmıyor.
Dördüncüsü, 3.2.6'yı "her sayfaya yardım butonu koy" diye okumak. Kriter tutarlılık istiyor; yardımın varlığını değil.
Beşincisi, beyan sayfasına "tam uyumlu" yazıp bir daha dokunmamak. Beyan audit'in bir çıktısı; audit değiştikçe değişmeli.
Küçük Bir Deneme
★☆☆ styles.css'te kenarlık rengini #9ca3af yap ve audit.mjs'i çalıştır. 1.4.11'in 3:1 sınırını geçiyor mu? Sonra aynı rengin beyaz üzerindeki metin contrast'ını hesapla ve 4.5:1 ile karşılaştır.
★★☆ Sepet tablosunu tabindex="0", role="region" ve bir aria-label taşıyan bir wrapper'a al, overflow-x: auto ver. 320 piksel genişlikte audit.mjs taşmayı hâlâ sayfa düzeyinde görüyor mu, ve wrapper Tab ile focus alıyor mu?
★★★ Katalogdaki kitap kartlarına 16 piksellik bir "favorilere ekle" ikon butonu ekle ve gap'i 0.25rem'de bırak. audit.mjs ve axe'ın target-size kuralı aynı sonucu veriyor mu? Butonu 24 piksele büyütmek ile boşluğu artırmak arasında hangisi tasarımı daha az değiştiriyor?
Audit tablosunun açık satırlarından ikisi focus ile ilgili: sayfa değişince focus'un nerede kaldığı ve sayfa başlığı. Bir sonraki yazıda Bookstore'u yalnız klavyeyle kullanıyoruz: Tab sırası, görünür focus stili, skip link, modal dialog'da focus'u yönetmek, inert, roving tabindex ve Angular router'da route değişiminde focus ve başlık.