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

Keyboard Navigation ve Focus Yönetimi: Tab Sırası, :focus-visible, Skip Link, Focus Trap, inert ve Roving Tabindex

Mert Özen 15 Kas 2026 16 dk 4 görüntülenme
Keyboard Navigation ve Focus Yönetimi: Tab Sırası, :focus-visible, Skip Link, Focus Trap, inert ve Roving Tabindex

Klavye kullanıcısı için focus her zaman görünür olmalı, mantıklı sırayla ilerlemeli ve odaktaki element kaybolduğunda bilinçli olarak bir yere taşınmalı. Bookstore'u yalnız klavyeyle kullanıp ölçüyoruz: SPA'da skip link'in base href tuzağı, sticky header'ın focus'u örtmesi ve scroll-padding, native dialog'un focus trap'i ve silinen satırdan sonra focus, native radio ile roving tabindex, Angular router'da route değişiminde focus ve sayfa başlığı.

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 ve Playwright 1.65 (Chromium 158) ile denenmiştir; klavye ölçümleri headless Chromium'da yapılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) kataloğunu mouse'a hiç dokunmadan kullanıyorsun. Sayfa açıldıktan sonra Dune'un "Add to cart" butonuna gelmek için kaç kez Tab'a basman gerekir? Sonra Dune'un link'inde Enter'a basıp kitap sayfasına geçiyorsun: focus nerede? Yeni sayfanın başında mı, sayfadaki ilk link'te mi, yoksa hiçbir yerde mi?

Bir de Selin'den (Product Owner) gelen bir istek var: sepet sayısı sayfayı kaydırırken de görünsün, yani header sticky olsun. Bu tek satırlık CSS değişikliği klavye kullanıcısı için neyi bozar? Bir önceki yazının audit tablosunda iki açık satır bu yazının konusu: 2.4.2 (Page Titled) ve 2.4.3 (Focus Order).

Bookstore'u Yalnız Klavyeyle Kullanmak

Klavye testini elle de yapabilirsin, ama sonuçları tekrar ölçebilmek için bir script'e döktüm. keyboard.mjs her modda bir görevi yalnız klavyeyle yapıyor ve her adımda focus'taki element'in role'ünü ve adını yazdırıyor; bu, bir screen reader'ın o anda duyuracağı bilginin özü:

// Bookstore'u yalnız klavyeyle kullanır ve focus'un nereye gittiğini yazdırır
// Kullanım: node keyboard.mjs <tabs|route|skip|sticky|radios|dialog>
import { chromium } from 'playwright';
import { base, open } from './lib.mjs';

const browser = await chromium.launch();
const page = await (await browser.newContext({ viewport: { width: 1280, height: 600 } })).newPage();

// Focus'taki element'in role'ü ve adı; screen reader'ın aldığı bilgi
async function focused() {
  const el = page.locator(':focus');
  if (await el.count() === 0) return '(nothing: document.body)';
  return (await el.ariaSnapshot()).split('\n')[0].replace(/^- /, '').replace(/:$/, '');
}

async function tabUntil(text, max = 60) {
  for (let i = 1; i <= max; i++) {
    await page.keyboard.press('Tab');
    if ((await focused()).includes(text)) return i;
  }
  return -1;
}

const mode = process.argv[2] ?? 'tabs';

if (mode === 'tabs') {
  // Sayfa açıldıktan sonra Dune'un "Add to cart"ına kaç Tab ile varılıyor?
  await open(page, 'catalog');
  const stops = [];
  for (let i = 0; i < 9; i++) {
    await page.keyboard.press('Tab');
    stops.push(await focused());
  }
  console.log(stops.map((s, i) => `${String(i + 1).padStart(2)} ${s}`).join('\n'));
  await open(page, 'catalog');
  const dune = page.locator('.book-card', { hasText: 'Dune' }).getByRole('button');
  for (let i = 1; i <= 60; i++) {
    await page.keyboard.press('Tab');
    if (await dune.evaluate(el => el === document.activeElement)) {
      console.log(`Tab presses to "Add to cart" for Dune: ${i}`);
      break;
    }
  }
  // Aynı görev, ama skip link ile: Tab, Enter, sonra main'in başından Tab
  await open(page, 'catalog');
  await page.keyboard.press('Tab');
  if ((await focused()).includes('Skip to main content')) {
    await page.keyboard.press('Enter');
    for (let i = 1; i <= 60; i++) {
      await page.keyboard.press('Tab');
      if (await dune.evaluate(el => el === document.activeElement)) {
        console.log(`with the skip link: 1 Tab + Enter + ${i} Tab`);
        break;
      }
    }
  }
}
node keyboard.mjs tabs
node keyboard.mjs route
 1 link "Bookstore"
 2 link "Catalog"
 3 link "Cart (0)"
 4 searchbox "Search books"
 5 button "Search"
 6 combobox "Category"
 7 link "1984"
 8 button "Add to cart"
 9 link "Anna Karenina"
