Mert Özen Her Satırda Daha İleri
Frontend HTML5 ve Semantic HTML — #11

Accessible Markup Temelleri ve Framework'lerde HTML: Native Önce, Accessibility Tree, Angular Host Element'leri ve Razor

Mert Özen 14 Kas 2026 17 dk 5 görüntülenme
Accessible Markup Temelleri ve Framework'lerde HTML: Native Önce, Accessibility Tree, Angular Host Element'leri ve Razor

Erişilebilir markup'ın temeli önce native element kullanmak, gizlemek için hidden ya da inert seçmek, pozitif tabindex'ten kaçınmak, lang ve dir'i doğru vermek ve framework'ün ürettiği HTML'i tarayıcıdaki çıktıdan okumaktır. Bu yazıda aria-hidden içindeki odaklanabilir butonu, lang="tr" ile değişen büyük İ'yi, Angular'da ul içinde listitem'sız host element'leri ve aria-invalid üretmeyen Razor Tag Helper'larını ölçüp angular-eslint ve Nu Html Checker'ı CI'a alıyoruz.

Sürüm notu: Bu yazı HTML (HyperText Markup Language) Living Standard'ın ve Baseline'ın Kasım 2026 durumuna göre yazıldı; örnekler Chrome 158, WebKit 27.2 (Playwright 1.65), axe-core 4.14, Nu Html Checker 26.11.9, Angular 22.2 ile angular-eslint 22.6 ve .NET 10 (ASP.NET Core Razor Pages) ile denenmiştir.

Önce Tahmin Et

Bookstore'un checkout sayfasında kullanıcıya o an gösterilmeyen üç bölüm var. Üç ayrı geliştirici üç ayrı yolla gizlemiş:

{{site/hiding.html}}

Klavye kullanıcısı sayfayı Tab ile gezerken hangi butonlara ulaşır? Ekran okuyucu hangi başlıkları ve butonları duyurur? Hangi bölüm ekranda görünür ama tıklanamaz? Üçünün de "gizli" olduğunu düşünüyorsan, bir tanesi seni şaşırtacak.

Bir önceki yazı olan "Table'lar ve Tabular Data: caption, thead, th scope, colgroup ve Responsive Table"de Angular'daki bir host element'in koca bir tabloyu ekran okuyucu için nasıl boşalttığını gördük. Bu yazıda o dersi bütün sayfaya genelleştiriyoruz: accessible markup'ın temeli ve framework'lerin ürettiği HTML'i kontrol etmek. ARIA (Accessible Rich Internet Applications), focus yönetimi ve ekran okuyucu testleri ilerideki Accessibility (a11y) serisinin konusu; buradaki giriş kasıtlı olarak kısa.

Önce Native Element: ARIA'yı Ne Zaman Bırakmalı

Bu serinin neredeyse her yazısında aynı şeyi gördük: doğru HTML element'i, accessibility tree'de doğru role'ü, klavye davranışını ve durum bilgisini bedava getiriyor. "Metin Semantiği"nde button, Enter ve Space ile çalıştı, tıklanabilir div çalışmadı. "Native Interaktif Element'ler 1"de dialog odağı ve inert'i yönetti. Bir önceki yazıda tr satır oldu, app-order-row olmadı.

W3C'nin (World Wide Web Consortium) ARIA kullanım kılavuzundaki ilk kural da bunu söyler: ihtiyacın olan anlam ve davranış native bir HTML element'inde zaten varsa, bir element'i ARIA ile başka bir şeye çevirmek yerine onu kullan. ARIA bir element'in tree'de nasıl görüneceğini değiştirir, ama davranış eklemez: role="button" verdiğin bir div ekran okuyucuda "button" diye duyulur, ama Tab ile odaklanmaz ve Enter'a tepki vermez; onları senin yazman gerekir. Bu yüzden kendi kodunda bir role attribute'u gördüğünde ilk soru şu olmalı: bunun yerine bir native element kullanabilir miyim? Cevap çoğu zaman evet. Hayır olduğu durumlar (tab'lar, combobox, tree, menü) ve ARIA'nın doğru kullanımı a11y serisinde.

