Native Interaktif Element'ler 1: details/summary, dialog, popover ve Invoker Commands
Modal, popover ve accordion artık HTML'de var: Invoker Commands (command/commandfor, Baseline 2025-12) ile dialog ve popover tek satır JavaScript yazmadan açılıp kapanıyor, odak ve inert'i tarayıcı yönetiyor. Bu yazıda Bookstore'un "Kitabı sil" onayını, mini sepetini ve SSS accordion'unu Chromium'da ölçüyor; returnValue tuzağını, requestClose()'u, :open'ı ve Safari'de olmayan closedby, popover="hint" ve hidden="until-found"'ı progressive enhancement olarak ele 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 (Playwright 1.65) ve Nu Html Checker 26.11.9 ile denenmiştir, Safari ve Firefox desteği web-features verisinden alınmıştır.
Önce Tahmin Et
Bookstore'un admin panelindeki kitap sayfasında bir "Delete book" butonu, bir onay penceresi, bir mini sepet, bir SSS (Sıkça Sorulan Sorular) bölümü ve kapalı duran bir içindekiler listesi var. Sayfada tek satır JavaScript yok:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Clean Architecture | Bookstore</title>
</head>
<body>
<header>
<button type="button" command="toggle-popover" commandfor="mini-cart">Show cart</button>
<div id="mini-cart" popover>
<h2>Your cart</h2>
<p>2 books, 84.90 USD</p>
<a href="/cart">Go to cart</a>
</div>
</header>
<main>
<h1>Clean Architecture</h1>
<button type="button" command="show-modal" commandfor="delete-dialog">Delete book</button>
<dialog id="delete-dialog" closedby="any" aria-labelledby="delete-title">
<form method="dialog">
<h2 id="delete-title">Delete this book?</h2>
<p>Clean Architecture will be removed from the catalog.</p>
<button value="cancel" autofocus>Cancel</button>
<button value="delete">Delete</button>
</form>
</dialog>
<section aria-labelledby="faq-title">
<h2 id="faq-title">Frequently asked questions</h2>
<details name="faq">
<summary>When will my order ship?</summary>
<p>Orders placed before 14:00 ship the same day.</p>
</details>
<details name="faq">
<summary>Can I return an e-book?</summary>
<p>E-books can be returned within 14 days if not downloaded.</p>
</details>
</section>
<section aria-labelledby="toc-title">
<h2 id="toc-title">Table of contents</h2>
<div id="toc-full" hidden="until-found">
<p id="chapter-22">Chapter 22: The Clean Architecture</p>
</div>
</section>
</main>
</body>
</html>
"Delete book"a basınca onay penceresi açılır mı? Açılırsa arkadaki sayfaya tıklanabilir mi, Esc ne yapar, pencere kapandığında klavye odağı nereye gider? Bir de: SSS'de ilk soruyu açıp sonra ikinciye tıklarsan ilki açık kalır mı?
Formlar bitti; bu yazıda tarayıcının JavaScript'siz verdiği etkileşimli element'lere geçiyoruz. Yıllarca her projede bir modal, bir dropdown ve bir accordion component'i yazdık ya da bir kütüphaneden aldık. Bugün bunların büyük kısmı HTML'de var ve tarayıcı odak, klavye, katman sırası ve accessibility tree tarafını bizim yerimize yönetiyor. CSS (Cascading Style Sheets) ile görsel stil, animasyon ve anchor positioning ilerideki CSS3: Flexbox, Grid ve Modern CSS serisinde; focus yönetiminin derinliği Accessibility (a11y) serisinde.
details/summary ve Exclusive Accordion
details en eski ve en basit olanı: summary her zaman görünür ve tıklanabilir, geri kalan içerik açılıp kapanır. Klavyeyle Enter ve Space çalışır, ekran okuyucu açık/kapalı durumunu duyurur. Yeni olan, name attribute'u: aynı name'i taşıyan details'ler bir grup oluşturur ve aynı anda yalnız biri açık kalabilir. Bookstore'un SSS'si için tam aradığımız şey bu; Eylül 2024'ten beri Baseline.
Sayfayı Chromium'da gezen bir Playwright script'i yazdım; tahmin sorusundaki her adımı sırayla yapıp durumu yazdırıyor:
const { chromium } = require('playwright');
const http = require('http');
const fs = require('fs');
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(fs.readFileSync('book.html'));
}).listen(18880);
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:18880/books/42');
const state = () => page.evaluate(() => {
const d = document.getElementById('delete-dialog');
const f = document.activeElement;
return `open=${d.open} modal=${d.matches(':modal')} returnValue="${d.returnValue}" ` +
`focus=${f.tagName.toLowerCase()} "${f.textContent.trim()}"`;
});
// Invoker Commands: JavaScript'siz modal dialog
await page.getByRole('button', { name: 'Delete book' }).click();
console.log('after Delete book ->', await state());
await page.keyboard.press('Escape');
console.log('after Escape ->', await state());
await page.getByRole('button', { name: 'Delete book' }).click();
await page.getByRole('button', { name: 'Delete', exact: true }).click();
console.log('after Delete ->', await state());
// closedby="any": backdrop'a tıklamak dialog'u kapatır mı?
await page.getByRole('button', { name: 'Delete book' }).click();
console.log('reopened ->', await state());
await page.mouse.click(5, 5);
console.log('after backdrop ->', await state());
// popover: light dismiss
await page.getByRole('button', { name: 'Show cart' }).click();
console.log('cart open:', await page.locator('#mini-cart').evaluate(p => p.matches(':popover-open')));
await page.mouse.click(5, 400);
console.log('cart open after outside click:',
await page.locator('#mini-cart').evaluate(p => p.matches(':popover-open')));
// details name: exclusive accordion
await page.locator('summary').nth(0).click();
await page.locator('summary').nth(1).click();
console.log('details open:', await page.locator('details').evaluateAll(ds => ds.map(d => d.open)));
// hidden="until-found": fragment ile gelinince açılıyor mu?
await page.goto('http://localhost:18880/books/42#chapter-22');
console.log('toc hidden attr:', await page.locator('#toc-full').evaluate(e => e.getAttribute('hidden')),
'| visible:', await page.locator('#chapter-22').isVisible());
await browser.close();
server.close();
})();
after Delete book -> open=true modal=true returnValue="" focus=button "Cancel"
after Escape -> open=false modal=false returnValue="" focus=button "Delete book"
after Delete -> open=false modal=false returnValue="delete" focus=button "Delete book"
reopened -> open=true modal=true returnValue="delete" focus=button "Cancel"
after backdrop -> open=false modal=false returnValue="delete" focus=button "Delete book"
cart open: true
cart open after outside click: false
details open: [ false, true ]
toc hidden attr: null | visible: true
Sondan ikinci satır accordion'u gösteriyor: önce ilk soru, sonra ikinci soru tıklandı ve sonuçta yalnız ikincisi açık. Tahmin sorusunun son cevabı bu; JavaScript'siz ve iki satır attribute ile.
Son satır daha yeni bir özelliği gösteriyor: hidden="until-found". Normal hidden içeriği tamamen gizler ve tarayıcının sayfa içi araması (Ctrl+F) onu bulamaz. until-found ise içeriği gizler ama aranabilir bırakır: kullanıcı aradığında ya da bir link onun içindeki bir id'ye geldiğinde tarayıcı attribute'u kaldırır ve içeriği gösterir. Script sayfaya #chapter-22 ile geldi ve hidden attribute'u kalktı. Bookstore'un uzun içindekiler listesi için ideal: varsayılan kapalı, ama "Chapter 22" diye aratan bulabilir. Destek durumu ise karışık: Chrome 102'den, Firefox 148'den beri var, Safari'de yok. Safari'de içerik normal hidden gibi kapalı kalır; yani bu özellik bir progressive enhancement olarak, gizli içeriğe başka bir yoldan (ör. "Show all" butonu) da ulaşılabiliyorsa kullanılmalı.
dialog: show(), showModal(), Top Layer ve inert
dialog iki modda açılır. show() non-modal bir pencere açar: sayfanın geri kalanı kullanılabilir kalır, bir sohbet kutusu ya da bir bildirim paneli gibi. showModal() ise modal açar ve beraberinde üç şey getirir. Birincisi top layer: dialog sayfadaki bütün z-index'lerin üstünde, ayrı bir katmanda çizilir; hiçbir overflow: hidden ya da stacking context onu kesemez. İkincisi inert: sayfanın geri kalanı tıklanamaz ve odaklanamaz hâle gelir, ekran okuyucu için de gizlenir. Üçüncüsü ::backdrop pseudo-element'i ve Esc ile kapanma.
İlk iki satır tahmin sorusunu cevaplıyor. "Delete book"a basınca dialog modal olarak açıldı (modal=true) ve odak doğrudan autofocus verdiğim "Cancel" butonuna gitti. Esc dialog'u kapattı ve odak, dialog'u açan "Delete book" butonuna geri döndü. Klavye kullanıcısının sayfada kaybolmaması için gereken davranışın tamamı bu, ve bir satır kod yazmadım. Inert'in kanıtı da script'i yazarken geldi: dialog açıkken arkadaki bir butona tıklamaya çalıştığımda Playwright tıklamayı yapamadı ve "dialog intercepts pointer events" diye bekledi.
Odak için bir öneri: yıkıcı bir işlemi onaylayan pencerede autofocus'u "Cancel"a ver, "Delete"e değil. Enter'a refleksle basan bir kullanıcı kitabı silmemeli.
form method="dialog", returnValue ve requestClose()
Dialog'un içindeki form'da method="dialog" var. Böyle bir form gönderildiğinde sunucuya istek gitmez; dialog kapanır ve gönderen butonun value'su dialog'un returnValue'suna yazılır. Üçüncü satır bunu gösteriyor: "Delete"e basınca dialog kapandı ve returnValue="delete" oldu. JavaScript tarafında close event'inde bu değere bakıp ne yapılacağına karar verirsin.
Ama dördüncü ve beşinci satırlara dikkat: dialog tekrar açıldığında returnValue hâlâ "delete", ve backdrop'a tıklanarak kapatıldığında da öyle kalıyor. showModal() bu değeri sıfırlamıyor; Esc ya da backdrop ile kapanış da yeni bir değer yazmıyor. close event'inde yalnız returnValue === 'delete' kontrolü yapan bir kod, kullanıcı ikinci açılışta vazgeçip dışarı tıkladığında kitabı yine siler. Bu yüzden yıkıcı işlemlerde method="dialog" yerine aşağıda göstereceğim gerçek bir form gönderimini tercih ediyorum.
Dialog'u kapatmak için bir de requestClose() method'u var ve Mayıs 2025'ten beri Baseline. close()'dan farkı, önce cancel event'ini atması: dialog'un içindeki form yarım doldurulmuşsa kapanmayı engelleyip kullanıcıya sorabilirsin. Bunu ve yazının geri kalanındaki iki yeni özelliği ölçen ikinci 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('book.html', 'utf8'));
page.on('console', m => console.log(m.text()));
await page.evaluate(() => {
// ToggleEvent.source: popover'ı hangi button açtı?
document.getElementById('mini-cart').addEventListener('toggle', e =>
console.log(`toggle ${e.oldState} -> ${e.newState}, source: ${e.source?.textContent}`));
// requestClose() cancel event'i atar; form dolmuşsa kapatmayı engelleyebilirsin
document.getElementById('delete-dialog').addEventListener('cancel', e => {
console.log('cancel event, preventing close');
e.preventDefault();
});
});
await page.getByRole('button', { name: 'Show cart' }).click();
await page.getByRole('button', { name: 'Show cart' }).click();
await page.locator('summary').first().click();
await page.getByRole('button', { name: 'Delete book' }).click();
console.log(':open ->', await page.evaluate(() =>
[...document.querySelectorAll(':open')].map(e => e.id || e.tagName.toLowerCase()).join(', ')));
await page.evaluate(() => document.getElementById('delete-dialog').requestClose());
console.log('dialog still open:', await page.evaluate(() => document.getElementById('delete-dialog').open));
await browser.close();
})();
toggle closed -> open, source: Show cart
toggle open -> closed, source: Show cart
:open -> delete-dialog, details
cancel event, preventing close
dialog still open: true
Son iki satır: requestClose() cancel event'ini attı, handler preventDefault() çağırdı ve dialog açık kaldı. İlk iki satıra popover ve Invoker Commands bölümünde, ortadaki :open satırına da o bölümün sonunda döneceğim.
closedby ile Light Dismiss ve Progressive Enhancement
Modal dialog varsayılan olarak yalnız Esc ile ya da bir butonla kapanır; dışarı tıklamak bir şey yapmaz. closedby attribute'u bunu ayarlar: any dışarı tıklamayı (light dismiss), Esc'i ve butonları kabul eder; closerequest yalnız Esc ve butonları; none yalnız butonları. Bizim dialog'da closedby="any" var ve interact.js çıktısının beşinci satırında backdrop'a tıklamak dialog'u kapattı.
Ama closedby Baseline değil: Chrome 134'ten, Firefox 141'den beri var, Safari'nin yayınlanmış sürümlerinde yok. Bir not: Playwright'ın WebKit build'i WebKit'in geliştirme dalını takip ediyor ve orada closedBy property'si mevcut; yani destek yolda olabilir, ama Safari kullanıcısının bugün elindeki tarayıcı bu değil. Burada progressive enhancement doğal: desteklemeyen tarayıcı attribute'u yok sayar ve dialog yine Esc ve butonlarla kapanır. Bir özellik çalışmadığında kullanıcı bir şey kaybetmiyorsa, onu bugün kullanmak güvenlidir. closedby tam olarak böyle bir özellik.
popover Attribute'u: auto, manual ve hint
Popover, dialog'dan daha hafif bir kavram: herhangi bir element'e popover attribute'u verirsin ve o element gizli başlar, açıldığında top layer'a çıkar. Mini sepet, kullanıcı menüsü, bir "?" ipucu gibi sayfayı kilitlemeyen paneller için. Ocak 2025'ten beri Baseline.
Üç türü var. popover ya da popover="auto" light dismiss destekler: dışarı tıklamak ya da Esc onu kapatır ve aynı anda açık olan başka bir auto popover'ı da kapatır; yani iki menü üst üste açık kalmaz. interact.js çıktısında sepet açıldı ve sayfanın boş bir yerine tıklanınca kapandı. popover="manual" yalnız açıkça kapatıldığında kapanır; "Kitap sepete eklendi" gibi bir toast için uygun. popover="hint" ise üzerine gelince açılan ipuçları için tasarlandı ve açılırken auto popover'ları kapatmaz. Hint Chrome 151 ve Firefox 153 ile geldi, Safari'nin yayınlanmış sürümlerinde yok; desteklemeyen tarayıcı geçersiz değer gibi davranıp onu manual sayar. Hint'i fareyle üzerine gelince açan interestfor attribute'u ise yalnız Chrome 149+'da var; ikisi de Kasım 2026 itibarıyla limited availability.
Popover modal değildir: sayfanın geri kalanı inert olmaz, odak da otomatik olarak içine taşınmaz. Kullanıcının bir karar vermesi gereken her şey için dialog, bilgi gösteren ve kolayca kapatılabilen her şey için popover.
Invoker Commands: JavaScript'siz Aç ve Kapat
Buraya kadar her şey HTML'deydi, ama bir soru açık kaldı: dialog'u açan showModal() çağrısını kim yapıyor? Cevap, sayfadaki iki attribute: commandfor hedef element'in id'sini, command yapılacak işi söyler. "Delete book" butonu command="show-modal" commandfor="delete-dialog" taşıyor; "Show cart" butonu command="toggle-popover" commandfor="mini-cart". Spec'teki diğer komutlar close, request-close, show-popover ve hide-popover. -- ile başlayan değerler de custom komuttur: tarayıcı hedefte bir command event'i atar ve gerisini senin JavaScript'in yapar. Invoker Commands, Safari 26.2'nin gelişiyle Aralık 2025'te Baseline newly available oldu.
Popover için daha eski bir yol da var: popovertarget attribute'u. O da çalışır ve Ocak 2025'ten beri Baseline; yeni kodda dialog ve popover için tek bir model olduğu için command'ı tercih ediyorum.
extras.js'in ilk iki satırı popover'ın toggle event'i: oldState ve newState ile birlikte artık source da geliyor, yani popover'ı hangi butonun açtığını biliyorsun. Aynı popover'ı birden fazla buton açıyorsa (ör. sayfanın üstünde ve altında birer "Show cart") bu bilgi işe yarıyor. Ortadaki satır :open pseudo-class'ı: o anda açık olan dialog ve details'i tek selector ile seçiyor. ToggleEvent.source ve :open Mayıs 2026'da Baseline oldu; :open sayesinde açık bir accordion'u stillemek için artık details[open] attribute selector'üne ihtiyacın yok.
Yukarıda returnValue tuzağı yüzünden yıkıcı işlemlerde gerçek bir form gönderimini tercih ettiğimi söylemiştim. Invoker Commands ile o da JavaScript'siz:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Admin | Bookstore</title>
</head>
<body>
<main>
<h1>Clean Architecture</h1>
<button type="button" command="show-modal" commandfor="delete-dialog">Delete book</button>
<dialog id="delete-dialog" aria-labelledby="delete-title">
<h2 id="delete-title">Delete this book?</h2>
<p>Clean Architecture will be removed from the catalog.</p>
<form method="post" action="/admin/books/42/delete">
<button type="button" command="close" commandfor="delete-dialog" autofocus>Cancel</button>
<button type="submit">Delete</button>
</form>
</dialog>
</main>
</body>
</html>
server: GET /admin/books/42
after Cancel, dialog open: false
server: POST /admin/books/42/delete
"Cancel" bir type="button" ve command="close" ile dialog'u kapatıyor; sunucuya hiçbir şey gitmedi. "Delete" ise form'u gerçekten POST ediyor ve silme kararı sunucuda veriliyor. returnValue'ya bakan bir JavaScript yok, dolayısıyla tuzak da yok.
Üretimde Dikkat
Baseline tarihleri bu yazıda önemli. Invoker Commands newly available, yani Safari 26.2'den eski bir iPhone'da "Delete book" butonu hiçbir şey yapmaz. Bookstore'un analitiğinde bu sürümlerin payı hâlâ anlamlıysa iki seçenek var: yaygın kullanılan invokers polyfill'ini yüklemek ya da 'command' in HTMLButtonElement.prototype ile özellik kontrolü yapıp desteklenmiyorsa birkaç satırlık bir click handler bağlamak. Widely available'a geçiş Haziran 2028'de; o tarihe kadar hedef tarayıcı listeni bilinçli seç. Angular'ın tarayıcı hedefi ve Baseline ilişkisi serinin son yazısında.
Test ederken dikkat: bu yazıdaki ölçümleri Chromium'da yaptım. Playwright'ın WebKit build'i WebKit'in geliştirme dalını izlediği için closedby, popover="hint" ve hidden="until-found" gibi Safari'nin yayınlanmış sürümünde olmayan özellikleri destekliyor görünüyor. Safari desteği hakkında karar verirken Playwright'ın WebKit'ine değil, web-features verisine ve gerçek Safari'ye bak.
Framework'lerde bu element'leri sarmalarken DOM'daki (Document Object Model) yerlerine dikkat et. Top layer, dialog'u görsel olarak her şeyin üstüne çıkarır ama DOM'daki yeri değişmez. Angular'da dialog'u bir @if ile koşullu render edip açıkken DOM'dan kaldırırsan, close event'i ve odağın açan butona dönmesi gibi davranışlar beklediğin gibi çalışmayabilir. Dialog'u DOM'da tutup aç/kapat ile yönetmek daha öngörülebilir.
Sık Düşülen Tuzaklar
Birincisi, modal'ı div ve z-index ile taklit etmek. Top layer yok, bir üst element'in overflow'u onu keser; inert yok, arkadaki sayfa Tab ile gezilebilir; Esc yok, odak yönetimi yok. Bunların hepsini elle yazmak, dialog'un bedava verdiğini yeniden icat etmek demek.
İkincisi, returnValue'nun her açılışta sıfırlandığını sanmak. Sıfırlanmaz; yıkıcı işlemi ya sunucuya giden bir form ile yap ya da açmadan önce değeri kendin temizle.
Üçüncüsü, autofocus'u yıkıcı butona vermek. Odak "Cancel"da başlasın.
Dördüncüsü, summary'nin içine link ya da buton koymak. summary zaten etkileşimli bir element; içindeki ikinci etkileşimli element hem tıklamayı hem ekran okuyucuyu karıştırır.
Beşincisi, closedby, popover="hint" ya da hidden="until-found"'ı her yerde çalışıyor sanmak. Üçü de Kasım 2026 itibarıyla Safari'de yok; yalnız yokluğunda kullanıcının bir şey kaybetmediği yerlerde kullan.
Küçük Bir Deneme
★☆☆ Yazıdaki book.html'i Chrome'da aç ve fareye dokunmadan, yalnız klavyeyle dialog'u aç, Tab ile içinde gez, Esc ile kapat. Odağın nereden nereye gittiğini izle. Sonra dialog'u bir de DevTools Console'da document.getElementById('delete-dialog').show() ile non-modal aç ve arkadaki sayfaya tıklayıp Tab ile gezerek farkı gör.
★★☆ SSS'ye üçüncü bir soru ekle ve details:open summary selector'üyle açık sorunun başlığını kalın yap. Ardından sepete popover="manual" olan bir "Book added to cart" toast'u ekle ve "Show cart" ile açılan auto popover'ın onu kapatıp kapatmadığını test et.
★★★ Kendi projendeki bir modal component'ini dialog ve Invoker Commands ile yeniden yaz. Eski ve yeni sürümü klavyeyle karşılaştır: arkadaki sayfa Tab ile erişilebilir mi, Esc çalışıyor mu, kapanınca odak nereye dönüyor? Silinen satır sayısını not et.
Bir sonraki yazıda native etkileşimli element'lerin ikinci yarısına geçiyoruz: select, datalist, tasarımı nihayet CSS ile değiştirilebilen customizable select ve output, progress, meter.