Tab presses to "Add to cart" for Dune: 18
url=/books/9780441172719 title="Book · Bookstore"
focus after Enter: (nothing: document.body)
next Tab:         link "← Back to catalog"

İlk tahminin cevabı 18. Focus sırası DOM'un (sayfanın element ağacı) sırasını izliyor: header'daki üç link, arama kutusu, Search butonu, kategori seçici ve sonra kitaplar, her kitapta bir link ve bir buton. Dune altıncı kitap olduğu için önünde beş kitabın on durağı var. Bu doğal sıra doğru; yanlış olan, pozitif tabindex ile ya da CSS ile görsel sırayı DOM'dan ayırmak olurdu. Pozitif tabindex'in neden sırayı bozduğu HTML serisinin "Accessible Markup Temelleri" yazısında ölçüldü; burada tekrar etmiyoruz.

İkinci tahminin cevabı can sıkıcı: Enter'dan sonra focus hiçbir yerde değil. Angular router eski sayfanın component'ini DOM'dan kaldırdı, focus'taki link de onunla gitti ve focus body'ye düştü. Sayfa başlığı da "Book · Bookstore"; hangi kitap olduğunu söylemiyor. Bir screen reader kullanıcısı link'e bastıktan sonra hiçbir şey duymuyor; sayfanın değiştiğini ancak etrafı yeniden tarayarak anlıyor. Chromium'da bir sonraki Tab "← Back to catalog"a gitti, başka bir tarayıcıda sayfanın en başına dönebilir; ikisi de "yeni sayfanın başı" değil.

Görünür Focus: :focus-visible ve outline: none Tuzağı

Focus'un nerede olduğunu gören kullanıcı için ilk şart, focus'un görünmesi (2.4.7 Focus Visible). En sık yapılan hata, tasarımda tarayıcının focus çerçevesi beğenilmediği için outline: none yazıp yerine bir şey koymamak; klavye kullanıcısı o andan sonra nerede olduğunu bilmiyor. Bookstore'da böyle bir satır yok, ama tarayıcının varsayılan halkası mavi butonların üzerinde zor seçiliyor. Bütün kontroller için tek bir stil yazdık:

a { color: #1d4ed8; }

/* Bütün kontroller için tek görünür focus stili; offset'li 3px mavi butonlarda da görünür */
:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 2px;
}

:focus-visible, tarayıcının "bu focus gösterilmeli mi?" kararına uyuyor: klavyeyle gelince çerçeve çiziliyor, butona mouse ile tıklayınca çizilmiyor; bu ayrım ve selector'ın tarayıcı desteği CSS serisinin "Modern Selector'ler" yazısında. outline-offset çerçeveyi butonun dışına çekiyor, böylece mavi zemin üzerindeki mavi çerçeve kaybolmuyor. outline kullanmanın bir avantajı daha var: Windows'un contrast temalarında (forced colors) box-shadow ile çizilen focus halkası kaybolur, outline kalır.

2.4.1 (Bypass Blocks), her sayfada tekrarlanan blokları atlamanın bir yolunu istiyor. Landmark'lar ve heading'ler screen reader kullanıcısı için bu işi görüyor, ama yalnız klavye kullanan biri için tek yol bir skip link: sayfanın ilk focus alan element'i, focus alınca görünen "Skip to main content" link'i. Header'a ekledik ve main'e bir id ve tabindex="-1" verdik ki programatik olarak focus alabilsin. İlk denemede link'in href'i yalnız #main-content'ti. Sepet sayfasında ölçtük:

