Native Interaktif Element'ler 2: select, datalist, Customizable Select ve output/progress/meter
Seçim için select, öneri için datalist kullanılır: datalist listede olmayan değeri de gönderir, required select ise yalnız value="" olan ilk option'ı placeholder sayar. Bu yazıda katalog filtresini, appearance: base-select ile kapak görselli customizable select'i (Chrome 135 ve Safari 27, Firefox yok) ve output/progress/meter'ı Chromium ve WebKit'te ölçüyor, disabled placeholder tuzağını ve submit edilmeyen output'u gösteriyoruz.
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) ve Nu Html Checker 26.11.9 ile denenmiştir, Firefox desteği web-features verisinden alınmıştır.
Önce Tahmin Et
Bookstore'un katalog sayfasında bir filtre form'u var: sıralama, kategori, format ve yazar. Hepsi native element:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Catalog | Bookstore</title>
</head>
<body>
<main>
<h1>Catalog</h1>
<form action="/catalog" method="get">
<label for="sort">Sort by</label>
<select id="sort" name="sort">
<option value="relevance">Relevance</option>
<option value="price-asc">Price: low to high</option>
<option value="newest" selected>Newest first</option>
</select>
<label for="category">Category</label>
<select id="category" name="category" required>
<option value="">Choose a category</option>
<optgroup label="Software">
<option value="architecture">Architecture</option>
<option value="dotnet">.NET</option>
</optgroup>
<optgroup label="Fiction">
<option>Science fiction</option>
<option>Crime</option>
</optgroup>
</select>
<label for="format">Formats</label>
<select id="format" name="format" multiple size="3">
<option value="hardcover">Hardcover</option>
<option value="paperback">Paperback</option>
<option value="ebook">E-book</option>
</select>
<label for="author">Author</label>
<input id="author" name="author" list="author-list" autocomplete="off">
<datalist id="author-list">
<option value="Robert C. Martin"></option>
<option value="Martin Fowler"></option>
<option value="Eric Evans" label="Domain-Driven Design"></option>
</datalist>
<button type="submit">Filter</button>
</form>
</main>
</body>
</html>
Kullanıcı hiçbir şey seçmeden "Filter"a basarsa ne olur? Sonra kategoriden "Crime"ı, formatlardan "Paperback" ile "E-book"u seçip yazar alanına listede olmayan "Uncle Bob" yazarsa sunucuya giden URL (Uniform Resource Locator) tam olarak nasıl görünür? "Crime" option'ının value'su yok; ne gönderilir? İki format nasıl taşınır? Ve "Uncle Bob" listede olmadığı için reddedilir mi?
Bir önceki yazı olan "Native Interaktif Element'ler 1: details/summary, dialog, popover ve Invoker Commands"ta tarayıcının JavaScript'siz verdiği pencereleri ve panelleri gördük. Bu yazıda ikinci yarı var: seçim yapan element'ler (select ve datalist), on yıllardır "stillenemez" diye kütüphanelerle değiştirilen select'in nihayet CSS (Cascading Style Sheets) ile tasarlanabilen hâli, ve bir değeri gösteren üç küçük element: output, progress, meter.
select, optgroup ve multiple: Katalog Filtresi
Tahmin sorusunu Chromium'da cevaplayan Playwright script'i; sayfayı küçük bir Node sunucusundan servis ediyor ve sunucuya gelen her isteği yazdırıyor:
const { chromium } = require('playwright');
const http = require('http');
const fs = require('fs');
const server = http.createServer((req, res) => {
if (req.url !== '/catalog') console.log(`server: GET ${req.url}`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(fs.readFileSync('catalog.html'));
}).listen(18882);
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:18882/catalog');
// required select: ilk option value="" ise placeholder sayılır
console.log('category valid:', await page.locator('#category').evaluate(s => s.validity.valid),
'| value:', JSON.stringify(await page.locator('#category').inputValue()));
await page.getByRole('button', { name: 'Filter' }).click();
console.log('url after submit:', new URL(page.url()).search || '(no navigation)');
await page.locator('#category').selectOption({ label: 'Crime' });
await page.locator('#format').selectOption(['paperback', 'ebook']);
// datalist yalnız öneri: listede olmayan değer de gönderilir
await page.locator('#author').fill('Uncle Bob');
await page.getByRole('button', { name: 'Filter' }).click();
await page.waitForURL('**/catalog?*');
console.log('datalist options:', await page.locator('#author').evaluate(i =>
[...i.list.options].map(o => `${o.value}|${o.label}`).join(', ')));
await browser.close();
server.close();
})();
category valid: false | value: ""
url after submit: (no navigation)
server: GET /catalog?sort=newest&category=Crime&format=paperback&format=ebook&author=Uncle+Bob
datalist options: Robert C. Martin|, Martin Fowler|, Eric Evans|Domain-Driven Design
Üçüncü satır sunucunun gördüğü query string. Tek tek bakalım. sort=newest: kullanıcı sıralamaya dokunmadı, ama selected attribute'u taşıyan option varsayılan seçim oldu ve gönderildi. selected yoksa ilk option seçili başlar; "kullanıcı bir şey seçmedi" diye bir durum tek seçimli select'te yoktur.
category=Crime: option'ın value attribute'u yoksa tarayıcı onun metnini gönderir. Kolaylık gibi görünür ama metni sonradan "Crime & Thriller" yapan biri sunucudaki filtreyi bozar. Gösterilen metin ile gönderilen anahtarı ayırmak için her option'a value yaz; metni çeviri ekibi, value'yu backend sahiplenir.
optgroup option'ları başlık altında gruplar ve ekran okuyucu grubun adını okur, ama gönderilen değere hiçbir şey eklemez: sunucu "Fiction" kelimesini görmedi. optgroup'un label'ı seçilebilir bir option değildir.
format=paperback&format=ebook: multiple olan select'te her seçili option aynı isimle ayrı bir çift olarak gider. Sunucu tarafında bunu bir array olarak karşılarsın; ASP.NET Core'da bir action parametresini string[] format yapmak yeterli, model binding ayrıntısı ASP.NET Core MVC (Model-View-Controller) ve Razor Pages serisinde. Ama multiple'ı kullanmadan önce iki kez düşün: masaüstünde birden fazla seçim için Ctrl (Mac'te Cmd) ile tıklamak gerekir ve bunu bilen kullanıcı azdır; yanlışlıkla Ctrl'siz tıklayan kişi önceki seçimlerini sessizce kaybeder. Üç-beş seçenek varsa "HTML Form'ları 1" yazısındaki fieldset ve checkbox grubu hem daha anlaşılır hem aynı query string'i üretir.
required select ve Placeholder Option
Çıktının ilk iki satırı tahmin sorusunun ilk cevabı: kategori alanı required ve kullanıcı bir şey seçmeden "Filter"a bastığında form gönderilmedi, sayfa adresi değişmedi. Peki tarayıcı "Choose a category" option'ını neden geçersiz saydı? O da bir option ve seçili.
Spec bu option'a placeholder label option der. Koşulları: select required olacak, multiple olmayacak ve tek satır görünecek (size 1 ya da yok), ilk option'ın value'su boş string olacak ve bu option bir optgroup'un içinde değil, doğrudan select'in çocuğu olacak. Bu durumda ilk option seçiliyken alan valueMissing sayılır. "HTML Form'ları 2" yazısındaki :user-invalid stili burada da aynen çalışır.
Koşullardan birini kaçırmak sessiz bir hatadır ve en sık görülen iki hâli birbirinin zıddı. Dört placeholder varyantını aynı required select'te deneyen script:
const { chromium } = require('playwright');
const variants = [
'<option value="">Choose a category</option>',
'<option value="" disabled>Choose a category</option>',
'<option value="" disabled selected>Choose a category</option>',
'<option value="none">Choose a category</option>',
];
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
for (const placeholder of variants) {
await page.setContent(`<select required>${placeholder}<option value="crime">Crime</option></select>`);
const [value, valid] = await page.evaluate(() => {
const s = document.querySelector('select');
return [s.value, s.validity.valid];
});
console.log(`${placeholder.padEnd(62)} value="${value}" valid=${valid}`);
}
await browser.close();
})();
<option value="">Choose a category</option> value="" valid=false
<option value="" disabled>Choose a category</option> value="crime" valid=true
<option value="" disabled selected>Choose a category</option> value="" valid=false
<option value="none">Choose a category</option> value="none" valid=true
İkinci satır en sinsi olanı: "kullanıcı placeholder'ı seçemesin" diye disabled eklenmiş ama selected unutulmuş. Tek satırlık bir select'te hiçbir option seçili değilse tarayıcı ilk disabled olmayan option'ı seçer; placeholder hiç seçilmedi, ekranda doğrudan "Crime" göründü ve required boşa düştü. disabled selected birlikte yazılınca (üçüncü satır) kural yine çalışıyor. Dördüncü satırda value="none" alanı her zaman geçerli yapıyor ve sunucuya category=none gidiyor. Benim tercihim ilk satır: value="", disabled yok. Kullanıcı yanlışlıkla bir kategori seçerse geri dönüp "seçmedim" diyebilir.
datalist: Öneri Listesi, Seçim Değil
Yazar alanındaki input, list="author-list" ile bir datalist'e bağlı. Kullanıcı yazdıkça tarayıcı listedeki option'lardan eşleşenleri önerir. Kritik nokta çıktıdaki author=Uncle+Bob: listede olmayan değer reddedilmedi. datalist bir select değildir; yalnız öneri verir, kullanıcı istediğini yazabilir. Değerin listeden olması gerekiyorsa ya select kullan ya da kontrolü sunucuda yap.
Script'in son satırı da datalist'in iki attribute'unu gösteriyor: gönderilen şey her zaman value, label ise öneri satırında ek bir açıklama olarak görünür. "Eric Evans" option'ı "Domain-Driven Design" etiketini taşıyor; kullanıcı kitabın adını görüyor, input'a yazarın adı giriyor. Tarayıcılar label'ı öneri satırında farklı biçimlerde çizer; arayüzün ona dayanmasın. Yazar input'unda autocomplete="off" var, çünkü tarayıcının kendi form geçmişinin bu öneri listesine karışmasını istemiyoruz.
datalist'in sınırları bilinmeli. Öneri penceresinin görünümü tamamen tarayıcıya ait ve CSS ile değiştirilemez. Eşleşme kuralı (baştan mı, içinde mi geçiyor, büyük-küçük harf) tarayıcıya göre değişir. Gruplama yok. Ve web-features verisine göre özellik Baseline değil: Chrome, Edge, Safari ve masaüstü Firefox destekliyor ama Firefox for Android desteklemiyor; Baseline'a geçişi Şubat 2023'ten beri bu yüzden bekliyor. Desteklemeyen tarayıcıda alan sıradan bir metin kutusudur, yani kayıp yalnız önerilerdir. Bu da datalist'i "olursa iyi" türünden yardımlar için uygun, sunucudan binlerce kayıt arayan bir autocomplete için uygunsuz yapar. İkincisi için JavaScript ile yazılmış, ARIA (Accessible Rich Internet Applications) combobox pattern'ine uyan bir component gerekir ve bu ilerideki Accessibility (a11y) serisinin konusu.
Customizable Select: appearance: base-select
On yılı aşkın süredir tasarım ekiplerinin select'le kavgası aynıydı: kapalı hâlini biraz stilleyebiliyorsun, açılan liste ise işletim sisteminin. İçine görsel koyamıyorsun, fiyatı sağa yaslayamıyorsun. Sonuç, her projede div'lerden yapılmış bir dropdown ve onun klavye, odak ve ekran okuyucu hataları. Customizable select bunu değiştiriyor. Bookstore'un kitap sayfasındaki format seçimini kapak görseli ve fiyatla birlikte gösterelim:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Clean Architecture | Bookstore</title>
<style>
select,
::picker(select) {
appearance: base-select;
}
option img {
width: 24px;
height: 36px;
}
option:checked {
font-weight: bold;
}
select:open::picker-icon {
rotate: 180deg;
}
</style>
</head>
<body>
<main>
<h1>Clean Architecture</h1>
<form action="/cart" method="post">
<label for="format">Format</label>
<select id="format" name="format">
<button>
<selectedcontent></selectedcontent>
</button>
<option value="hardcover">
<img src="/covers/42-hardcover.webp" alt="" width="24" height="36">
<span>Hardcover</span>
<span class="price">49.90 USD</span>
</option>
<option value="paperback" selected>
<img src="/covers/42-paperback.webp" alt="" width="24" height="36">
<span>Paperback</span>
<span class="price">34.90 USD</span>
</option>
<option value="ebook">
<img src="/covers/42-ebook.webp" alt="" width="24" height="36">
<span>E-book</span>
<span class="price">19.90 USD</span>
</option>
</select>
<button type="submit">Add to cart</button>
</form>
</main>
</body>
</html>
Üç parça var. Birincisi CSS'te opt-in: select'e ve açılan listeyi temsil eden ::picker(select) pseudo-element'ine appearance: base-select veriliyor. İkisini birlikte vermek gerekir; yalnız select'e verirsen liste işletim sisteminin görünümünde kalır. İkincisi select'in ilk çocuğu olan button ve içindeki selectedcontent: kapalı select'in görünen kısmı bu buton, selectedcontent ise seçili option'ın içeriğinin bir kopyasını gösteriyor. Üçüncüsü option'ların içi: artık img ve span gibi interaktif olmayan element'ler konabiliyor. option:checked seçili option'ı, select:open listenin açık olduğu durumu seçiyor (:open'ı bir önceki yazıda dialog ve details için görmüştük); ::picker-icon oku, ::checkmark da seçili option'ın yanındaki işareti stillemeye yarıyor. Liste açıldığında, bir önceki yazıdaki popover gibi top layer'da çizilir; anchor positioning ile konumunu değiştirmek ilerideki CSS3: Flexbox, Grid ve Modern CSS serisinin konusu.
Bu davranışı iki motorda ölçen script:
const pw = require('playwright');
const fs = require('fs');
const source = fs.readFileSync('format.html', 'utf8');
(async () => {
for (const name of ['chromium', 'webkit']) {
const browser = await pw[name].launch();
const page = await browser.newPage();
await page.setContent(source);
const select = page.locator('#format');
const report = () => select.evaluate(s => ({
open: s.matches(':open'),
value: s.value,
focus: document.activeElement.tagName.toLowerCase(),
shown: [...s.querySelector('selectedcontent').children].map(c => c.textContent || c.tagName.toLowerCase()).join(' | '),
}));
console.log(`--- ${name} ${browser.version()}`);
console.log('base-select supported:', await page.evaluate(() => CSS.supports('appearance', 'base-select')));
// parser option içindeki img ve span'leri DOM'da tuttu mu?
console.log('option children:', await select.evaluate(s =>
[...s.options[0].children].map(c => c.tagName.toLowerCase()).join(',')));
console.log('initial:', JSON.stringify(await report()));
await select.focus();
await page.keyboard.press('Space');
console.log('after Space:', JSON.stringify(await report()));
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
console.log('after ArrowDown+Enter:', JSON.stringify(await report()));
if (name === 'chromium') {
// Accessibility tree'de select hâlâ bir combobox mı?
const cdp = await page.context().newCDPSession(page);
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
const combo = nodes.find(n => n.role?.value === 'combobox');
console.log(`ax: combobox "${combo.name.value}" value="${combo.value.value}"`);
}
await browser.close();
}
})();
--- chromium 158.0.8127.18
base-select supported: true
option children: img,span,span
initial: {"open":false,"value":"paperback","focus":"body","shown":"img | Paperback | 34.90 USD"}
after Space: {"open":true,"value":"paperback","focus":"option","shown":"img | Paperback | 34.90 USD"}
after ArrowDown+Enter: {"open":false,"value":"ebook","focus":"select","shown":"img | E-book | 19.90 USD"}
ax: combobox "Format" value=" E-book 19.90 USD"
--- webkit 27.2
base-select supported: true
option children: img,span,span
initial: {"open":false,"value":"paperback","focus":"body","shown":"img | Paperback | 34.90 USD"}
after Space: {"open":true,"value":"paperback","focus":"option","shown":"img | Paperback | 34.90 USD"}
after ArrowDown+Enter: {"open":false,"value":"ebook","focus":"select","shown":"img | E-book | 19.90 USD"}
Chromium ve WebKit aynı sonucu verdi. option children satırı parser'ın img ve span'leri DOM'da (Document Object Model) tuttuğunu gösteriyor; eski parser bunları atıp yalnız metni bırakırdı. Space tuşu listeyi açtı ve odak seçili option'a geçti; ArrowDown ve Enter ile "E-book" seçildi, liste kapandı, odak select'e döndü. selectedcontent'in içinde artık E-book option'ının img'i, adı ve fiyatı var: tarayıcı seçim değiştikçe içeriği kendisi kopyalıyor. Form gönderildiğinde giden yine value, yani format=ebook.
Klavye, odak ve rol tarafını tarayıcı yönetiyor: Chromium'un accessibility tree'sinde bu hâlâ "Format" adlı bir combobox. Kendi yazdığın bir dropdown'da bunların her birini elle kurman gerekirdi. Ama son satırdaki değere dikkat: ekran okuyucunun okuyacağı değer yalnız "E-book" değil, " E-book 19.90 USD". Option'a koyduğun her metin seçili değerin parçası olur. Burada fiyatın okunması işe yarıyor; option'a "Bestseller" gibi bir rozet ya da "in stock" gibi bir not eklersen o da her seferinde değerle birlikte okunur.
Desteklemeyen Tarayıcıda Ne Olur?
Kasım 2026 itibarıyla web-features verisi şöyle: Chrome ve Edge 135'ten (Nisan 2025), Safari 27'den (Eylül 2026) beri destekliyor; Firefox desteklemiyor ve özellik bu yüzden limited availability. Firefox 153 ile select'in parser kısıtlamalarını kaldırdı, yani button, img ve span DOM'a giriyor; ama appearance: base-select henüz yok. Firefox'u bu yazı için çalıştıramadım; MDN'e (MDN Web Docs) göre desteklemeyen tarayıcıda button ve selectedcontent yok sayılır, option'larda yalnız metin görünür ve ortaya sıradan, tamamen çalışan bir select çıkar.
Progressive enhancement'ın bu kadar temiz olmasının şartı, WebKit ekibinin Haziran 2026'daki yazısında "golden rule" dediği kural: her option'ın bir metni olmalı. Görsel, ikon ya da renk kutusu metni tamamlar, yerine geçmez. Bizim örnekte her option'da "Paperback" gibi bir metin var ve kapak görselleri alt="" ile dekoratif işaretlendi, çünkü anlamı metin taşıyor. Görseli alt="Paperback cover" yapsaydın ekran okuyucu her option'da formatın adını iki kez okurdu.
Fallback'te değişen başka bir şey daha var: fiyat. Desteklemeyen tarayıcıda option metni "Paperback 34.90 USD" gibi bitişik bir string olarak görünür. Bu kabul edilebilir; kabul edilemeyecek olan, bilgiyi yalnız görselde ya da yalnız CSS ile eklenen ::before içeriğinde taşımaktır.
output, progress ve meter
Son üç element bir değer gösterir ve üçü de sıkça yanlış yerde kullanılır. Bookstore'un sipariş sayfası:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Order #1042 | Bookstore</title>
</head>
<body>
<main>
<h1>Order #1042</h1>
<p>
<label for="order-step">Order status</label>
<progress id="order-step" max="4" value="2"></progress>
Step 2 of 4: Packed
</p>
<p>
<label for="upload">Uploading invoice</label>
<progress id="upload"></progress>
</p>
<p>
<label for="stock">Stock</label>
<meter id="stock" min="0" max="50" low="5" high="20" optimum="50" value="3"></meter>
3 left
</p>
<form action="/cart" method="get" oninput="total.value = (qty.valueAsNumber * 34.90).toFixed(2)">
<label for="qty">Quantity</label>
<input id="qty" name="qty" type="number" min="1" max="3" value="1">
<p><label for="total">Total</label> <output id="total" name="total" for="qty">34.90</output> USD</p>
<button type="submit">Update cart</button>
</form>
</main>
</body>
</html>
const { chromium } = require('playwright');
const http = require('http');
const fs = require('fs');
const server = http.createServer((req, res) => {
if (req.url !== '/orders/1042') console.log(`server: GET ${req.url}`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(fs.readFileSync('order.html'));
}).listen(18883);
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:18883/orders/1042');
// Accessibility tree: progress, meter ve output hangi role ile görünüyor?
const cdp = await page.context().newCDPSession(page);
const { nodes } = await cdp.send('Accessibility.getFullAXTree');
for (const n of nodes.filter(n => ['progressbar', 'meter', 'status'].includes(n.role?.value))) {
const prop = k => n.properties?.find(p => p.name === k)?.value.value;
console.log(`${n.role.value} "${n.name?.value}" value=${n.value?.value} max=${prop('valuemax')}` +
(prop('live') ? ` live=${prop('live')}` : ''));
}
console.log(':indeterminate ->', await page.evaluate(() =>
[...document.querySelectorAll(':indeterminate')].map(e => e.id).join(', ')));
await page.getByLabel('Quantity').fill('3');
console.log('output value:', await page.locator('#total').textContent());
await page.getByRole('button', { name: 'Update cart' }).click();
await page.waitForURL('**/cart?*');
await browser.close();
server.close();
})();
progressbar "Order status" value=2 max=4
progressbar "Uploading invoice" value=undefined max=1
meter "Stock" value=3 max=50
status "Total" value=undefined max=undefined live=polite
:indeterminate -> upload
output value: 104.70
server: GET /cart?qty=3
progress bir işin ne kadarının bittiğini söyler: value ve max ile "4 adımın 2'si". Accessibility tree'de progressbar rolüyle, değeri ve maksimumuyla görünüyor. value vermezsen progress indeterminate olur, yani "sürüyor ama ne kadar kaldığını bilmiyoruz"; çıktıdaki :indeterminate satırı bunu seçiyor ve fatura yükleme gibi süresi belirsiz işler için doğru gösterim bu. Progress'in yanına "Step 2 of 4: Packed" metnini de yazdım; çubuk bir oranı gösterir ama hangi adımda olunduğunu metin söyler.
meter ise bilinen bir aralıktaki bir ölçümü gösterir: stok seviyesi, disk kullanımı, bir puan. İlerleme değildir; stok 3'ten 50'ye "tamamlanmaz". low, high ve optimum aralığı bölgelere ayırır. Bizde optimum="50" yüksek bölgede, değer 3 ise low'un altında; spec'e göre bu, optimumdan en uzak bölge ve tarayıcı çubuğu buna göre renklendirir. Renk tarayıcıya göre değişir, o yüzden "3 left" metni yine görünür durumda. Accessibility tree'de meter rolüyle görünüyor.
output bir hesaplamanın sonucudur. Örnekte form'un oninput'u adet değişince toplamı yazıyor; adet 3 olunca 104.70 oldu. Accessibility tree'de rolü status ve live=polite: ekran okuyucu değer değiştiğinde, kullanıcının o anki işini bölmeden yeni toplamı okur. Bunu bir span ile yapsaydın bir live region'ı elle kurman gerekirdi. for attribute'u sonucun hangi alanlardan hesaplandığını söyler, ve output label ile adlandırılabilir; tree'deki "Total" adı oradan geliyor.
Çıktının son satırı en çok şaşırtanı: sunucuya yalnız qty=3 gitti. output'un bir name'i olsa da form ile gönderilmez; form'un element listesinde yer alır ama submittable değildir. Toplamı sunucuya göndermeyi düşünüyorsan zaten yanlış yoldasın: fiyatı istemciden almak, "HTML Form'ları 2" yazısında konuştuğumuz client validation hatasının bir başka hâli. Toplam sunucuda yeniden hesaplanır. Event handler'ları satır içi yazmak yerine bir script dosyasından bağlamak ve valueAsNumber gibi API'ler (Application Programming Interface) JavaScript Temel serisinin DOM ve event yazılarında.
Üretimde Dikkat
Customizable select'i bir design system'e almadan önce CI (Continuous Integration) araçlarını kontrol et. Bu yazıdaki üç sayfayı Nu Html Checker'dan geçirdim:
docker run --rm -v "${PWD}/site:/site" ghcr.io/validator/validator:latest vnu --errors-only /site
"file:/site/format.html":8.24-8.24: error: CSS: Parse Error.
"file:/site/format.html":9.25-9.35: error: CSS: “appearance”: “base-select” is not a “appearance” value.
"file:/site/format.html":18.22-18.32: error: CSS: Unknown pseudo-element or pseudo-class “::picker-icon”.
Checker HTML tarafında sorun görmedi: select'in içindeki button, selectedcontent ve option içindeki img geçerli. Ama içindeki CSS doğrulayıcısı base-select'i, ::picker(select)'i ve ::picker-icon'u tanımıyor ve üç error veriyor. Bunlar senin hatan değil, aracın geride kalması; ama checker'ı CI'da error'larda build kıracak şekilde kurduysan (serinin ilk yazısında önerdiğim gibi) build kırılır. Çözüm, stili ayrı bir .css dosyasına almak ya da bu mesajları --filterpattern ile bilinçli olarak filtrelemek; mesajları körlemesine susturmak değil.
Framework tarafında: MDN, bazı JavaScript framework'lerinin select içindeki button'ı engellediğini, server-side render eden bazılarında da hydration hatası çıkabildiğini not ediyor. Angular ya da Blazor'da kullanmadan önce framework sürümünle küçük bir sayfa render edip DOM'u kontrol et. Ve desteği artıracak her yeni sürümde değil, Firefox desteği geldiğinde bile en az bir süre fallback görünümünü test etmeye devam et.
Son olarak datalist'i analitikle birlikte düşün: Bookstore'un mobil trafiğinde Firefox for Android'in payı küçük olsa da sıfır değilse, öneriye bağlı bir akış (ör. "listeden seç, yoksa bulunamaz") o kullanıcılar için kırılır. Öneri bir kolaylık olarak kalmalı.
Sık Düşülen Tuzaklar
Birincisi, native select'i yalnız görünüşü için bir dropdown kütüphanesiyle değiştirmek. Kütüphane genellikle mobilde işletim sisteminin seçicisini kaybettirir, klavyede yarım çalışır ve form'a katılmak için gizli bir input'a ihtiyaç duyar. Görünüm tek sebepse önce customizable select'e, desteklemeyen tarayıcıda klasik select'e razı olmayı dene. Arama yapan, sunucudan sonuç getiren ya da birden fazla seçimi etiket olarak gösteren gerçek bir combobox gerekiyorsa sıfırdan yazma; Angular'da Angular Aria gibi headless bir directive kütüphanesi klavye ve ARIA tarafını hazır verir. Hangisinin ne zaman gerektiği Accessibility (a11y) serisinde.
İkincisi, datalist'i bir doğrulama sanmak. Listede olmayan değer gönderilir; sunucu kontrol etmeli.
Üçüncüsü, placeholder option'ı yanlış yazmak: value="0" ya da value="none" ile required hiç tetiklenmez.
Dördüncüsü, progress ile meter'ı karıştırmak. Stok, puan ve kota meter; bir işin ilerlemesi progress. Bir de, değeri bilinmeyen bir iş için value="0" yazmak: kullanıcı "yüzde sıfırda takıldı" görür. Değer bilinmiyorsa value'yu hiç yazma.
Beşincisi, customizable select'te bilgiyi yalnız görsele koymak. Option'da metin yoksa fallback'te boş satırlar, ekran okuyucuda adsız seçenekler kalır.
Küçük Bir Deneme
★☆☆ catalog.html'deki kategori placeholder'ını value="none" yap ve "Filter"a hiçbir şey seçmeden bas. Sunucuya ne gittiğini gör, sonra düzelt. Aynı denemeyi placeholder'ı bir optgroup'un içine taşıyarak tekrarla.
★★☆ Format seçimini @supports (appearance: base-select) içine al: destekleyen tarayıcıda fiyat sağa yaslı ayrı bir sütunda, desteklemeyende option metni "Paperback (34.90 USD)" gibi okunaklı bir string olsun. İki durumu Chrome'da DevTools'tan appearance'ı kapatarak karşılaştır.
★★★ Projendeki bir custom dropdown component'ini bul ve yanına aynı seçenekleri gösteren bir customizable select koy. Klavyeyle ikisini karşılaştır (Space, ok tuşları, Esc, harfle atlama), sonra Chrome DevTools'ta ikisinin accessibility tree'deki rolüne ve adına bak. Kütüphanenin hangi özelliğine gerçekten ihtiyacın olduğunu bir listeye yaz.
Seçim ve değer gösteren element'lerle native etkileşimli element'ler bitti. Bir sonraki yazıda sayfanın en ağır kısmına geçiyoruz: görseller ve media. width/height ile layout shift'i önlemek, iyi bir alt metni yazmak, srcset ve sizes, picture ile format fallback'i, video'ya altyazı eklemek ve iframe.