hidden, aria-hidden ve inert

Tahmin sorusuna dönelim. Üç bölümü dört soruyla ölçen script:

const { chromium } = require('playwright');
const fs = require('fs');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(fs.readFileSync('site/hiding.html', 'utf8'));

  // Accessibility tree'de hangi heading ve button'lar var?
  const cdp = await page.context().newCDPSession(page);
  const { nodes } = await cdp.send('Accessibility.getFullAXTree');
  const inTree = new Set(nodes.filter(n => !n.ignored && ['heading', 'button'].includes(n.role?.value))
    .map(n => n.name.value));

  // Tab ile sırayla hangi button'lara gidilebiliyor?
  const tabStops = [];
  for (let i = 0; i < 3; i++) {
    await page.keyboard.press('Tab');
    tabStops.push(await page.evaluate(() =>
      document.activeElement === document.body ? '(body)' : document.activeElement.textContent));
  }

  for (const id of ['with-hidden', 'with-aria-hidden', 'with-inert']) {
    const section = page.locator(`#${id}`);
    const button = section.locator('button');
    const name = await button.textContent();
    let clicked = 'yes';
    try { await button.click({ timeout: 500 }); } catch { clicked = 'no'; }
    console.log(`${id.padEnd(17)} visible=${String(await section.isVisible()).padEnd(5)} ` +
      `inTree=${String(inTree.has(name)).padEnd(5)} tabbable=${String(tabStops.includes(name)).padEnd(5)} clickable=${clicked}`);
  }
  console.log('tab order:', tabStops.join(' -> '));

  await page.addScriptTag({ path: require.resolve('axe-core') });
  console.log('axe:', await page.evaluate(async () =>
    (await axe.run()).violations.map(v => `${v.id} (${v.impact})`).join(', ') || 'no violations'));
  await browser.close();
})();
with-hidden       visible=false inTree=false tabbable=false clickable=no
with-aria-hidden  visible=true  inTree=false tabbable=true  clickable=yes
with-inert        visible=true  inTree=false tabbable=false clickable=no
tab order: Use this address -> Place order -> (body)
axe: aria-hidden-focus (serious)

hidden en net olanı: bölüm ekranda yok, tree'de yok, Tab ile ulaşılamıyor, tıklanamıyor. Tarayıcının varsayılan stilinde display: none olduğu için kullanıcı açısından hiç var olmamış gibi. Bir dikkat: CSS (Cascading Style Sheets) ile display'i başka bir değere ezersen hidden görsel etkisini kaybeder.

inert bölümü ekranda bırakır ama onu her türlü etkileşimden çıkarır: tree'de yok, Tab ile ulaşılmıyor, tıklanamıyor. "Native Interaktif Element'ler 1"de modal dialog'un arkasındaki sayfaya tarayıcının uyguladığı şey buydu; inert attribute'u bunu herhangi bir bölüme elle uygulamanı sağlar. Bir adım tamamlanmadan dokunulmaması gereken ödeme bölümü, ya da kendi yazdığın bir çekmece menü açıkken arkadaki içerik için doğru araç.

aria-hidden="true" ise tuzak. Bölüm tree'den çıktı, ekran okuyucu "Saved addresses" başlığını duymaz; ama "Use this address" butonu Tab sırasında ilk durak ve tıklanabiliyor. Klavyeyle gezen bir ekran okuyucu kullanıcısı, var olmayan bir şeyin üzerine odaklanmış olur ve ne olduğunu duyamaz. axe tam olarak bunu aria-hidden-focus diye ciddi bir ihlal olarak işaretledi. aria-hidden yalnız görsel olarak anlamı tekrar eden ve etkileşimsiz içerik (ör. yanında metni olan bir ikon) için doğru; içinde odaklanabilir bir şey varsa yanlış.