first Tab: link "Skip to main content"
after Enter: url=/#main-content focus=main
next Tab: searchbox "Search books"

Link bizi sepetten kataloğa götürdü. Angular uygulamasının index.html'inde <base href="/"> var ve göreli bir #main-content adresi bu base'e göre çözülüyor: /cart#main-content değil, /#main-content. SPA'larda (single-page application; sayfayı yeniden yüklemeden route değiştiren uygulama) klasik bir tuzak. Çözüm, link'in href'ini anlam için bırakıp tıklamayı kendimiz yakalamak:

<a class="skip-link" href="#main-content" (click)="skipToMain($event)">Skip to main content</a>
<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 #main id="main-content" tabindex="-1">
  <router-outlet />
</main>
export class App implements OnInit {
  protected readonly cartStore = inject(CartStore);
  private readonly main = viewChild.required<ElementRef<HTMLElement>>('main');

  ngOnInit(): void {
    void this.cartStore.load();
  }

  // <base href="/"> varken href="#main-content" "/#main-content"e gider; focus'u kendimiz taşıyoruz
  protected skipToMain(event: Event): void {
    event.preventDefault();
    this.main().nativeElement.focus();
  }
.skip-link {
  position: absolute;
  left: 1rem;
  top: -3rem;
  z-index: 20;
  padding: 0.5rem 1rem;
  background: #ffffff;
}

.skip-link:focus {
  top: 0.5rem;
}

/* main ve h1 focus'u yalnız script'ten alır (tabindex="-1"); kontrol değiller */
main:focus, h1:focus { outline: none; }
first Tab: link "Skip to main content"
after Enter: url=/cart focus=main
next Tab: link "1984"
node keyboard.mjs tabs
Tab presses to "Add to cart" for Dune: 19
with the skip link: 1 Tab + Enter + 15 Tab

Focus main'e geldi ve sonraki Tab sepetteki ilk link'e gitti. Skip link ekranın dışında duruyor ve yalnız focus alınca görünüyor; display: none ile gizleseydik Tab ile hiç ulaşılamazdı. main ve h1 için focus çerçevesini kaldırdık: bunlar kontrol değil, yalnız script'ten focus alıyorlar ve sonraki Tab görünür çerçeveli bir kontrole gidiyor. Bookstore'un header'ı küçük: Dune'a varmak skip link'in kendisi de bir durak olduğu için artık 19 Tab, skip link ile 1 Tab, Enter ve 15 Tab. Kazanç birkaç tuş; menüsü kırk link olan bir sitede fark çok daha büyük.

Sticky Header ve 2.4.11 Focus Not Obscured

Selin'in isteği için header'a üç satır ekledik:

.site-header {
  position: sticky;
  top: 0;
  z-index: 10;
  background: #ffffff;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem 1.5rem;
  border-bottom: 1px solid #e5e7eb;
}

Kaydırırken header görünür kalıyor. Klavyeyle aşağı doğru Tab'la gezerken sorun yok: tarayıcı focus alan element'i görünür alanın altına kaydırıyor. Sorun geri dönerken çıkıyor. Shift+Tab ile yukarı çıkınca tarayıcı element'i görünür alanın en üstüne kaydırıyor; orası da artık header'ın altı. keyboard.mjs sticky, kataloğun son butonundan Shift+Tab ile yukarı çıkıp her focus'ta element'in header tarafından ne kadar örtüldüğünü ölçüyor:

header height=56px, focused elements checked=42, fully hidden=8, partly hidden=0

Kırk iki durağın sekizinde focus'taki element tamamen header'ın altında kaldı: kullanıcı nerede olduğunu göremiyor. 2.4.11 (AA) tam olarak bunu yasaklıyor; kısmen örtülmesine izin veriyor, tamamen örtülmesine izin vermiyor (AAA olan 2.4.12 kısmi örtülmeyi de yasaklıyor). Düzeltme tek satır CSS: scroll-padding-top, tarayıcıya bir element'i görünür alana kaydırırken üstte ne kadar pay bırakacağını söylüyor.

html {
  /* Tarayıcı focus alan element'i görünür alana kaydırırken sticky header kadar pay bırak (WCAG 2.4.11) */
  scroll-padding-top: 4.5rem;
}
header height=56px, focused elements checked=42, fully hidden=0, partly hidden=0

Header 56 piksel; 4.5rem (72 piksel) header'ı ve focus çerçevesini birlikte bırakacak kadar pay veriyor. Aynı sorun cookie banner'ı, sayfanın altına yapışan "sepete git" çubuğu ve sohbet widget'ları için de geçerli; altta duranlar için scroll-padding-bottom kullanılır.

Sepetteki "Remove" butonu artık hemen silmiyor, bir onay soruyor. Native dialog ve showModal() kullandık; temel davranışı (top layer, autofocus, method="dialog", Esc) HTML serisinin "Native Interaktif Element'ler 1" yazısında anlatıldı. Burada focus'a bakıyoruz:

<dialog #removeDialog class="confirm" aria-labelledby="remove-title" (close)="onRemoveDialogClosed()">
  <h2 id="remove-title">Remove {{ pendingRemoval()?.title }} from your cart?</h2>
  <form method="dialog">
    <button value="keep" autofocus>Keep it</button>
    <button value="remove" class="danger">Remove</button>
  </form>
</dialog>

İlk sürümde dialog kapandıktan sonra yalnız store.remove()'u çağırıyorduk. keyboard.mjs dialog, sepete iki kitap koyup ilkini klavyeyle siliyor:

trigger: button "Remove" (row 1 of 2)
dialog open, focus: button "Keep it"
Tab x3: button "Remove" -> (nothing: document.body) -> button "Keep it"
page link .focus() while open: button "Keep it"
Escape: focus=button "Remove"
reopen + Tab: button "Remove"
confirmed, rows left=1, focus=(nothing: document.body)

Native dialog'un yaptıkları satır satır görünüyor. Açılınca focus autofocus'lu "Keep it"e gitti. Tab dialog'un içinde dönüyor; iki butondan sonra bir kez tarayıcının kendi arayüzüne çıkıyor ("nothing" satırı, gerçek tarayıcıda adres çubuğu) ve tekrar "Keep it"e dönüyor, sayfanın geri kalanına hiç gitmiyor. Arka plandaki bir link'e script ile focus() çağırmak bile işe yaramadı: modal açıkken dialog dışındaki her şey inert. Esc dialog'u kapattı ve focus'u kendisini açan "Remove" butonuna geri verdi. Yani focus trap'in ve focus'u geri vermenin büyük kısmını tarayıcı yapıyor; kendi yazdığın bir div modal'da bunların hepsini elle kodlaman gerekirdi.

Son satır ise bizim sorunumuz: "Remove" onaylandığında satır silindi, focus'u geri alacak buton da onunla gitti ve focus body'ye düştü. Tarayıcı bunu bilemez; nereye gidileceğine biz karar vermeliyiz. Kural basit: silinen satırın yerine geçen satırın butonu, o yoksa bir öncekinin, sepet boşaldıysa sayfanın başlığı:

