HTML'de Global Attribute'lar, Link Türleri ve Inline SVG: data-*, hidden, contenteditable, spellcheck, translate, download, mailto/tel ve Erişilebilir İkon
contenteditable bir form control'ü değildir: plaintext-only zengin metni düz metne çevirir ama not form'la sunucuya gitmez, doğru element textarea. Bu yazıda data-*, hidden="until-found", spellcheck, autocapitalize ve translate'i Chromium ve WebKit'te ölçüyor; tel:, mailto: ve download'ın aynı origin sınırını, erişilebilir SVG ikonları ve <svg hidden> tuzağını Bookstore üzerinde gösteriyoruz.
Sürüm notu: Bu yazı HTML (HyperText Markup Language) Living Standard'ın 14 Kasım 2026 tarihli hâline ve Baseline'ın Kasım 2026 durumuna göre yazıldı; destek verileri web-features 3.46 paketinden alınmış, örnekler Chrome 158, WebKit 27.2 (Playwright 1.65), Nu Html Checker 26.11.14 ve html-validate 11.21 ile denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) kitap detay sayfasından ve yönetim panelinden üç parça:
<button type="button" title="Add to wishlist" id="wishlist-old">
<svg viewBox="0 0 24 24" width="24" height="24">
<title>Heart</title>
<path d="M12 21 3.5 12.5a5 5 0 0 1 8.5-6 5 5 0 0 1 8.5 6z"/>
</svg>
</button>
<p><a href="/orders/1042/invoice.pdf" download="bookstore-order-1042.pdf">Download invoice (PDF)</a></p>
<p><a href="http://127.0.0.1:52114/orders/1042/invoice.pdf" download="bookstore-order-1042.pdf">Invoice from files host</a></p>
<div id="note" contenteditable="plaintext-only" role="textbox" aria-multiline="true"
aria-labelledby="note-label" data-field="note" spellcheck="true"></div>
Üç soru. Ekran okuyucu ilk butonu ne diye okur: "Add to wishlist" mi, "Heart" mi? İki fatura linkinden ikincisi faturayı ayrı bir origin'den (burada aynı makinede başka bir port) alıyor; ikisi de download taşıyor, hangisi hangi adla iner? Son olarak, not alanı bir form'un içinde; "Save"e basınca not sunucuya gider mi?
"HTML'de 2023–2026 Arası Gelenler" yazısında seriyi yeni özelliklerle kapatmıştık. Bu ek yazı, her element'e yazdığımız ama üzerinde durmadığımız parçaları topluyor: global attribute'lar, link türleri ve inline SVG (Scalable Vector Graphics; XML tabanlı vektörel görsel biçimi) ikonların erişilebilir markup'ı.
data-*: Script'e Veri Taşımak
Kitap sayfasının article'ı, sepet script'inin ihtiyaç duyduğu iki değeri taşıyor: ISBN (kitaplara verilen uluslararası standart numara) ve stok adedi.
<article data-isbn="9780547928227" data-stock-count="12">
<h1 translate="no">The Hobbit</h1>
<p>by <span translate="no">J. R. R. Tolkien</span>, ISBN <span class="isbn" translate="no">978-0547928227</span></p>
data- ile başlayan her attribute geçerlidir ve tarayıcı ona hiçbir anlam yüklemez; sayfanın kendi script'i ve CSS'i için ayrılmış bir alandır. JavaScript tarafında dataset ile okunur: önek düşer, tireden sonraki harf büyür, data-stock-count dataset.stockCount olur. Adlarda büyük harf kullanma. Bu yazıdaki global attribute'ları tek seferde ölçen script:
const pw = require('playwright');
const start = require('./server');
(async () => {
const servers = start();
for (const name of ['chromium', 'webkit']) {
const browser = await pw[name].launch();
const page = await browser.newPage();
await page.goto('http://127.0.0.1:52113/book.html');
const book = await page.evaluate(() => {
const article = document.querySelector('article');
// data-stock-count → dataset.stockCount, değer her zaman string
const { isbn, stockCount } = article.dataset;
return `dataset: isbn=${isbn} stockCount=${JSON.stringify(stockCount)}\n ` +
`translate: h1=${document.querySelector('h1').translate} isbn=${document.querySelector('.isbn').translate} aside=${document.querySelector('aside').translate}\n ` +
`draggable: a=${document.querySelector('aside a').draggable} p=${document.querySelector('aside p').draggable}`;
});
await page.goto('http://127.0.0.1:52113/admin-note.html');
const form = await page.evaluate(() =>
['note', 'recipient', 'coupon', 'courier'].map(id => {
const el = document.getElementById(id);
return `${id}: autocapitalize="${el.autocapitalize}" spellcheck=${el.spellcheck}`;
}).join('\n '));
console.log(`${name} ${browser.version()}\n ${book}\n ${form}`);
await browser.close();
}
servers.forEach(s => s.close());
})();
chromium 158.0.8127.18
dataset: isbn=9780547928227 stockCount="12"
translate: h1=false isbn=false aside=true
draggable: a=true p=false
note: autocapitalize="" spellcheck=true
recipient: autocapitalize="words" spellcheck=true
coupon: autocapitalize="characters" spellcheck=false
courier: autocapitalize="none" spellcheck=false
webkit 27.2
dataset: isbn=9780547928227 stockCount="12"
translate: h1=false isbn=false aside=true
draggable: a=true p=false
note: autocapitalize="undefined" spellcheck=true
recipient: autocapitalize="undefined" spellcheck=true
coupon: autocapitalize="undefined" spellcheck=false
courier: autocapitalize="undefined" spellcheck=false
İlk satırda stockCount tırnak içinde geliyor: değer her zaman string. "12" > "9" karşılaştırması false döner, sayıya çevirmek senin işin. dataset'in ayrıntısı JavaScript Orta serisinin "JavaScript ile Gelişmiş DOM" yazısında.
data-* yalnız script'in kullandığı veri içindir; kullanıcının bilmesi gereken şey metinde olmalı, ekran okuyucular data-*'ı okumaz. Bir role ya da durum gerekiyorsa onun için native element ya da ARIA (erişilebilirlik için HTML'e eklenen rol ve durum attribute'ları) var.
Üçüncü satır draggable: link ve görseller varsayılan olarak sürüklenebilir, paragraf değil. draggable="true" sürüklemeyi açar ama bırakma mantığını ve klavye karşılığını sen yazarsın; sürükle-bırak ile çalışan her arayüzün butonla çalışan ikinci bir yolu olmalı.
hidden="until-found": Aramada Açılan İçerik
"Native Interaktif Element'ler 1" yazısında hidden="until-found"'ı bir id'ye gelen fragment ile açmıştık. Asıl amacı sayfa içi arama: içerik gizli durur ama Ctrl+F ile bulunur. Living Standard'a (14 Kasım 2026) göre arama ya da fragment böyle bir içeriği hedeflediğinde tarayıcı önce beforematch event'ini atar, sonra attribute'u kaldırır; eşleşme kapalı bir details'in içindeyse onu da açar. MDN'in (MDN Web Docs, Mozilla'nın web dokümantasyonu) hidden sayfasına (10 Kasım 2026) göre bu content-visibility: hidden ile yapılıyor, display'i inline ya da none olan element açılmıyor. İçindekiler listesi ve details üzerinde:
const pw = require('playwright');
const start = require('./server');
const base = 'http://127.0.0.1:52113/book.html';
(async () => {
const servers = start();
for (const name of ['chromium', 'webkit']) {
const browser = await pw[name].launch();
const page = await browser.newPage();
// her sayfa yüklemesinde beforematch event'lerini kaydet
await page.addInitScript(() => {
window.matches = [];
document.addEventListener('beforematch', e => matches.push(e.target.id || e.target.tagName), true);
});
const state = () => page.evaluate(() =>
`toc hidden=${JSON.stringify(document.getElementById('toc').getAttribute('hidden'))} ` +
`details open=${document.querySelector('details').open} beforematch=[${matches}]`);
await page.goto(base);
console.log(`${name} load: ${await state()}`);
await page.evaluate(() => window.find('Riddles in the Dark'));
console.log(`${name} window.find: ${await state()}`);
await page.goto('about:blank');
await page.goto(base + '#:~:text=Riddles%20in%20the%20Dark');
console.log(`${name} text fragment: ${await state()}`);
await page.goto('about:blank');
await page.goto(base + '#:~:text=original%20maps');
console.log(`${name} details text: ${await state()}`);
await browser.close();
}
servers.forEach(s => s.close());
})();
chromium load: toc hidden="until-found" details open=false beforematch=[]
chromium window.find: toc hidden="until-found" details open=false beforematch=[]
chromium text fragment: toc hidden=null details open=false beforematch=[toc]
chromium details text: toc hidden="until-found" details open=true beforematch=[]
webkit load: toc hidden="until-found" details open=false beforematch=[]
webkit window.find: toc hidden="until-found" details open=false beforematch=[]
webkit text fragment: toc hidden=null details open=false beforematch=[toc]
webkit details text: toc hidden="until-found" details open=true beforematch=[]
Gerçek Ctrl+F script'ten tetiklenemiyor; ikinci satır window.find()'ın onun yerine geçmediğini gösteriyor: standart dışı bu method metni arıyor ama gizli içeriği açmıyor. Üçüncü satırdaki text fragment (#:~:text=, arama sonuçlarındaki "metne git" link'lerinin biçimi) ise listeyi açtı ve beforematch atıldı. Son satırda eşleşme details'in içindeydi: details açıldı ama event atılmadı, çünkü event yalnız until-found için. Chromium'da text fragment aynı sayfada yalnız fragment değişince işlenmedi; script'teki about:blank adımları bu yüzden var.
WebKit satırları da her şeyi açıyor, ama "HTML'de 2023–2026 Arası Gelenler" yazısındaki uyarı burada da geçerli: Playwright'ın WebKit build'i geliştirme dalını izliyor. web-features'a göre hidden="until-found" Chrome 102 ve Firefox 148'de var, Safari'nin yayınlanmış sürümlerinde yok; Baseline değil. Safari'de içerik düz hidden gibi kapalı kalır; içeriğe başka bir yoldan da ulaşılabilen yerlerde kullan. Düz hidden'ın aria-hidden ve inert'ten farkı "Accessible Markup Temelleri" yazısında.
contenteditable ve plaintext-only: Not Alanı Form'a Girmez
Yönetim panelinde siparişe kısa bir not eklenen form:
<form method="post" action="/admin/orders/1042/notes" autocapitalize="words">
<p id="note-label">Internal note</p>
<div id="note" contenteditable="plaintext-only" role="textbox" aria-multiline="true"
aria-labelledby="note-label" data-field="note" spellcheck="true"></div>
<label for="recipient">Recipient name</label>
<input type="text" id="recipient" name="recipient" autocomplete="off">
<label for="coupon">Coupon code</label>
<input type="text" id="coupon" name="coupon" autocapitalize="characters" spellcheck="false" autocomplete="off">
<label for="courier">Courier reference</label>
<textarea id="courier" name="courier" spellcheck="false" autocapitalize="none"></textarea>
<button type="submit">Save</button>
</form>
contenteditable="plaintext-only" Mart 2025'te Firefox 136 ile Baseline newly available oldu; Chrome ve Safari yıllardır destekliyordu. true'dan farkı, yalnız düz metnin düzenlenebilmesi. Aynı yapıştırma ve Ctrl+B ile iki değeri, ardından form gönderimini deneyen script:
const { chromium } = require('playwright');
const start = require('./server');
(async () => {
const servers = start();
const browser = await chromium.launch();
const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
const page = await context.newPage();
await page.goto('http://127.0.0.1:52113/admin-note.html');
// aynı yapıştırma ve Ctrl+B, iki contenteditable değeriyle
for (const mode of ['plaintext-only', 'true']) {
await page.evaluate(async m => {
const note = document.getElementById('note');
note.contentEditable = m;
note.textContent = '';
await navigator.clipboard.write([new ClipboardItem({
'text/html': new Blob(['<b>Call</b> before <i>17:00</i>'], { type: 'text/html' }),
'text/plain': new Blob(['Call before 17:00'], { type: 'text/plain' }),
})]);
}, mode);
await page.locator('#note').click();
await page.keyboard.press('ControlOrMeta+v');
await page.keyboard.press('ControlOrMeta+b');
await page.keyboard.type(' urgent');
console.log(`${mode.padEnd(14)} innerHTML: ${await page.locator('#note').innerHTML()}`);
}
// form gönderiminde hangi alanlar sunucuya gidiyor?
await page.fill('#recipient', 'Ada Lovelace');
await page.fill('#coupon', 'HOBBIT10');
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState();
// düzeltilmiş sayfa: not bir textarea
await page.goto('http://127.0.0.1:52113/admin-note-fixed.html');
await page.fill('#note', 'Call before 17:00');
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForLoadState();
await browser.close();
servers.forEach(s => s.close());
})();
plaintext-only innerHTML: Call before 17:00 urgent
true innerHTML: <b>Call</b> before <i>17:00<b> urgent</b></i>
server: POST /admin/orders/1042/notes recipient=Ada+Lovelace&coupon=HOBBIT10&courier=
server: POST /admin/orders/1042/notes note=Call+before+17%3A00&recipient=&coupon=&courier=
Panodaki zengin metin plaintext-only'de düz metin olarak yapıştı ve Ctrl+B bir şey yapmadı; true'da etiketler de geldi ve Ctrl+B yeni kelimeyi b'ye sardı. Kullanıcıdan HTML almak istemiyorsan doğru değer bu.
Üçüncü satır tahmin sorusunun cevabı: sunucuya giden body'de not yok. contenteditable bir element form control'ü değildir; name'i yoktur, form'la gönderilmez, required ya da maxlength ile doğrulanmaz, reset'te temizlenmez. Doğru element textarea:
<label for="note">Internal note</label>
<textarea id="note" name="note" maxlength="500" autocapitalize="sentences"></textarea>
Script'in son satırında not artık body'de. İçerik kadar büyüyen bir textarea için field-sizing: content "HTML Form'ları 2" yazısında. html-validate da aynı yeri gösteriyor, ama ilk satırına dikkat:
site/admin-note.html
12:45 error Attribute "contenteditable" has invalid value "plaintext-only" attribute-allowed-values
12:61 error Prefer to use the native <textarea> element prefer-native-element
11.21 sürümünün metadata'sı plaintext-only'yi henüz tanımıyor; Nu Html Checker kabul etti. İkinci kural ise doğru öneri. contenteditable'ın gerçekten gerektiği yer zengin metin editörleri; onları da sıfırdan yazmak yerine olgun bir library kullan.
spellcheck, autocapitalize ve translate: Tarayıcıya İpucu
Bu üç attribute yalnız ipucu verir; attributes.js çıktısının geri kalanı onları gösteriyor. spellcheck="false"'u kupon kodu ve kargo referansı gibi, yazım denetiminin kırmızı çizgisinin anlamsız olduğu alanlara koydum. MDN'in spellcheck sayfası (3 Temmuz 2026) bir de güvenlik notu düşüyor: bazı tarayıcıların gelişmiş yazım denetimi alanın içeriğini üçüncü bir tarafa gönderebiliyor, bu yüzden hassas bilgi taşıyan alanlarda kapat. Değer üst element'ten miras alınır.
autocapitalize mobil klavyenin ve sesle yazmanın büyük harf davranışını belirler: none, sentences, words, characters. Fiziksel klavyeyi etkilemez; email, url ve password tipindeki input'larda yok sayılır. Kupon kodu için characters yazdım. Form'daki words, kendi değeri olmayan alıcı adına geçti; kendi değeri olanlar form'unkini ezdi. Living Standard sırayı böyle tanımlıyor; MDN'in sayfası (17 Nisan 2026) form'un değerinin öncelikli olduğunu yazıyor, Chromium'un davranışı spec'le aynı. contenteditable'lı not form'dan bir şey almadı (""), çünkü form control'ü değil. WebKit'teki undefined, Playwright'ın macOS WebKit'inin masaüstü Safari gibi bu property'yi tanımamasından: web-features'a göre autocapitalize Chrome, Firefox ve iOS Safari'de var, masaüstü Safari'de yok; Baseline değil ama zararsız bir ipucu. Aynı aileden autocorrect "HTML Form'ları 1" yazısındaydı.
translate="no", tarayıcının ya da bir çeviri aracının sayfayı çevirirken dokunmayacağı yerleri işaretler. Sayfayı otomatik çeviriyle okuyan kullanıcı kitap adını çevrilmiş görmesin diye başlığa, yazara ve ISBN'e translate="no" verdim. Değer miras alınır: çıktıda h1 ve ISBN false, aside true. Spec title, alt ve download gibi attribute'ları da çevrilebilir sayıyor; translate="no" onları da korur. translate Mart 2023'ten beri Baseline, Eylül 2025'ten beri widely available.
Bu bölümde resmî belgelerden yararlanılmıştır.
Link Türleri: tel:, mailto:, sms: ve Fragment
Kitap sayfasının yan kolonu müşteri hizmetlerine üç yol ve iki fatura linki veriyor:
<aside aria-labelledby="help-title">
<h2 id="help-title">Need help with an order?</h2>
<p>
<a href="tel:+12025550143">
<svg aria-hidden="true" width="16" height="16"><use href="#icon-phone"/></svg>
+1 202 555 0143
</a>
</p>
<p><a href="mailto:support@example.com?subject=Order%201042">support@example.com</a></p>
<p><a href="sms:+12025550143?body=Order%201042">Text us</a></p>
<p><a href="/orders/1042/invoice.pdf" download="bookstore-order-1042.pdf">Download invoice (PDF)</a></p>
<p><a href="http://127.0.0.1:52114/orders/1042/invoice.pdf" download="bookstore-order-1042.pdf">Invoice from files host</a></p>
<p><a href="http://127.0.0.1:52114/orders/1042/invoice.pdf?attachment">Invoice from files host (attachment)</a></p>
</aside>
tel: telefonda arama ekranını, masaüstünde bu işe atanmış uygulamayı (varsa) açar. href'teki numarayı uluslararası biçimde, + ile ve boşluksuz yaz; ekranda görünen metin istediğin biçimde olabilir. Örnekteki numara, kurgusal kullanım için ayrılmış 555-0100–555-0199 aralığından. Görünen numaradaki 'ler html-validate'in tel-non-breaking önerisi: numara satır sonunda bölünmesin.
mailto:, ?subject= ve &body= ile konuyu ve gövdeyi önceden doldurabilir; değerler URL-encoded olmalı (%20). E-posta istemcisi tanımlı olmayan masaüstünde link bir şey yapmıyor gibi görünür; adresi link metni olarak göstermek en azından kopyalamayı mümkün kılar. sms:'in gövde parametresi platformlar arasında farklı ve masaüstünde çoğu zaman karşılığı yok.
#fragment aynı sayfadaki bir id'ye gider; yukarıdaki text fragment onun metin üzerinden çalışan biçimi. "Metin Semantiği" yazısındaki kural burada da geçerli: link metni, bağlamından koparılsa da nereye gittiğini söylemeli. "Text us" ve e-posta adresi bunu yapıyor, "Click here" yapmaz.
Bu bölümde resmî belgelerden yararlanılmıştır.
download, rel ve ping
Faturalar için iki origin açan küçük bir sunucu: mağaza 52113, fatura sunucusu 52114 portunda. İkincisi ?attachment ile istenirse Content-Disposition: attachment header'ı da gönderiyor:
const http = require('http');
const fs = require('fs');
const path = require('path');
const types = { '.html': 'text/html; charset=utf-8', '.pdf': 'application/pdf' };
// iki ayrı origin: mağaza sayfaları ve fatura dosyaları
function serve(root, port, extraHeaders = () => ({})) {
return http.createServer((req, res) => {
if (req.method === 'POST') {
let body = '';
req.on('data', c => (body += c));
req.on('end', () => {
console.log(`server: POST ${req.url} ${body}`);
res.end('saved');
});
return;
}
const url = new URL(req.url, `http://${req.headers.host}`);
const file = path.join(root, url.pathname === '/' ? 'book.html' : url.pathname);
if (!file.startsWith(root) || !fs.existsSync(file)) {
res.statusCode = 404;
return res.end('not found');
}
res.writeHead(200, { 'Content-Type': types[path.extname(file)] ?? 'application/octet-stream', ...extraHeaders(url) });
res.end(fs.readFileSync(file));
}).listen(port, '127.0.0.1');
}
module.exports = () => [
serve(path.join(__dirname, 'site'), 52113),
serve(path.join(__dirname, 'files'), 52114, url =>
url.searchParams.has('attachment') ? { 'Content-Disposition': 'attachment; filename="order-1042-invoice.pdf"' } : {}),
];
Üç fatura linkine sırayla tıklayan script:
const { chromium } = require('playwright');
const start = require('./server');
(async () => {
const servers = start();
// headless shell'de PDF görüntüleyici yok; PDF'e gitmek de indirmeye dönüşür
const browser = await chromium.launch({ channel: 'chromium' });
console.log(`chromium ${browser.version()}`);
const page = await browser.newPage({ acceptDownloads: true });
for (const name of ['Download invoice (PDF)', 'Invoice from files host', 'Invoice from files host (attachment)']) {
await page.goto('http://127.0.0.1:52113/book.html');
const download = page.waitForEvent('download', { timeout: 3000 }).catch(() => null);
await page.getByRole('link', { name, exact: true }).click();
const file = await download;
console.log(`${name.padEnd(38)} -> ${file ? `download "${file.suggestedFilename()}"` : `navigation ${page.url()}`}`);
}
await browser.close();
servers.forEach(s => s.close());
})();
chromium 158.0.8127.18
Download invoice (PDF) -> download "bookstore-order-1042.pdf"
Invoice from files host -> navigation http://127.0.0.1:52114/orders/1042/invoice.pdf
Invoice from files host (attachment) -> download "order-1042-invoice.pdf"
Tahmin sorusunun ikinci cevabı bu. Aynı origin'deki link download'daki adla indi. Başka origin'deki link aynı attribute'u taşımasına rağmen bir navigation oldu ve tarayıcı PDF'i açtı: download yalnız aynı origin'deki, blob: ve data: URL'lerde dikkate alınıyor. Başka origin'den indirtmenin yolu sunucu: üçüncü satırda Content-Disposition: attachment dosyayı indirtti ve ad header'daki filename'den geldi. MDN'in a sayfası (28 Ekim 2026) header'daki adın attribute'takini ezdiğini de belirtiyor. Faturalar bir CDN'den (içeriği kullanıcıya yakın sunuculardan dağıtan ağ) geliyorsa indirmeyi orada, header ile kur. Link metnindeki "(PDF)" de MDN'in önerisi: kullanıcı bir dosya ineceğini önceden bilmeli.
Playwright'ın varsayılan headless shell'inde PDF görüntüleyici yok ve ikinci link orada invoice.pdf adıyla iniyordu; bu yüzden tam Chromium build'ini (channel: 'chromium') kullandım.
noopener ve noreferrer "Metin Semantiği" yazısındaydı. Diğer rel değerleri ipucudur: nofollow linklenen sayfaya kefil olunmadığını, ugc linkin kullanıcı içeriğinden (kitap yorumları gibi) geldiğini, sponsored ücretli olduğunu, external başka bir siteye gittiğini söyler. SEO (arama motoru optimizasyonu) etkileri Teknik SEO serisinde.
ping, link takip edildiğinde verilen URL'lere arka planda gövdesi PING olan bir POST gönderir; spec'teki adı "hyperlink auditing", yani tıklama takibi. Firefox'ta yok, Baseline değil, ve spec tarayıcının bu isteği tamamen atlamasına izin veriyor. Tıklama ölçümünü analitik aracına ya da sunucuya bırak. Bu bölümde resmî belgelerden yararlanılmıştır.
Inline SVG ve Erişilebilir İkon
Kitap sayfasındaki ikonlar tek bir sprite'tan geliyor: her ikon bir kez symbol olarak tanımlanıyor, kullanılacağı yerde use ile çağrılıyor:
<svg aria-hidden="true" style="display: none">
<symbol id="icon-heart" viewBox="0 0 24 24">
<path d="M12 21 3.5 12.5a5 5 0 0 1 8.5-6 5 5 0 0 1 8.5 6z"/>
</symbol>
<symbol id="icon-phone" viewBox="0 0 24 24">
<path d="M6 2h4l2 5-3 2a11 11 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A18 18 0 0 1 4 4a2 2 0 0 1 2-2z"/>
</symbol>
<symbol id="icon-check" viewBox="0 0 24 24">
<path d="M4 12l5 5L20 6" fill="none" stroke="currentColor" stroke-width="3"/>
</symbol>
</svg>
<p>
Availability:
<svg role="img" aria-label="In stock" width="16" height="16"><use href="#icon-check"/></svg>
</p>
<button type="button" aria-label="Add to wishlist" id="wishlist">
<svg aria-hidden="true" width="24" height="24"><use href="#icon-heart"/></svg>
</button>
İlk sürümde sprite'ı saklamak için <svg hidden> yazmıştım. Aşağıdaki script'in o sürümdeki ilk iki satırı:
sprite box: 300x150 display=inline
image ""
Sprite görünmüyordu ama sayfanın tepesinde 300×150 piksellik boş bir alan kaplıyordu ve Chromium onu adsız bir görsel olarak tree'ye koymuştu; WebKit'te de kutu aynıydı. Sebep, hidden'ın bir HTML attribute'u olması: tarayıcının onu gizleyen varsayılan stili yalnız HTML element'lerine uygulanıyor, svg ise SVG namespace'inde. aria-hidden="true" ve display: none ile düzeltilmiş sayfada:
const pw = require('playwright');
const start = require('./server');
(async () => {
const servers = start();
const browser = await pw.chromium.launch();
const page = await browser.newPage();
await page.goto('http://127.0.0.1:52113/book.html');
// sprite'ı taşıyan svg ekranda yer kaplıyor mu?
console.log('sprite box:', await page.locator('body > svg').evaluate(s => {
const { width, height } = s.getBoundingClientRect();
return `${width}x${height} display=${getComputedStyle(s).display}`;
}));
// Chromium'un kendi accessibility tree'si: role, name ve description
const cdp = await page.context().newCDPSession(page);
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
for (const n of nodes) {
const role = n.role?.value;
if (!['button', 'image', 'link'].includes(role) || n.ignored) continue;
const desc = n.description?.value ? ` description="${n.description.value}"` : '';
console.log(`${role} "${n.name?.value ?? ''}"${desc}`);
}
// Tab sırası: svg'lerden biri odak alıyor mu?
const order = [];
for (let i = 0; i < 8; i++) {
await page.keyboard.press('Tab');
order.push(await page.evaluate(() => {
const el = document.activeElement;
return el.id ? `${el.tagName.toLowerCase()}#${el.id}` : el.tagName.toLowerCase();
}));
}
console.log('tab order:', order.join(' > '));
await browser.close();
servers.forEach(s => s.close());
})();
sprite box: 0x0 display=none
button "Heart" description="Add to wishlist"
button "Add to wishlist"
image "In stock"
image "Heart"
link " +1 202 555 0143"
link "support@example.com"
link "Text us"
link "Download invoice (PDF)"
link "Invoice from files host"
link "Invoice from files host (attachment)"
tab order: button#wishlist-old > button#wishlist > summary > a > a > a > a > a
Telefon ikonu dekoratif: anlamı numara taşıyor, aria-hidden="true" ile tree'den çıktı. Stok durumundaki onay işareti ise yanında metin olmadan bilgi veriyor; role="img" ve aria-label ile image "In stock" oldu. İkon-only butonun kuralı ise şu: ad ikonda değil, button'da. aria-label button'da, ikon gizli; sonuç button "Add to wishlist".
Tahmin sorusundaki eski butonun sonucu ise button "Heart". Button'un adı içeriğinden hesaplanıyor ve içerik, SVG'nin title'ı olan "Heart". title attribute'u yalnız ad olacak başka hiçbir şey yoksa ada dönüşür; burada description'a düştü. Ekran okuyucu kullanıcısı "Heart" adlı bir button duyar. Üstelik title'ın tooltip'i yalnız fareyle üzerine gelince görünür; MDN'in title sayfası (1 Ekim 2026) dokunmatik ekran, klavye ve büyüteç kullanıcıları için sorunlu olduğunu yazıyor. Önemli bir bilgiyi yalnız title'a koyma.
Son satır Tab sırası: iki button, summary ve beş link; hiçbir svg odak almadı. Eski kodlarda görülen focusable="false" Internet Explorer içindi, bugün gerekmiyor. İkonların rengi ve boyutu (fill, currentColor) CSS Property'leri: Uygulamalı Rehber serisinin "Görselleri ve SVG İkonları Stillemek" yazısında, img ile eklenen bir SVG'nin alt'ı "Image ve Media" yazısında.
Üretimde Dikkat
Bu yazıdaki attribute'ların Kasım 2026 durumu, web-features 3.46'dan:
| Attribute | Baseline | Not |
|---|---|---|
data-*, title, draggable | widely (2015) | Her yerde |
spellcheck | widely (2017-11-28) | Yalnız ipucu |
download | widely (2019-09-19) | Yalnız aynı origin, blob:, data: |
translate | widely (2023-03-14) | Firefox 111 ile tamamlandı |
contenteditable="plaintext-only" | newly (2025-03-04) | Firefox 136 ile |
autocorrect | newly (2026-09-11) | "HTML Form'ları 1" |
hidden="until-found" | limited | Safari'de yok |
autocapitalize | limited | Masaüstü Safari'de yok |
ping | limited | Firefox'ta yok |
Nu Html Checker bu yazıdaki sayfaları hatasız geçirdi; adı "Heart" olan butonu da form'un dışında kalan notu da yakalamaz, ikisi de spec açısından geçerli. Bunlar için accessibility tree'yi okuyan bir test (icons.js gibi) ya da axe gerekiyor; CI'a alma yolu "Accessible Markup Temelleri" yazısında.
Bu attribute'ları component'lere göm: Angular'da kitap adını basan component translate="no"'yu kendisi yazsın, aria-label'ı zorunlu input olarak alan tek bir icon button component'i adsız butonları baştan engellesin.
Sık Düşülen Tuzaklar
Birincisi, contenteditable'ı input yerine kullanmak: form'la gitmez, doğrulanmaz.
İkincisi, ikon-only butonun adını SVG'nin title'ına bırakmak; ad button'da olmalı.
Üçüncüsü, önemli bilgiyi title'a koymak; dokunmatik ekranda ve klavyeyle görünmez.
Dördüncüsü, sprite'ı <svg hidden> ile saklamak; kutu yerinde kalır.
Beşincisi, başka origin'deki dosyada download'a güvenmek; indirmeyi header ile kur.
Altıncısı, hidden="until-found"'ı tek erişim yolu yapmak; Safari'de içerik kapalı kalır.
Küçük Bir Deneme
★☆☆ Eski wishlist butonundan önce title attribute'unu, sonra SVG'deki title'ı kaldır ve her adımda icons.js'i çalıştır. Ad ve description nasıl değişiyor?
★★☆ Bookstore'un SSS (Sıkça Sorulan Sorular) cevaplarını hidden="until-found" ile gizle, beforematch'te açılan sorunun başlığına bir class ekle ve Safari için bir "Show all answers" butonu yaz.
★★★ Projendeki ikon-only button'ların accessibility tree'deki adlarını icons.js benzeri bir script ile listele. Adı boş olan ya da ikonun adını taşıyan ("Heart") her button'u tek bir icon button component'ine taşı.
Seri bu ek yazıyla bitiyor. On üç yazıda doğru element'i doğru anlam için seçmeyi ve onları tamamlayan attribute'ları ölçerek gördük. Buradan devam etmek için iki doğal yol var: ARIA, focus yönetimi ve ekran okuyucu testlerinin derinliği için ilerideki Accessibility (a11y) serisi; bu element'lerin ve ikonların görünümü için ilerideki CSS3: Flexbox, Grid ve Modern CSS serisi.