tabindex: 0, -1 ve Pozitif Değerin Bedeli

tabindex üç anlamda kullanılır. 0 normalde odaklanamayan bir element'i doğal Tab sırasına ekler; bir önceki yazıdaki kaydırılabilir tablo wrapper'ı gibi. -1 element'i Tab sırasına koymadan JavaScript ile odaklanabilir yapar; bir hata özeti ya da bir route değişiminden sonra odağı taşıdığın başlık için. Pozitif değerler ise sırayı elle belirler ve hemen her zaman yanlıştır:

{{site/tabindex.html}}
const { chromium } = require('playwright');
const fs = require('fs');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(fs.readFileSync('site/tabindex.html', 'utf8'));
  const stops = [];
  for (let i = 0; i < 5; i++) {
    await page.keyboard.press('Tab');
    stops.push(await page.evaluate(() => {
      const e = document.activeElement;
      return e.labels?.[0]?.textContent ?? e.textContent;
    }));
  }
  console.log('tab order:', stops.join(' -> '));
  await page.addScriptTag({ path: require.resolve('axe-core') });
  console.log('axe:', await page.evaluate(async () =>
    (await axe.run()).violations.map(v => `${v.id} (${v.impact})`).join(', ') || 'no violations'));
  await browser.close();
})();
tab order: Search books -> Bookstore -> Catalog -> Cart -> Add to cart
axe: tabindex (serious)

Arama kutusuna "kullanıcı ilk ona gelsin" diye tabindex="1" verilmiş. Sonuç: ilk Tab, sayfanın sonunda duran arama kutusuna gidiyor, sonra odak sayfanın başına, logoya geri zıplıyor ve oradan normal sıraya devam ediyor. Görsel sıra ile odak sırası artık farklı; pozitif tabindex'li element'ler her zaman diğer hepsinden önce gelir, ve sayfaya ikinci bir pozitif değer eklendiğinde sıra tahmin edilemez hâle gelir. axe bunu da ciddi buldu. Arama kutusu önce gelsin istiyorsan onu DOM'da (Document Object Model) önce yaz.

lang ve dir

html'deki lang attribute'unu serinin ilk yazısından beri yazıyoruz. Ekran okuyucu hangi dilin telaffuzunu kullanacağına onunla karar verir; İngilizce bir sayfada geçen Türkçe bir kitap adı lang="tr" almazsa İngilizce kurallarla okunur. Ama lang yalnız ekran okuyucuyu değil, tarayıcının metin işleme kurallarını da etkiler:

{{site/lang.html}}
const pw = require('playwright');
const fs = require('fs');