  protected readonly pendingRemoval = signal<CartLine | null>(null);

  private readonly injector = inject(Injector);
  private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
  private readonly heading = viewChild.required<ElementRef<HTMLElement>>('heading');
  private readonly removeDialog = viewChild.required<ElementRef<HTMLDialogElement>>('removeDialog');

  protected askToRemove(line: CartLine): void {
    this.pendingRemoval.set(line);
    this.removeDialog().nativeElement.showModal();
  }

  protected async onRemoveDialogClosed(): Promise<void> {
    const dialog = this.removeDialog().nativeElement;
    const line = this.pendingRemoval();
    this.pendingRemoval.set(null);
    if (dialog.returnValue !== 'remove' || !line) {
      return; // "Keep it" ya da Escape: dialog focus'u kendisini açan Remove butonuna geri verir
    }
    dialog.returnValue = '';
    const index = this.store.cart()?.items.findIndex(l => l.isbn === line.isbn) ?? 0;
    await this.store.remove(line.isbn);
    this.focusAfterRemoval(index);
  }

  // Dialog'u açan buton satırıyla birlikte silindi; bu olmadan focus <body>'ye düşer
  private focusAfterRemoval(index: number): void {
    afterNextRender(() => {
      const buttons = this.host.nativeElement.querySelectorAll<HTMLButtonElement>('tbody button');
      const next = buttons[Math.min(index, buttons.length - 1)];
      (next ?? this.heading().nativeElement).focus();
    }, { injector: this.injector });
  }
trigger: button "Remove" (row 1 of 2)
dialog open, focus: button "Keep it"
Tab x3: button "Remove" -> (nothing: document.body) -> button "Keep it"
page link .focus() while open: button "Keep it"
Escape: focus=button "Remove"
reopen + Tab: button "Remove"
confirmed, rows left=1, focus=button "Remove"

afterNextRender, Angular satırı DOM'dan kaldırdıktan sonra çalışıyor; daha önce çalışsa silinmek üzere olan butonu seçebilirdi. Focus artık kalan satırın "Remove" butonunda. Bir sonraki yazıda bu butonların hepsinin aynı adı taşıdığını, yani "Remove" duyan kullanıcının hangi kitabın butonunda olduğunu bilmediğini düzelteceğiz.

inert attribute'unu kendin ne zaman kullanırsın? Native dialog modal açıldığında arka planı zaten inert yapıyor. Kendi çekmece menünü ya da tam ekran bir paneli dialog olmadan yazıyorsan, açıkken arkadaki main'e ve header'a inert vermen gerekir; yoksa Tab arkadaki link'lere kaçar. inert'in aria-hidden ve hidden'dan farkı HTML serisinin "Accessible Markup Temelleri" yazısında ölçüldü.

Roving Tabindex ve aria-activedescendant: Native Radio Örneği

Selin'in ikinci isteği, kategori seçicinin dropdown yerine her zaman görünen "chip"ler olması. Beş chip'i beş ayrı buton yapsaydık Tab sırasına beş durak eklerdik. Doğru model bir composite widget: grup tek bir Tab durağıdır, grup içinde ok tuşlarıyla gezilir. Bu davranışı native olarak veren element zaten var: aynı name'i taşıyan radio button'lar. Chip'leri görsel olarak gizlenmiş radio'lar ve onların label'ları olarak yazdık:

