Table'lar ve Tabular Data: caption, thead, th scope, colgroup ve Responsive Table
Veri tablosunun adı caption'dan, satır ve sütun ilişkisi th scope'tan gelir; dar ekranda tablo tabindex, role="region" ve adı olan kaydırılabilir bir wrapper'a konur. Bu yazıda Bookstore'un sipariş geçmişini accessibility tree'de okuyor, col'a verilen text-align'ın işlemediğini, headers/id hatasını, WebKit'in düz wrapper'a odak vermediğini ve Angular'da tr yerine host element kullanan satırın tabloyu nasıl boşalttığını ölçü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 ve Angular 22.2 (TypeScript 6.0) ile denenmiştir.
Önce Tahmin Et
Bookstore'un Angular uygulamasında sipariş geçmişi tablosunun her satırı bir component. Satırı yazan geliştirici her component gibi ona bir element selector vermiş:
import { Component, input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { Order } from './order';
@Component({
selector: 'app-order-row',
imports: [CurrencyPipe],
template: `
<th scope="row">#{{ order().id }}</th>
<td>{{ order().date }}</td>
<td>{{ order().total | currency: 'USD' }}</td>
`,
})
export class OrderRowElement {
readonly order = input.required<Order>();
}
Hesap sayfasının template'inde bu component tablonun tbody'sinde kullanılıyor; hemen altında aynı tablonun ikinci bir sürümü var:
<table id="orders-element">
<caption>Orders (element selector)</caption>
<thead>
<tr><th scope="col">Order</th><th scope="col">Date</th><th scope="col">Total</th></tr>
</thead>
<tbody>
@for (order of orders; track order.id) {
<app-order-row [order]="order" />
}
</tbody>
</table>
<table id="orders-attribute">
<caption>Orders (attribute selector)</caption>
<thead>
<tr><th scope="col">Order</th><th scope="col">Date</th><th scope="col">Total</th></tr>
</thead>
<tbody>
@for (order of orders; track order.id) {
<tr [appOrderRow]="order"></tr>
}
</tbody>
</table>
İki tablo da ekranda düzgün görünüyor, sütunlar hizalı. Bir ekran okuyucu kullanıcısı için ilk tabloda kaç satır var? Sipariş numarasını taşıyan th scope="row" bir satır başlığı olarak çalışıyor mu? Nu Html Checker ne der? Ve bu sayfayı Angular SSR (Server-Side Rendering) ile sunucuda render etseydik ne değişirdi?
Bir önceki yazı olan "head ve Meta Tag'ler: charset, viewport, title, description, Open Graph, theme-color ve link rel'leri"nde sayfanın kimliğini kurduk. Bu yazıda gövdeye dönüyoruz ve en çok yanlış kullanılan element'lerden birine bakıyoruz: table. Tablo, satır ve sütun ilişkisi olan veri içindir; ekran okuyucu bir hücreye geldiğinde onun hangi satır ve sütun başlığına ait olduğunu söyleyebilir. Bu ilişki markup doğruysa bedava gelir, yanlışsa sessizce kaybolur.
Sipariş Geçmişi: caption, thead, tbody, tfoot ve th scope
Önce düz HTML ile doğru tablo. Bookstore'un hesap sayfasındaki sipariş geçmişi:
{{site/orders.html}}
Chromium'un accessibility tree'sinden tablo ile ilgili role'leri yazdıran script (serinin "Semantic HTML ve Landmark'lar" yazısındaki axtree.js'in tablo için uyarlanmış hâli):
const { chromium } = require('playwright');
const fs = require('fs');
const show = new Set(['region', 'table', 'rowgroup', 'row', 'columnheader', 'rowheader', 'cell', 'generic', 'caption']);
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: Number(process.argv[3] ?? 1280), height: 800 } });
await page.setContent(fs.readFileSync(process.argv[2], 'utf8'));
const cdp = await page.context().newCDPSession(page);
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
const byId = new Map(nodes.map(n => [n.nodeId, n]));
const walk = (n, depth) => {
const role = n.role?.value;
const visible = !n.ignored && show.has(role) && !(role === 'generic' && !n.name?.value);
if (visible) {
console.log(`${' '.repeat(depth)}${role} "${n.name?.value ?? ''}"`);
}
for (const id of n.childIds ?? []) walk(byId.get(id), visible ? depth + 1 : depth);
};
walk(nodes[0], 0);
await browser.close();
})();
node axtree.js site/orders.html
region "Order history, last 12 months"
table "Order history, last 12 months"
caption ""
rowgroup ""
row ""
columnheader "Order"
columnheader "Date"
columnheader "Status"
columnheader "Total"
row ""
rowheader "#1042"
cell "2 Nov 2026"
cell "Shipped"
cell "84.90 USD"
row ""
rowheader "#1017"
cell "14 Oct 2026"
cell "Delivered"
cell "34.90 USD"
rowgroup ""
row ""
rowheader "Total spent"
cell "119.80 USD"
caption tablonun adıdır ve tree'de tablonun adı ("Order history, last 12 months") oradan geliyor. Ekran okuyucu tabloya girerken bu adı okur; sayfada birden fazla tablo varsa kullanıcı onları adlarıyla ayırt eder. Başlığı tablonun üstüne bir h2 olarak yazıp caption'ı boş bırakmak yaygın bir alışkanlık; görsel olarak aynı, ama tablo adsız kalır. Ya caption kullan ya da tabloyu aria-labelledby ile o başlığa bağla.
thead, tbody ve tfoot satırları gruplar. Tree'de thead ve tfoot birer rowgroup olarak görünüyor; tbody ise Chromium'da ayrı bir düğüm üretmiyor, satırlar doğrudan tablonun altında. Bu grupların asıl faydası yapısal: tablo yazdırılırken thead her sayfada tekrar edebilir, CSS (Cascading Style Sheets) ile başlığı ya da toplam satırını hedeflemek kolaylaşır, ve sıralama ya da sayfalama yapan JavaScript yalnız tbody'ye dokunur.
Başlık hücreleri th'dir ve scope attribute'u hangi yöne başlık olduklarını söyler. scope="col" olan dört hücre columnheader, sipariş numaralarını taşıyan scope="row" hücreleri rowheader oldu. Ekran okuyucu "84.90 USD" hücresine geldiğinde bu sayede "Total, #1042" bağlamını verebilir. Satır başlığı, satırı tek başına tanımlayan değer olmalı: sipariş numarası iyi bir seçim, "Shipped" gibi tekrar eden bir durum değil.
tfoot'taki toplam satırı da bir satır başlığı taşıyor: "Total spent", colspan="3" ile üç sütunu kaplıyor. Toplam satırını tbody'nin son satırına td olarak yazmak sık yapılır; o zaman toplam, bir siparişmiş gibi okunur. Tarihte time element'i, serinin "Metin Semantiği" yazısında gördüğümüz gibi, makinenin okuyacağı değeri datetime'da tutuyor.
colgroup ve col: Sütunu Stillemek
colgroup ve col sütunları markup'ta tanımlar. Para birimini sağa yaslamak için col'a bir class verip işi bitirmek cazip, ama CSS'in tablo modelinde col'a yalnız birkaç property uygulanır: border, background, width ve visibility. Ölçtüm:
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(`
<style>
col.amount { text-align: end; background-color: rgb(240, 240, 240); width: 120px; }
</style>
<table>
<colgroup><col><col class="amount"></colgroup>
<tr><th scope="row">#1042</th><td>84.90 USD</td></tr>
</table>`);
const td = await page.evaluate(() => {
const cell = document.querySelector('td');
return `text-align=${getComputedStyle(cell).textAlign} width=${cell.getBoundingClientRect().width}`;
});
console.log(td);
await browser.close();
})();
text-align=start width=120
width uygulandı (120 piksel), text-align uygulanmadı (start). Hücre col'un çocuğu değil, o yüzden col'a verilen text-align miras olarak da gelmez. Sipariş tablosunda amount class'ının hem col'da hem her td'de olmasının sebebi bu: col'daki yalnız belgeleme, hizalamayı td'deki yapıyor. Sütun genişliği ve zemin rengi için col yeterli; geri kalanı için hücre selector'ı (ör. td:nth-child(4)) ya da hücredeki class gerekir.
Karmaşık Tablolar: headers ve id
Her hücrenin tek bir sütun ve tek bir satır başlığı varsa scope yeter. Başlıkların iki seviyeli olduğu bir rapor tablosunda ise hücreyi başlıklarına tek tek bağlamak gerekir: her başlığa bir id, her hücreye o id'leri listeleyen bir headers attribute'u. Bookstore'un yıllık satış raporu, yarıyıl ve çeyrek başlıklarıyla (çeyrekler Q1 (first quarter) ile başlayan kısaltmalarla):
{{site/report.html}}
"E-book" satırının son hücresinde bir hata var: headers listesi var olmayan bir q5'e işaret ediyor. axe ve checker ikisi de buldu:
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/report.html', 'utf8'));
await page.addScriptTag({ path: require.resolve('axe-core') });
const result = await page.evaluate(async () =>
(await axe.run(document, { runOnly: ['td-headers-attr', 'th-has-data-cells'] })).violations
.flatMap(v => v.nodes.map(n => `${v.id}: ${n.html}`)));
console.log(result.join('\n') || 'no violations');
await browser.close();
})();
td-headers-attr: <table>
docker run --rm -v "${PWD}/site:/site" ghcr.io/validator/validator:latest vnu --errors-only /site
"file:/site/report.html":38.55-39.46: error: The “headers” attribute on the element “td” refers to the ID “q5”, but there is no “th” element with that ID in the same table.
Checker satır ve sütun numarasıyla daha net; axe ise hatanın tabloda olduğunu söylüyor. headers/id güçlü ama kırılgan: elle yazılan id'ler bir kopyala-yapıştırda kolayca kayar. Böyle bir tabloyu her zaman bir template döngüsüyle üret ve tercihen önce tabloyu sadeleştirmeyi dene. İki seviyeli başlık yerine "Q1 2026" gibi birleşik tek seviyeli başlıklar çoğu zaman hem okuması hem bakımı kolay bir tablo verir.
Dar Ekranda Tablo: Kaydırılabilir ve Odaklanabilir Wrapper
Dört sütunlu sipariş tablosu 390 piksellik bir telefona sığmıyor. En basit ve semantiği hiç bozmayan çözüm, tabloyu yatayda kaydırılabilir bir kutuya koymak: orders.html'deki div.table-scroll ve onun overflow-x: auto'su. Ama kaydırılabilir bir kutu, fareyle ya da dokunarak kaydırılabilir; klavye kullanıcısının onu kaydırmak için önce odaklayabilmesi gerekir. Wrapper'ın düz hâlini ve tabindex="0", role="region" ve aria-labelledby taşıyan hâlini iki motorda karşılaştırdım:
const pw = require('playwright');
const fs = require('fs');
const orders = fs.readFileSync('site/orders.html', 'utf8');
const variants = {
'plain wrapper': orders.replace(' tabindex="0" role="region" aria-labelledby="orders-caption"', ''),
'focusable region': orders,
};
(async () => {
for (const engine of ['chromium', 'webkit']) {
const browser = await pw[engine].launch();
console.log(`--- ${engine} ${browser.version()}`);
for (const [name, html] of Object.entries(variants)) {
// 390 piksellik ekranda 640 piksellik tablo yatayda kayar
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
await page.setContent(html);
await page.addScriptTag({ path: require.resolve('axe-core') });
const violations = await page.evaluate(async () =>
(await axe.run(document, { runOnly: ['scrollable-region-focusable'] })).violations.map(v => `${v.id} (${v.impact})`));
// yalnız klavye: Tab'a basınca odak kaydırılabilir wrapper'a geliyor mu?
await page.keyboard.press('Tab');
const focus = await page.evaluate(() => document.activeElement.className || document.activeElement.tagName.toLowerCase());
console.log(`${name.padEnd(17)} focus=${focus.padEnd(13)} axe: ${violations.join(', ') || 'no violations'}`);
await page.close();
}
await browser.close();
}
})();
--- chromium 158.0.8127.18
plain wrapper focus=table-scroll axe: scrollable-region-focusable (serious)
focusable region focus=table-scroll axe: no violations
--- webkit 27.2
plain wrapper focus=body axe: scrollable-region-focusable (serious)
focusable region focus=table-scroll axe: no violations
Chromium düz wrapper'a da odak verdi: Chrome 130'dan beri, içinde odaklanabilir bir element olmayan kaydırılabilir kutular klavyeyle varsayılan olarak odaklanabiliyor. WebKit vermedi; Tab'a basınca odak sayfada kaldı ve klavye kullanıcısı tablonun sağ tarafına ulaşamaz. axe ise motorlardan bağımsız olarak düz wrapper'ı ciddi bir ihlal saydı. Doğru çözüm üç attribute: tabindex="0" kutuyu her tarayıcıda odaklanabilir yapar, role="region" onu bir landmark yapar, aria-labelledby de bu landmark'ı tablonun caption'ıyla adlandırır. Odaklanabilen her şeyin bir adı olmalı; adsız bir odak durağı ekran okuyucuda yalnızca "region" diye okunur. Odağın görünür olması (:focus-visible stili) da şart; o CSS3 serisinin ve Accessibility (a11y) serisinin konusu.
Kart Görünümü: CSS display ve Tablo Semantiği
Dar ekranda tabloyu kaydırmak yerine her satırı bir karta çevirmek de yaygın. Bunun için tablonun element'lerine display: block verilir:
/* dar ekranda her satırı bir karta çevir */
@media (max-width: 600px) {
table, caption, thead, tbody, tfoot, tr, th, td {
display: block;
}
table {
min-width: 0;
}
thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
}
Bu CSS'i orders.html'e ekleyip aynı axtree.js'i 390 piksellik ekranda çalıştırdım (node axtree.js site/cards.html 390). Chromium'un ürettiği tree, yukarıdaki geniş ekran çıktısıyla satırı satırına aynı: tablo, satırlar, başlıklar ve hücreler yerinde. Chromium bugün CSS display'i değişen tablo element'lerinin semantiğini koruyor.
Ama bu her tarayıcı için geçerli değildi. Erişilebilirlik uzmanı Adrian Roselli'nin 2020 ve 2022'deki testlerinde Safari, display: block verilen tablo hücrelerinin semantiğini düşürüyordu. Safari'nin bugünkü davranışını bu yazı için ölçemedim (Playwright WebKit'in accessibility tree'sine erişim vermiyor). Bu yüzden kart görünümünü seçersen gerçek bir Safari ve VoiceOver ile test et; sorun görürsen ya tablo element'lerinin display'ine dokunmayan bir düzen (ör. kaydırılabilir wrapper) seç ya da semantiği role="table", role="row", role="cell" ile açıkça geri ver. İkincisi ARIA (Accessible Rich Internet Applications) bilgisi isteyen bir yol ve Accessibility (a11y) serisinin konusu.
Kart görünümünün görsel bir bedeli de var: thead gizlendiği için hücre değerleri başlıksız kalıyor. Bunu genellikle td'lere bir data-label ve CSS'te ::before { content: attr(data-label) } ekleyerek çözerler. Ekran okuyucular CSS ile üretilen bu içeriği de okuyabildiği için başlık iki kez duyulabilir. Kart görünümü iyi bir tasarım olabilir; ama bedava değildir.
Angular: tr Yerine Host Element
Tahmin sorusuna dönelim. Angular uygulamasını build edip tarayıcıda açan ve iki tablodaki role'leri sayan script, proje klasörünün yanında:
import { Component, input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { Order } from './order';
@Component({
selector: 'tr[appOrderRow]',
imports: [CurrencyPipe],
template: `
<th scope="row">#{{ order().id }}</th>
<td>{{ order().date }}</td>
<td>{{ order().total | currency: 'USD' }}</td>
`,
})
export class OrderRow {
readonly order = input.required<Order>({ alias: 'appOrderRow' });
}
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(18894);
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:18894/');
await page.waitForSelector('#books-attribute li');
// Chromium'un accessibility tree'sinde her tablo ve listenin içinde hangi role'ler var?
const cdp = await page.context().newCDPSession(page);
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
const byId = new Map(nodes.map(n => [n.nodeId, n]));
const roles = n => (n.childIds ?? []).flatMap(id => {
const c = byId.get(id);
return [...(c.ignored ? [] : [c.role.value]), ...roles(c)];
});
for (const id of process.argv.slice(2)) {
const { node } = await cdp.send('DOM.describeNode', {
objectId: (await cdp.send('Runtime.evaluate', { expression: `document.getElementById('${id}')` })).result.objectId,
});
const ax = nodes.find(n => n.backendDOMNodeId === node.backendNodeId);
const counts = {};
for (const r of roles(ax)) counts[r] = (counts[r] ?? 0) + 1;
const summary = ['row', 'columnheader', 'rowheader', 'cell', 'listitem', 'generic']
.filter(r => counts[r]).map(r => `${r}=${counts[r]}`).join(' ');
console.log(`${id.padEnd(17)} ${ax.role.value.padEnd(6)} ${summary}`);
}
// checker için tarayıcıdaki son DOM'u diske yaz
fs.mkdirSync('rendered', { recursive: true });
fs.writeFileSync('rendered/account.html', await page.content());
await browser.close();
server.close();
})();
node angular-dom.js orders-element orders-attribute
orders-element table row=1 columnheader=3 generic=6
orders-attribute table row=3 columnheader=3 rowheader=2 cell=4
İlk tabloda tek bir row var: başlık satırı. Veri satırları yok; th scope="row"'lar rowheader olmamış, td'ler cell olmamış. Altı hücrenin hepsi generic, yani anlamsız birer kutu. Sebep, DOM'da (Document Object Model) tbody ile hücreler arasında duran app-order-row element'i: tablo modeli tbody'nin içinde yalnız tr bekler, tanımadığı bir element'in içindeki hücreleri satır olarak görmez. Ekranda düzgün görünmesi tarayıcının CSS'te hücreleri yine de bir satır gibi dizmesinden; ekran okuyucu için bu tabloda veri yok.
İkinci tablo aynı component'in attribute selector'lı sürümü: selector: 'tr[appOrderRow]'. Angular artık kendi host element'ini üretmiyor, component'i template'teki tr'nin kendisine bağlıyor. Sonuç üç satır, iki rowheader ve dört cell, yani doğru tablo. Input'a selector ile aynı adı (alias: 'appOrderRow') vermek de kullanımı tek attribute'a indiriyor: <tr [appOrderRow]="order">.
Checker'a tarayıcıdaki son DOM'u verdiğimde ilk tabloda durdu:
"file:/site/account.html":9.239-9.253: error: Start tag “app-order-row” seen in “table”.
"file:/site/account.html":9.239-9.253: error fatal: Cannot recover after last error. Any further errors will be ignored.
Ve SSR sorusunun cevabı: bu DOM'u Angular tarayıcıda DOM API'leriyle (Application Programming Interface) kurduğu için parser hiç devreye girmiyor. Aynı yapı sunucudan HTML string olarak gelseydi, serinin ilk yazısındaki hata affeden parser onu yeniden düzenlerdi:
const { chromium } = require('playwright');
// Angular'ın DOM'a yazdığı yapı, bu kez sunucudan HTML string olarak gelirse
const ssrHtml = `<table><tbody><app-order-row><th scope="row">#1042</th><td>2026-11-02</td></app-order-row></tbody></table>`;
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(ssrHtml);
console.log(await page.evaluate(() => document.body.innerHTML));
await browser.close();
})();
<app-order-row></app-order-row><table><tbody><tr><th scope="row">#1042</th><td>2026-11-02</td></tr></tbody></table>
Parser app-order-row'u tablonun dışına, öncesine taşıdı ve hücreler için kendisi bir tr açtı. Sunucunun gönderdiği DOM ile Angular'ın beklediği DOM artık farklı; hydration bu farkla karşılaşınca hata verir ya da o bölümü baştan render eder. Element selector'lı satır component'i client-side render'da sessizce erişilebilirliği, SSR'da yüksek sesle hydration'ı bozar. Aynı sorun ul içindeki host element'ler için de var; onu serinin bir sonraki yazısında listeler üzerinden göreceğiz.
Üretimde Dikkat
Layout için table kullanma. 2000'lerin başındaki sayfa düzenlerinden kalma bu alışkanlık bugün e-posta template'leri dışında nadir, ama bir CMS (Content Management System) editörünün iki sütunlu bir metin için tablo eklemesi hâlâ olur. Ekran okuyucu böyle bir tabloyu veri tablosu sanıp "2 sütun, 1 satır" diye duyurabilir. Kaçınılmazsa role="presentation" tablonun semantiğini kaldırır; ama asıl çözüm CSS Grid ya da Flexbox.
Sıralanabilen tablolarda, sıralamanın hangi sütunda ve hangi yönde olduğunu orders.html'deki gibi th'ye aria-sort ile yaz ve sıralamayı tetikleyen şeyi th'nin içinde bir button yap; tıklanabilir bir th klavyeyle çalışmaz. aria-sort'un ekran okuyucularda nasıl duyulduğu Accessibility (a11y) serisinde.
Büyük tablolarda sayfalama ya da virtual scroll kullanıyorsan, ekranda olmayan satırlar DOM'da da yoktur ve ekran okuyucu "50 satır" yerine "20 satır" der. aria-rowcount ve aria-rowindex bu durum için var; ama önce gerçekten virtual scroll'a ihtiyaç olup olmadığını ölç. Birkaç yüz satırlık bir sipariş geçmişi düz bir tabloyla rahatça render edilir.
Sık Düşülen Tuzaklar
Birincisi, başlık hücrelerini td ve kalın font ile yazmak. Görsel olarak başlık, semantik olarak sıradan bir hücre; tablo başlıksız kalır.
İkincisi, tablo adını caption yerine tablonun üstündeki bağımsız bir başlığa yazıp bağlamamak.
Üçüncüsü, toplam satırını tbody'nin son satırı olarak ve satır başlığısız yazmak.
Dördüncüsü, kaydırılabilir wrapper'ı tabindex'siz bırakmak. Chromium'da çalışıyor gibi görünür, WebKit'te klavye kullanıcısı tablonun yarısına ulaşamaz.
Beşincisi, Angular'da satır ya da hücre component'ine element selector vermek. Tablo içindeki component'ler tr[appX] ya da td[appX] gibi attribute selector ile yazılır.
Küçük Bir Deneme
★☆☆ orders.html'deki caption'ı sil, yerine tablonun üstüne bir h2 koy ve axtree.js'i çalıştır. Tablonun adına ne oldu? Sonra tabloya aria-labelledby ekleyip tekrar dene.
★★☆ report.html'i headers/id kullanmadan, iki satırlı başlığı tek satıra indirerek ("Q1 2026" gibi) yeniden yaz. axe ve checker'ı iki sürüme de çalıştır ve hangisinin okunmasının ve bakımının kolay olduğunu karşılaştır.
★★★ Kendi Angular projende tbody, tr, ul ya da ol'un doğrudan içinde kullanılan component'leri bul (template'lerde bir grep işini görür). Birini attribute selector'a çevir ve angular-dom.js benzeri bir script ile önce ve sonra accessibility tree'deki role'leri karşılaştır.
Tablo, semantiğin en görünür biçimde bozulduğu yerdi. Bir sonraki yazıda bu dersi bütün sayfaya genelleştiriyoruz: "önce native element" ilkesi, lang ve dir, pozitif tabindex, hidden, aria-hidden ve inert farkı, DevTools'ta accessibility tree'yi okumak, ve Angular host element'leri ile Razor Tag Helper çıktısını kontrol etmek.