(async () => {
  for (const name of ['chromium', 'webkit']) {
    const browser = await pw[name].launch();
    const page = await browser.newPage();
    await page.setContent(fs.readFileSync('site/lang.html', 'utf8'));
    console.log(`--- ${name} ${browser.version()}`);
    // text-transform dilin kurallarını kullanır: "i" hangi büyük harfe dönüşüyor?
    for (const id of ['badge-en', 'badge-tr']) {
      console.log(`${id}: ${await page.locator(`#${id}`).innerText()}`);
    }
    // dir="auto": yön metnin ilk güçlü karakterinden belirlenir
    for (const id of ['review-1', 'review-2']) {
      console.log(`${id}: direction=${await page.locator(`#${id}`).evaluate(e => getComputedStyle(e).direction)}`);
    }
    await browser.close();
  }
})();
--- chromium 158.0.8127.18
badge-en: SHIPS FROM ISTANBUL
badge-tr: İSTANBUL DEPOSUNDAN
review-1: direction=ltr
review-2: direction=rtl
--- webkit 27.2
badge-en: SHIPS FROM ISTANBUL
badge-tr: İSTANBUL DEPOSUNDAN
review-1: direction=ltr
review-2: direction=rtl

Aynı CSS kuralı (text-transform: uppercase), aynı "istanbul" kelimesini İngilizce paragrafta "ISTANBUL", lang="tr" olan paragrafta "İSTANBUL" yaptı. İki motor da aynı sonucu verdi. Türkçe bir sitede lang="en" bırakılmış bir layout, büyük harfli bütün buton ve rozetlerde "I" ile "İ"yi karıştırır. Tireleme (hyphens: auto) ve tırnak işaretleri de dile göre değişir.

dir yazı yönüdür. Bookstore'un okur yorumları her dilden gelebilir ve bir yorumun yönünü önceden bilemezsin. dir="auto" tarayıcıya yönü metnin ilk güçlü karakterinden belirlemesini söyler: İngilizce yorum ltr, Arapça yorum rtl oldu. Kullanıcının yazdığı ve dilini bilmediğin her metin için (yorumlar, kullanıcı adları, arama sorguları) dir="auto" doğru varsayılan.

DevTools'ta Accessibility Tree'yi Okumak

Bu serideki ölçümlerin çoğunu Chromium'un accessibility tree'sini bir script ile okuyarak yaptım. Günlük işte aynısını Chrome DevTools'ta yapabilirsin. Elements panelinde bir element'i seçip yan paneldeki Accessibility sekmesini açtığında o element'in role'ünü, hesaplanan adını ve adın nereden geldiğini (label, alt, aria-labelledby, içerik) görürsün. Aynı sekmedeki "full-page accessibility tree" seçeneği açıldığında Elements panelinde DOM görünümü ile accessibility tree görünümü arasında geçiş yapabilirsin. Elements panelinin kendisi Browser DevTools serisinin "Chrome DevTools'a Giriş ve Elements Paneli" yazısında.

Bu görünümü kullanmanın en iyi yolu bir soru sormak: "Bir ekran okuyucu bu sayfaya girdiğinde ne duyar?" Landmark'lar adlı mı, başlık seviyeleri sıralı mı, her buton ve link'in bir adı var mı, liste gerçekten liste mi, tablo gerçekten tablo mu? Bir önceki yazıdaki generic hücreler ve aşağıdaki boş liste, bu görünümde ilk bakışta fark edilir.

Angular: Host Element'ler, Attribute Selector ve Host Binding

Bir önceki yazıdaki tablo sorununun daha sık görülen hâli listeler. Bookstore'un "Recommended for you" bölümü, aynı kitap kartının üç sürümüyle:

import { Component, input } from '@angular/core';
import { Book } from './book';

@Component({
  selector: 'app-book-card',
  template: `<a href="/books/{{ book().id }}">{{ book().title }}</a> by {{ book().author }}`,
})
export class BookCard {
  readonly book = input.required<Book>();
}
import { Component, input } from '@angular/core';
import { Book } from './book';

@Component({
  selector: 'app-book-card-role',
  host: { role: 'listitem' },
  template: `<a href="/books/{{ book().id }}">{{ book().title }}</a> by {{ book().author }}`,
})
export class BookCardHostRole {
  readonly book = input.required<Book>();
}
import { Component, input } from '@angular/core';
import { Book } from './book';

@Component({
  selector: 'li[appBookItem]',
  template: `<a href="/books/{{ book().id }}">{{ book().title }}</a> by {{ book().author }}`,
})
export class BookItem {
  readonly book = input.required<Book>({ alias: 'appBookItem' });
}
  <h2>Recommended for you</h2>
  <ul id="books-element">
    @for (book of books; track book.id) {
      <app-book-card [book]="book" />
    }
  </ul>
  <ul id="books-host-role">
    @for (book of books; track book.id) {
      <app-book-card-role [book]="book" />
    }
  </ul>
  <ul id="books-attribute">
    @for (book of books; track book.id) {
      <li [appBookItem]="book"></li>
    }
  </ul>

Bir önceki yazının angular-dom.js'ini bu kez listelerin id'leriyle çalıştırdım, ardından listeleri checker'a verdim:

node angular-dom.js books-element books-host-role books-attribute
books-element     list   
books-host-role   list   listitem=2
books-attribute   list   listitem=2
const { chromium } = require('playwright');
const http = require('http');
const fs = require('fs');
const path = require('path');

const dist = path.resolve('bookstore-web/dist/bookstore-web/browser');
const server = http.createServer((req, res) => {
  const file = path.join(dist, req.url === '/' ? 'index.html' : req.url);
  res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : 'text/html; charset=utf-8');
  res.end(fs.readFileSync(file));
}).listen(18895);

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('http://localhost:18895/');
  await page.waitForSelector('#books-attribute li');
  // önceki yazının hatalı tablosu checker'ı fatal error ile durduruyor; listeleri görmek için onu çıkar
  await page.evaluate(() => document.getElementById('orders-element').remove());
  fs.mkdirSync('rendered-lists', { recursive: true });
  fs.writeFileSync('rendered-lists/account.html', await page.content());
  await browser.close();
  server.close();
})();
"file:/site/account.html":9.455-9.469: error: Element “app-book-card” not allowed as child of element “ul” in this context. (Suppressing further errors from this subtree.)
"file:/site/account.html":9.537-9.551: error: Element “app-book-card” not allowed as child of element “ul” in this context. (Suppressing further errors from this subtree.)
"file:/site/account.html":9.662-9.697: error: Element “app-book-card-role” not allowed as child of element “ul” in this context. (Suppressing further errors from this subtree.)
"file:/site/account.html":9.770-9.805: error: Element “app-book-card-role” not allowed as child of element “ul” in this context. (Suppressing further errors from this subtree.)