  <fieldset class="chips">
    <legend>Category</legend>
    <label><input type="radio" name="category" value="" [checked]="category() === ''" (change)="category.set('')" /> All</label>
    @for (c of categories.value(); track c.slug) {
      <label>
        <input type="radio" name="category" [value]="c.slug" [checked]="category() === c.slug" (change)="category.set(c.slug)" />
        {{ c.name }}
      </label>
    }
  </fieldset>
.chips { display: flex; flex-wrap: wrap; gap: 0.5rem; border: 0; padding: 0; margin: 0; }
.chips legend { float: left; margin-right: 0.5rem; padding: 0.3rem 0; }
.chips label { padding: 0.3rem 0.8rem; border: 1px solid #6b7280; border-radius: 999px; cursor: pointer; }
.chips input { position: absolute; opacity: 0; }
.chips label:has(:checked) { background: #1d4ed8; border-color: #1d4ed8; color: #ffffff; }
.chips label:has(:focus-visible) { outline: 3px solid #1d4ed8; outline-offset: 2px; }
Tab reaches: radio "All" [checked]
ArrowRight: radio "Classics" [checked]  14 books
ArrowRight: radio "Science Fiction" [checked]  3 books
Tab leaves the group: link "Dune"
Shift+Tab comes back to: radio "Science Fiction" [checked]

Grup tek durak; ok tuşu hem focus'u hem seçimi taşıyor ve liste hemen filtreleniyor (Classics'te 14, Science Fiction'da 3 kitap). Tab gruptan çıkıyor, Shift+Tab gruba seçili radio'dan geri giriyor. Radio'yu display: none değil opacity: 0 ile gizledik ki focus alabilsin; focus çerçevesini de :has(:focus-visible) ile label'a taşıdık.

Tarayıcının burada yaptığı şeyin adı roving tabindex: grupta yalnız aktif öğe tabindex="0" taşır, diğerleri -1; ok tuşu basıldığında 0 bir sonrakine geçer ve focus onunla gider. Toolbar, listbox, tabs, tree ve grid gibi composite widget'ları kendin yazarsan bu mantığı da kendin kurarsın. Alternatifi bir ARIA (WAI-ARIA; HTML element'lerine role, state ve property ekleyen standart) attribute'u olan aria-activedescendant: DOM focus'u konteynerde (ör. combobox'ın metin kutusunda) kalır, konteynerin bu attribute'u o an "aktif" sayılan öğenin id'sini gösterir ve screen reader o öğeyi duyurur. Kullanıcının yazmaya devam etmesi gereken combobox'ta focus'u metin kutusundan çıkaramadığın için aria-activedescendant kullanılır; toolbar ve tabs'ta roving tabindex daha yaygın. İkisinin de beklenen tuş davranışı W3C'nin (web standartlarını yayımlayan kuruluş) ARIA Authoring Practices Guide'ında (APG; widget'ların klavye ve ARIA davranışını tarif eden W3C rehberi) pattern pattern tarif ediliyor. Bu mantığı her projede yeniden yazmak yerine Angular CDK'nın (Component Dev Kit; Angular ekibinin davranış library'si) ListKeyManager'ını ya da Angular Aria'yı kullanmak, serinin Angular yazısının konusu.

SPA'da Route Değişimi: Focus ve Sayfa Başlığı

Klasik bir web sitesinde her link yeni bir document yükler; tarayıcı focus'u sayfanın başına koyar, screen reader yeni başlığı okur. SPA'da bunların hiçbiri olmuyor, ölçümde gördük. Angular router route değişiminde focus'u taşımıyor; iki parçalı küçük bir çözüm yazdık. Bir service, client-side bir navigation olduğunu hatırlıyor; bir directive de sayfanın h1'ine konuyor ve render edilince, bekleyen bir navigation varsa focus'u alıyor:

import { Injectable, inject } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';

// Client-side bir navigation olduğunu hatırlar; böylece yeni sayfanın heading'i focus'u alabilir
@Injectable({ providedIn: 'root' })
export class RouteFocus {
  private pending = false;

  constructor() {
    inject(Router).events
      .pipe(filter(event => event instanceof NavigationEnd))
      // id 1 ilk yükleme: tarayıcı zaten document'ın başından başlar
      .subscribe(event => this.pending = event.id > 1);
  }

  claim(): boolean {
    const pending = this.pending;
    this.pending = false;
    return pending;
  }
}
import { Directive, ElementRef, afterNextRender, inject } from '@angular/core';
import { RouteFocus } from './route-focus';

// <h1 appPageHeading>: route değişince focus yeni sayfanın ana heading'ine geçer
// Sonradan render edilen heading'lerde de çalışır (ör. bir httpResource yüklendikten sonra)
@Directive({
  selector: 'h1[appPageHeading]',
  host: { tabindex: '-1' },
})
export class PageHeading {
  constructor() {
    const heading = inject<ElementRef<HTMLElement>>(ElementRef);
    const routeFocus = inject(RouteFocus);
    afterNextRender(() => {
      if (routeFocus.claim()) {
        heading.nativeElement.focus();
      }
    });
  }
}

Focus'u neden NavigationEnd anında değil heading render edilince taşıyoruz? Kitap detayı sayfasının h1'i veri httpResource ile geldikten sonra oluşuyor; NavigationEnd anında henüz DOM'da yok. Directive, element ne zaman oluşursa o zaman çalıştığı için iki durumu da karşılıyor. Directive'i dört sayfanın h1'ine ekledik (<h1 appPageHeading>). İlk yüklemede focus'a dokunmuyoruz; tarayıcı zaten document'ın başından başlıyor. Başlık için de kitap yüklenince Title service'ini çağırıyoruz:

  constructor() {
    // Route başlığı yalnız "Book · Bookstore"; kitap yüklenince adını başlığa yaz (WCAG 2.4.2)
    const title = inject(Title);
    effect(() => {
      const book = this.book.value();
      if (book) {
        title.setTitle(`${book.title} · Bookstore`);
      }
    });
  }
url=/books/9780441172719 title="Dune · Bookstore"
focus after Enter: heading "Dune" [level=1]
next Tab:         spinbutton "Quantity": "1"

Enter'dan sonra focus "Dune" heading'inde, başlık "Dune · Bookstore". Screen reader focus'u alan heading'i okuduğu için kullanıcı yeni sayfaya geldiğini ve hangi kitapta olduğunu hemen duyuyor; sonraki Tab da sayfanın ilk kontrolüne, adet alanına gidiyor. Bazı ekipler focus'u h1 yerine main'e taşımayı ya da route değişimini bir live region ile duyurmayı tercih ediyor; bunlar da geçerli yollar. Önemli olan seçilen yolun bütün route'larda tutarlı olması ve bir screen reader ile denenmesi. Beyan sayfasındaki 2.4.2 ve 2.4.3 satırını da kaldırdık.

Üretimde Dikkat

Klavye testini merge öncesi kontrol listesine koy: yeni ekranı yalnız klavyeyle baştan sona kullan; her durakta focus görünüyor mu, sıra mantıklı mı, bir yerde hapsoluyor musun, Esc beklenen yerde çalışıyor mu? Bu yazıdaki keyboard.mjs gibi script'ler bu kontrolü tekrar edilebilir yapıyor ve E2E (uçtan uca) testlerine dönüştürülebilir.

Element silen, gizleyen ya da değiştiren her işlemde "focus nereye gidecek?" sorusunu sor. Silinen satır, kapanan panel, değişen sekme, sonuç sayfasına dönen filtre; focus'taki element ortadan kalkıyorsa focus'u bilinçli olarak bir yere koy.

Sabit konumlu her şey (header, cookie banner, alttaki sepet çubuğu, sohbet butonu) için 2.4.11'i ölç. Yüksekliği değişebilen bir header varsa (ör. mobilde iki satıra çıkıyorsa) scroll-padding-top'ı bir CSS değişkenine bağla ve breakpoint'lerde güncelle.

Route değişiminde focus yönetimini bütün uygulama için bir kez ve aynı yolla çöz. Her sayfa kendi bildiği gibi yaparsa bazı sayfalar focus'u taşır, bazıları taşımaz; kullanıcı ne bekleyeceğini bilemez.

Sık Düşülen Tuzaklar

Birincisi, outline: none yazıp yerine görünür bir focus stili koymamak.

İkincisi, SPA'da skip link'in href'ini düz bir hash olarak bırakmak. <base href="/"> ile link başka bir sayfaya gider.

Üçüncüsü, skip link'i ya da görsel olarak gizlenen radio'yu display: none ile gizlemek. Element Tab sırasından da çıkar.

Dördüncüsü, modal'ı div ile yazıp focus trap'i, Esc'yi, arka planın inert olmasını ve focus'u geri vermeyi unutmak. Native dialog bunların çoğunu hazır veriyor.

Beşincisi, focus'taki element'i DOM'dan kaldırıp focus'u body'ye bırakmak. Klavye kullanıcısı sayfanın başına döner, screen reader kullanıcısı hiçbir şey duymaz.

Altıncısı, composite widget'ın her öğesini Tab sırasına koymak. Grup bir durak, içi ok tuşlarıyla.

Küçük Bir Deneme

★☆☆ styles.css'teki scroll-padding-top'ı 2rem yap ve node keyboard.mjs sticky'yi çalıştır. Tamamen örtülen element kalmadı mı, yoksa "partly hidden" sayısı mı arttı? 2.4.11 ve 2.4.12 açısından sonucu yorumla.

★★☆ Sepetten son kalan kitabı da klavyeyle sil. focusAfterRemoval hangi element'e gidiyor ve screen reader orada ne duyurur? Sepet boşken focus'un "Browse the catalog" link'ine gitmesi daha mı iyi olurdu?

★★★ Katalog için bir "Grid / List" görünüm toolbar'ı yaz: iki toggle button, role="toolbar", roving tabindex ile tek Tab durağı ve sağ/sol ok tuşlarıyla gezinme. keyboard.mjs'e bir mod ekleyip Tab durağı sayısını ve ok tuşu davranışını ölç; sonra aynı davranışı native radio'larla yazmanın daha kısa olup olmadığına bak.

Bu yazıda role="toolbar", aria-labelledby ve aria-activedescendant gibi ARIA attribute'larının adı geçti ama ne yaptıklarına girmedik. Bir sonraki yazıda WAI-ARIA'yı doğru kullanmayı anlatıyoruz: role, state ve property'ler, accessible name'in nasıl hesaplandığı, aynı adı taşıyan "Remove" ve "Add to cart" butonları ve "kötü ARIA'dan hiç ARIA iyidir" kuralı.