Mert Özen Her Satırda Daha İleri
Frontend Accessibility (a11y) — #02

WCAG 2.2'yi Geliştirici Gözüyle Okumak: En Sık Düşülen Kriterler ve Bookstore Audit'i

Mert Özen 15 Kas 2026 17 dk 3 görüntülenme
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:

KriterSorunEtkilenen kullanıcıDüzeltmeTest
4.1.2 Name, Role, ValueSepetteki adet input'unun adı yokScreen reader, sesle kontrolGörünmez label: "Quantity of 1984"axe label, accessibility tree
3.3.2 Labels or InstructionsSipariş formunda yalnız placeholder varBilişsel, az gören, screen readerGörünür label ve for/idManuel; axe geçiriyor
1.3.5 Identify Input PurposeAd ve e-postada autocomplete yokMotor, bilişselautocomplete="name", "email"audit.mjs
1.4.11 Non-text ContrastForm alanı kenarlığı 1.47:1Az görenKenarlık #6b7280 (4.83:1)audit.mjs
1.3.1 Info and RelationshipsKatalogda h1 yokHeading ile gezinen screen reader kullanıcısıGörünür h1: "Catalog"axe best practice
1.3.1 Info and RelationshipsSepet 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 HelpYardım ya da iletişim yolu yokBilişsel, herkesHer sayfada footer'da aynı yerdeManuel
2.4.2 Page TitledHer kitabın başlığı "Book · Bookstore"Screen reader, çok sekmeyle çalışan herkesBaşlıkta kitap adıYazı 03
2.4.3 Focus OrderSayfa değişince focus eski yerinde kalıyorKlavye, screen readerRoute değişiminde focus yönetimiYazı 03
4.1.3 Status Messages"Added." ve kitap sayısı duyurulmuyorScreen readerLive regionYazı 05
3.3.1 Error IdentificationSipariş hatası alana bağlı değilScreen reader, bilişselHata mesajını alana bağlamakYazı 04 ve 05
1.4.10 Reflow320 pikselde sepet tablosu taşıyorZoom kullanan az görenİstisna kapsamında; kaydırılabilir wrapperaudit.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.