Element selector'lı ilk listede hiç listitem yok: ul'un doğrudan çocukları app-book-card. Ekran okuyucu bir liste olduğunu duyurur ama içinde hiç item bulamaz; "list, 2 items" bilgisi kaybolur. Checker de her kart için error veriyor.

İkinci liste host binding ile çözmeye çalışıyor: component'in host metadata'sı, Angular'ın ürettiği app-book-card-role element'ine role="listitem" ekliyor. Accessibility tree açısından bu işe yaradı: iki listitem var. Ama HTML açısından ul'un içinde hâlâ li olmayan bir element var ve checker yine error veriyor. Host binding, host element'i değiştiremediğin durumlar için (ör. bir üçüncü parti component'i sarmalarken) kabul edilebilir bir yama; host'a class ya da aria-* eklemek için de iyi bir yer.

Üçüncü liste, bir önceki yazıdaki tablo satırıyla aynı çözüm: selector: 'li[appBookItem]'. Angular component'i template'teki li'nin kendisine bağlıyor; hem accessibility tree hem checker memnun. Tablo, liste, select ya da dl gibi içerik modeli katı olan bir element'in içine giren her component için kural bu: host'u, olması gereken element yap.

Razor: Tag Helper Çıktısını Kontrol Etmek

Razor'da durum tersine: Tag Helper'lar senin yerine HTML üretir ve o HTML'e genellikle hiç bakılmaz. Bookstore'un checkout'undaki e-posta alanı, ASP.NET Core'un standart Tag Helper'larıyla:

{{razor/Pages/Checkout.cshtml}}
{{razor/Pages/Checkout.cshtml.cs}}

Üretilen HTML'i iki durumda, sayfa ilk açıldığında ve boş form gönderildikten sonra, tarayıcıdan okuyan script:

const { chromium } = require('playwright');

const url = `http://localhost:5292${process.argv[2] ?? '/checkout'}`;

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto(url);
  const field = () => page.evaluate(() =>
    [...document.querySelectorAll('form > :not(button):not([type=hidden])')].map(e => e.outerHTML).join('\n'));
  console.log('--- GET\n' + await field());

  // boş form'u gönder; form.submit() native doğrulamayı atlar, böylece sunucunun yanıtını görürüz
  await Promise.all([page.waitForNavigation(), page.evaluate(() => document.querySelector('form').submit())]);
  console.log('--- POST (empty)\n' + await field());
  const ax = await page.getByLabel('Email address').evaluate(i =>
    `invalid=${i.getAttribute('aria-invalid')} describedby=${i.getAttribute('aria-describedby')}`);
  console.log(ax);
  await browser.close();
})();
--- GET
<label for="Input_Email">Email address</label>
<input autocomplete="email" type="email" data-val="true" data-val-email="The Email address field is not a valid e-mail address." data-val-required="The Email address field is required." id="Input_Email" name="Input.Email" value="">
<span class="field-validation-valid" data-valmsg-for="Input.Email" data-valmsg-replace="true"></span>
--- POST (empty)
<label for="Input_Email">Email address</label>
<input autocomplete="email" type="email" class="input-validation-error" data-val="true" data-val-email="The Email address field is not a valid e-mail address." data-val-required="The Email address field is required." id="Input_Email" name="Input.Email" value="">
<span class="field-validation-error" data-valmsg-for="Input.Email" data-valmsg-replace="true">The Email address field is required.</span>
invalid=null describedby=null

İyi taraflar: label'ın for'u ile input'un id'si aynı (Input_Email), label metni [Display]'den geliyor, [EmailAddress] type="email" üretmiş. Eksik taraflar üç tane. [Required]'dan required attribute'u üretilmiyor; kural yalnız jQuery Unobtrusive Validation için data-val-* attribute'larında, yani "HTML Form'ları 2" yazısındaki native validation devrede değil. Sunucu hatayı döndüğünde input'a yalnız bir CSS class'ı (input-validation-error) eklenmiş; aria-invalid yok, ekran okuyucu alanın hatalı olduğunu bilmiyor. Ve hata mesajı span'ı input'a bağlı değil (aria-describedby yok): ekran okuyucu alana odaklandığında hata mesajını okumaz. Düzeltilmiş sürüm:

{{razor/Pages/CheckoutFixed.cshtml}}
node taghelper.js /checkout-fixed
--- GET
<label for="Input_Email">Email address</label>
<input autocomplete="email" required="" aria-describedby="email-error" type="email" data-val="true" data-val-email="The Email address field is not a valid e-mail address." data-val-required="The Email address field is required." id="Input_Email" name="Input.Email" value="">
<span id="email-error" class="field-validation-valid" data-valmsg-for="Input.Email" data-valmsg-replace="true"></span>
--- POST (empty)
<label for="Input_Email">Email address</label>
<input autocomplete="email" required="" aria-describedby="email-error" aria-invalid="true" type="email" class="input-validation-error" data-val="true" data-val-email="The Email address field is not a valid e-mail address." data-val-required="The Email address field is required." id="Input_Email" name="Input.Email" value="">
<span id="email-error" class="field-validation-error" data-valmsg-for="Input.Email" data-valmsg-replace="true">The Email address field is required.</span>
invalid=true describedby=email-error

Artık required native, hata mesajının bir id'si var ve input ona aria-describedby ile bağlı; sunucu hata döndüğünde aria-invalid="true" basılıyor. Razor değeri null olan attribute'u hiç yazmadığı için ilk açılışta aria-invalid yok. Bunu her alan için tekrar etmek yerine özel bir Tag Helper ya da partial view'da toplamak doğal bir sonraki adım; Tag Helper yazmak ve validation ayrıntısı ASP.NET Core MVC (Model-View-Controller) ve Razor Pages serisinde. Buradaki ders daha genel: framework'ün ürettiği HTML'i kaynak koddan değil, tarayıcıya giden çıktıdan oku.

Üretimde Dikkat: Doğrulamayı CI'a Almak

Bu yazıdaki sorunların hepsi bir araçla yakalanabiliyor; yeter ki araç her pull request'te çalışsın. İki katman öneriyorum. İlki template seviyesinde lint: angular-eslint'in templateAccessibility config'i, ng add angular-eslint ile gelen eslint.config.js'te zaten açık. Hatalı bir component ve config'in ilgili kısmı:

import { Component, input, signal } from '@angular/core';
import { Book } from './book';

@Component({
  selector: 'app-book-preview',
  template: `
    <img [src]="'/covers/' + book().id + '.webp'" width="200" height="300">
    <div class="preview-toggle" (click)="open.set(!open())">Preview</div>
    <input type="search" tabindex="2">
  `,
})
export class BookPreview {
  readonly book = input.required<Book>();
  protected readonly open = signal(false);
}
      '@angular-eslint/component-selector': [
        'error',
        [
          { type: 'element', prefix: 'app', style: 'kebab-case' },
          { type: 'attribute', prefix: 'app', style: 'camelCase' },
        ],
      ],
    },
  },
  {
    files: ['**/*.html'],
    extends: [angular.configs.templateRecommended, angular.configs.templateAccessibility],
    rules: {
      '@angular-eslint/template/no-positive-tabindex': 'error',
      '@angular-eslint/template/button-has-type': 'error',
    },
  },
]);
npx ng lint

Linting "bookstore-web"...

src/app/books/book-preview.ts
  7:5   error  <img/> element must have a text alternative                                             @angular-eslint/template/alt-text
  8:5   error  click must be accompanied by either keyup, keydown or keypress event for accessibility  @angular-eslint/template/click-events-have-key-events
  8:5   error  Elements with interaction handlers must be focusable                                    @angular-eslint/template/interactive-supports-focus
  9:36  error  The `tabindex` attribute should not be positive                                         @angular-eslint/template/no-positive-tabindex

✖ 4 problems (4 errors, 0 warnings)

Lint errors found in the listed files.

Üç hata templateAccessibility'den geldi: alt'sız img, klavye event'i olmayan tıklanabilir div ve odaklanamayan etkileşimli element. Pozitif tabindex ise bu config'de yok; no-positive-tabindex'i (ve button-has-type'ı) rules'a kendim ekledim, yoksa yakalanmıyordu. İki not daha: varsayılan component-selector kuralı yalnız element selector'a izin veriyordu ve bu yazıdaki li[appBookItem] ile bir önceki yazıdaki tr[appOrderRow] için error verdi; config'i element ve attribute selector'ı ayrı ayrı kabul edecek şekilde değiştirdim. Ve no-autofocus kuralı açık: "Native Interaktif Element'ler 1"deki gibi dialog içindeki bilinçli bir autofocus için kuralı o satırda yorumla kapatman gerekir. Kural setinin ayrıntısı ilerideki Statik Analiz serisinde.

İkinci katman, render edilmiş HTML'e Nu Html Checker. Client-side render eden bir Angular uygulamasında dist/index.html neredeyse boş bir kabuk; checker'a orayı verirsen hiçbir şey bulmaz. Sayfayı bir tarayıcıda açıp son DOM'u kaydeden küçük bir script ve bir GitHub Actions job'u:

import { chromium } from 'playwright';
import { createServer } from 'node:http';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

const dist = 'dist/bookstore-web/browser';
const routes = ['/'];
const outDir = process.argv[2] ?? 'rendered';

// SPA: dosya yoksa index.html dön
const server = createServer((req, res) => {
  const file = join(dist, req.url);
  let body;
  try { body = readFileSync(file); } catch { body = readFileSync(join(dist, 'index.html')); }
  res.setHeader('Content-Type', req.url.endsWith('.js') ? 'text/javascript' : 'text/html; charset=utf-8');
  res.end(body);
}).listen(4300);

const browser = await chromium.launch();
const page = await browser.newPage();
mkdirSync(outDir, { recursive: true });
for (const route of routes) {
  await page.goto(`http://localhost:4300${route}`, { waitUntil: 'networkidle' });
  const name = route === '/' ? 'index' : route.slice(1).replaceAll('/', '-');
  writeFileSync(join(outDir, `${name}.html`), await page.content());
  console.log(`rendered ${route} -> ${outDir}/${name}.html`);
}
await browser.close();
server.close();
name: html-quality

on:
  pull_request:

jobs:
  html:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      # template accessibility kuralları (angular-eslint)
      - run: npx ng lint
      - run: npx ng build
      # CSR uygulamada dist/index.html boş bir kabuk; checker'a tarayıcıdaki DOM'u ver
      - run: npx playwright install --with-deps chromium
      - run: node scripts/render-pages.mjs rendered
      - name: Nu Html Checker
        run: docker run --rm -v "$PWD/rendered:/site" ghcr.io/validator/validator:latest vnu --errors-only /site

Workflow'u actionlint ile denetledim ve adımları yerelde tek tek çalıştırdım: lint yukarıdaki hataları verdi, render-pages.mjs sayfayı kaydetti, checker bir önceki yazıdaki app-order-row hatasında durdu. GitHub üzerinde çalıştırmadım. SSR (Server-Side Rendering) ya da prerender kullanıyorsan bu adıma gerek yok; checker'ı doğrudan sunucunun ürettiği HTML'e ver. Razor uygulamalarında da aynısı: uygulamayı CI'da (Continuous Integration) ayağa kaldırıp kritik sayfaları curl ile bir klasöre indir ve checker'ı oraya çalıştır.

Sık Düşülen Tuzaklar

Birincisi, içinde odaklanabilir element olan bir bölüme aria-hidden="true" vermek. Gizlemek için hidden, etkileşimden çıkarmak için inert kullan.

İkincisi, pozitif tabindex. Sırayı DOM'daki sırayla belirle.

Üçüncüsü, lang'ı yanlış bırakmak ya da sayfadaki başka dildeki metne lang vermemek; Türkçe bir sitede "i"/"İ" hatası buradan gelir.

Dördüncüsü, Angular'da ul, ol, tbody, tr ya da select içine element selector'lı component koymak. Host'u olması gereken element yapan attribute selector'ı kullan.

Beşincisi, Tag Helper'ın ürettiği HTML'i hiç okumamak. Label bağlantısı doğru olsa bile hata durumu ve mesaj bağlantısı eksik olabilir.

Altıncısı, bir role'ün davranış da getirdiğini sanmak. role="button" klavye desteği eklemez; button ekler.

Küçük Bir Deneme

★☆☆ hiding.html'deki aria-hidden="true"'yu inert ile değiştir ve hiding.js'i tekrar çalıştır. axe ihlali kayboldu mu, Tab sırası nasıl değişti?

★★☆ Kendi projende Chrome DevTools'un full-page accessibility tree görünümünü aç ve bir sayfayı yalnız o görünümden okumaya çalış. Adı olmayan bir buton, item'ı olmayan bir liste ya da generic'e dönüşmüş bir tablo buldun mu?

★★★ Angular projene angular-eslint'in templateAccessibility config'ini, no-positive-tabindex ve button-has-type ile birlikte ekle ve çıkan hataları say. Ardından render-pages.mjs benzeri bir script ile en önemli üç sayfanı render edip Nu Html Checker'dan geçir. İkisinin bulduklarını karşılaştır: hangi hataları yalnız biri yakaladı?

Seri boyunca element'leri tek tek ve ölçerek ele aldık. Son yazıda geri çekilip resmin bütününe bakıyoruz: 2023'ten 2026'ya HTML'e neler geldi, Baseline'ın newly ve widely available ayrımı bunları bugün kullanmak için ne anlama geliyor, ve Bookstore'da hangi özelliği bugün, hangisini fallback ile kullanırız.