HTML Form'ları 1: Input Type'ları, label, fieldset, autocomplete ve inputmode
Erişilebilir ve hızlı doldurulan bir form'un temeli her alanda görünen bir label, doğru input type'ı ve spec'teki autocomplete token'larıdır; posta kodu için type="number" baştaki sıfırı kaybeder, type'ı yazılmamış button ise form'u gönderir. Bu yazıda Bookstore'un checkout form'unu fieldset/legend, inputmode, enterkeyhint, autocorrect (Baseline 2026-09) ve form attribute'uyla kuruyor, her davranışı Chromium'da gerçek bir testle ö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) ve Nu Html Checker 26.11.9 ile denenmiştir.
Önce Tahmin Et
Bookstore'un checkout form'unda posta kodu alanı <input type="number"> olarak yazılmış; "sayı giriliyor, number olsun" denmiş. Ankara'daki bir müşteri 06100 yazıyor, sonra alan odaktayken yanlışlıkla klavyede yukarı ok tuşuna basıyor. Sunucuya hangi değer gider? Aynı form'da kupon kodunun yanında bir <button>Apply coupon</button> var, type attribute'u yok. Müşteri kuponu yazıp bu butona bastığında ne olur?
Bu yazı ve bir sonraki, Bookstore'un checkout form'u üzerine. Burada form'un yapısını kuruyoruz: label, fieldset, doğru input type'ı, tarayıcının adresi otomatik doldurmasını sağlayan autocomplete ve mobil klavyeyi yönlendiren attribute'lar. Doğrulama (validation) bir sonraki yazının konusu. Form'u JavaScript ile okumak ve göndermek, yani FormData ve Constraint Validation API (Application Programming Interface), JavaScript Temel ve Orta serilerinde; burada tarayıcının JavaScript'siz verdiklerine odaklanıyoruz.
Checkout Form'unun İskeleti
Yazının sonunda varacağımız form bu. Önce tamamını gör, sonra parça parça gerekçelerine gidelim:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Checkout | Bookstore</title>
</head>
<body>
<main>
<h1>Checkout</h1>
<form id="checkout" action="/orders" method="post">
<fieldset>
<legend>Contact</legend>
<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="email">
<label for="phone">Mobile phone</label>
<input type="tel" id="phone" name="phone" autocomplete="tel">
</fieldset>
<fieldset>
<legend>Shipping address</legend>
<label for="full-name">Full name</label>
<input type="text" id="full-name" name="fullName" autocomplete="shipping name">
<label for="street">Street address</label>
<textarea id="street" name="street" autocomplete="shipping street-address"></textarea>
<label for="postal-code">Postal code</label>
<input type="text" id="postal-code" name="postalCode"
autocomplete="shipping postal-code" inputmode="numeric">
<label for="city">City</label>
<input type="text" id="city" name="city" autocomplete="shipping address-level2">
</fieldset>
<fieldset>
<legend>Shipping method</legend>
<label><input type="radio" name="shipping" value="standard" checked> Standard (3-5 days)</label>
<label><input type="radio" name="shipping" value="express"> Express (next day)</label>
</fieldset>
<label for="coupon">Coupon code</label>
<input type="text" id="coupon" name="code" form="coupon-form"
autocomplete="off" autocorrect="off" enterkeyhint="done">
<button type="submit" form="coupon-form">Apply coupon</button>
</form>
<form id="coupon-form" action="/cart/coupon" method="post"></form>
<button type="submit" form="checkout">Place order</button>
</main>
</body>
</html>
Nu Html Checker bu form için tek satır bile yazmıyor. Önceki yazılardaki script'i bu kez form role'lerini gösterecek şekilde çalıştırınca Chromium'un accessibility tree'si şöyle:
main
heading "Checkout" level=1
form
group "Contact"
textbox "Email"
textbox "Mobile phone"
group "Shipping address"
textbox "Full name"
textbox "Street address"
textbox "Postal code"
textbox "City"
group "Shipping method"
radio "Standard (3-5 days)"
radio "Express (next day)"
textbox "Coupon code"
button "Apply coupon"
form
button "Place order"
Her alanın bir adı var ve bu ad görünen label'dan geliyor. Üç fieldset üç adlı group oldu; ekran okuyucu kullanıcısı "Shipping address" grubuna girdiğinde bunu duyar ve içindeki "City" alanının teslimat adresine ait olduğunu bilir. Form'un altındaki boş form kupon form'u; ona birazdan geleceğiz.
label: for/id, Sarmalama ve Placeholder'ın Label Olmadığı
Bir input'u label'a bağlamanın iki yolu var. Birincisi for/id eşleşmesi: <label for="email"> ve <input id="email">. İkincisi input'u label'ın içine koymak; radio button'larda bu yolu seçtim, çünkü metin ile radio yan yana ve ayrı bir id üretmeye gerek kalmıyor. İki yolda da label'a tıklamak input'a odaklanır ya da radio'yu seçer; mobilde küçük bir radio'nun tıklanabilir alanı bu sayede bütün satıra yayılır.
Sık görülen kısayol label yerine placeholder yazmak. Bunu tasarımda temiz görünen ama sorunlu bir form üzerinde denedim:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Checkout pitfalls | Bookstore</title>
</head>
<body>
<form id="checkout" action="/orders" method="post">
<input type="email" name="email" placeholder="Email">
<input type="number" id="postal-code" name="postalCode" autocomplete="shipping zip">
<input type="text" id="coupon" name="coupon">
<button id="apply-coupon">Apply coupon</button>
<button type="submit" id="place-order">Place order</button>
</form>
</body>
</html>
const { chromium } = require('playwright');
const fs = require('fs');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(fs.readFileSync('pitfalls.html', 'utf8'));
// Sadece placeholder'ı olan input'un accessible name'i
const email = page.locator('input[name=email]');
console.log('email name:', await page.getByRole('textbox', { name: 'Email' }).count(), 'match');
await email.fill('ada@example.com');
console.log('placeholder visible after typing:', await email.evaluate(el => el.matches(':placeholder-shown')));
// type="number" ile posta kodu
const postal = page.locator('#postal-code');
await postal.fill('06100');
console.log('value:', await postal.inputValue(), '| valueAsNumber:', await postal.evaluate(el => el.valueAsNumber));
await postal.press('ArrowUp');
console.log('after ArrowUp:', await postal.inputValue());
// type'ı olmayan button ve Enter ile implicit submission
await page.evaluate(() => {
document.forms.checkout.addEventListener('submit', e => {
e.preventDefault();
console.log(`submit by #${e.submitter.id}`);
});
});
page.on('console', m => console.log('page:', m.text()));
await page.click('#apply-coupon');
await page.locator('#coupon').press('Enter');
console.log('apply-coupon type:', await page.locator('#apply-coupon').evaluate(b => b.type));
await browser.close();
})();
email name: 1 match
placeholder visible after typing: false
value: 06100 | valueAsNumber: 6100
after ArrowUp: 6101
page: submit by #apply-coupon
page: submit by #apply-coupon
apply-coupon type: submit
İlk satır yanıltıcı olabilir: Chromium, label'ı olmayan input'a placeholder'ı yedek ad olarak veriyor, bu yüzden getByRole('textbox', { name: 'Email' }) bir eşleşme buluyor. Ama ikinci satır asıl sorunu gösteriyor: kullanıcı yazmaya başladığı anda placeholder kayboluyor. Form'u yarıda bırakıp geri dönen, yazdığını kontrol etmek isteyen ya da kısa süreli belleği zayıf olan kullanıcı alanın ne istediğini artık göremiyor. Placeholder'ın varsayılan rengi de çoğu tasarımda düşük kontrastlı. Kural basit: her alanın görünen bir label'ı olsun; placeholder varsa yalnız bir örnek biçim göstersin ("ör. 06100").
ASP.NET Core'da <label asp-for="Email"> ve <input asp-for="Email"> Tag Helper'ları for ve id'yi aynı property'den ürettiği için bu bağ kendiliğinden kurulur; ayrıntısı ASP.NET Core MVC (Model-View-Controller) ve Razor Pages serisinin Tag Helper ve form yazılarında. Angular'da ise id'yi kendin yazarsın; aynı component bir sayfada iki kez kullanılıyorsa id'nin benzersiz olmasına dikkat et.
fieldset ve legend: Grupları Adlandırmak
Bir checkout'ta aynı ada sahip alanlar farklı yerlerde tekrar edebilir: teslimat adresinde ve fatura adresinde iki "City" var. fieldset alanları gruplar, legend gruba ad verir ve bu ad accessibility tree'de group "Shipping address" olarak görünür. Radio button'larda grup daha da önemli: "Standard (3-5 days)" tek başına bir cevap, ama soru "Shipping method".
Aynı name'i paylaşan radio'lar klavyede tek bir durak gibi davranır. Bunu checkout form'unda ölçtüm: şehir alanından Tab ile radio grubuna girip aşağı ok tuşuna basmak seçimi değiştiriyor. Label'a tıklamanın odağı input'a taşıdığını ve diğer form davranışlarını da aynı script'te görüyoruz:
const { chromium } = require('playwright');
const fs = require('fs');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(fs.readFileSync('checkout.html', 'utf8'));
page.on('console', m => console.log(m.text()));
await page.evaluate(() => {
document.addEventListener('submit', e => {
e.preventDefault();
console.log(`#${e.target.id} submitted by "${e.submitter.textContent}"`);
});
});
// Kupon alanında Enter kupon form'unu, şehir alanında Enter siparişi gönderir
await page.locator('#coupon').press('Enter');
await page.locator('#city').press('Enter');
// Radio grubu tek bir Tab durağıdır, ok tuşları seçimi değiştirir
await page.focus('#city');
await page.keyboard.press('Tab');
await page.keyboard.press('ArrowDown');
console.log('focused:', await page.evaluate(() => document.activeElement.value));
// Label'a tıklamak input'a odaklanır
await page.click('label[for=coupon]');
console.log('focused:', await page.evaluate(() => document.activeElement.id));
// form attribute'u sayesinde form'un dışındaki button da submit eder
await page.click('text=Place order');
await browser.close();
})();
#coupon-form submitted by "Apply coupon"
#checkout submitted by "Place order"
focused: express
focused: coupon
#checkout submitted by "Place order"
Üçüncü satır radio grubunu gösteriyor: Tab gruba seçili radio'dan girdi, aşağı ok "express"i seçti. Kullanıcı yedi seçenekli bir kargo listesinde yedi kez Tab'a basmıyor; grubun içinde oklarla gezip Tab ile çıkıyor. Bu davranışı div'lerle kurmak ciddi miktarda JavaScript ister. İlk iki satıra ve son satıra button bölümünde döneceğiz.
Doğru Input Type: email, tel, url, date ve number Tuzağı
Input type'ı üç şeyi belirler: tarayıcının değeri nasıl doğrulayacağı, mobilde hangi klavyenin açılacağı ve ekran okuyucunun alanı nasıl tanıtacağı. email değeri e-posta biçimine göre kontrol eder ve mobilde @ tuşu olan klavyeyi açar. url benzer şekilde / ve .com tuşlarını öne çıkarır. tel hiçbir biçim doğrulaması yapmaz, çünkü telefon numarası biçimleri ülkeden ülkeye değişir; ama mobilde telefon tuş takımını açar. date tarayıcının tarih seçicisini getirir ve ekranda kullanıcının diline göre biçimlenmiş tarihi gösterse de form'a her zaman yyyy-mm-dd biçiminde değer gönderir; hediye paketinin teslim tarihi gibi gerçek bir tarih için iyi bir seçim.
Tahmin sorusunun ilk yarısı number. Yukarıdaki çıktının üçüncü ve dördüncü satırı: 06100 yazılınca input'un string değeri hâlâ 06100, ama sayısal değeri 6100. Yukarı ok tuşu değeri bir artırıp 6101'e çevirdi ve baştaki sıfır da gitti; sunucuya giden değer bu. Masaüstünde fare tekerleğiyle sayfayı kaydırırken odaktaki bir number alanının değerini değiştirmek de mümkün. Accessibility tree'de alan textbox değil spinbutton olarak görünüyor; ekran okuyucu kullanıcısına "artırıp azaltabileceğin bir sayı" diyor.
number artırıp azaltmanın anlamlı olduğu değerler içindir: sepetteki adet, kişi sayısı. Rakamlardan oluşan ama sayı olmayan değerler, yani posta kodu, telefon, kart numarası, sipariş numarası, type="text" ve inputmode="numeric" ile yazılır. ASP.NET Core'da bu tuzağın bir de sunucu tarafı var: model'de posta kodunu int olarak tutarsan Input Tag Helper alan için kendiliğinden type="number" üretir; posta kodu model'de de string olmalı.
autocomplete Token'ları ve Autofill
Checkout form'unun en çok terk edildiği an, kullanıcının adresini on birinci kez elle yazdığı an. Tarayıcılar kayıtlı adresi ve iletişim bilgisini tek dokunuşla doldurabilir, ama hangi alanın ne olduğunu bilmeleri gerekir. autocomplete attribute'u bunu söyler. Değeri serbest metin değil, spec'te tanımlı token'lardır: name, email, tel, street-address, postal-code, address-level2 (şehir), country gibi. Önlerine shipping ya da billing koyarak hangi adrese ait olduklarını belirtirsin; aynı sayfada iki adres formu varsa tarayıcı ikisini karıştırmaz.
Token'lar katı. Eski bir kaynaktan kopyalanmış autocomplete="shipping zip"'i checker reddetti:
:10.9-10.92: error: Bad value “shipping zip” for attribute “autocomplete” on element “input”: The string “zip” is not a valid autofill field name.
Doğrusu postal-code. Bilmen gereken birkaç token daha var: SMS (Short Message Service) ile gelen tek kullanımlık kod için one-time-code, yeni şifre alanı için new-password, mevcut şifre için current-password. Kupon alanında autocomplete="off" kullandım, çünkü orada tarayıcının daha önce girilmiş değerleri önermesi işe yaramıyor. off'u adres ve iletişim alanlarına koyma: kullanıcının en çok zaman kazandığı yeri kapatmış olursun.
Bir not: name ve id'nin autofill için bir anlamı yok, tarayıcılar sezgisel olarak bunlara da bakar ama güvenilir sinyal autocomplete. Alan adlarını İngilizce ve tutarlı tutmak yine de hem backend model binding'i hem de bu sezgileri kolaylaştırır.
inputmode, enterkeyhint ve autocorrect: Mobil Klavye
Input type'ın yetmediği yerde üç attribute mobil klavyeyi ince ayarlar. inputmode, doğrulamayı değiştirmeden hangi klavyenin açılacağını söyler: posta kodunda inputmode="numeric" rakam klavyesini açar, ama alan text kaldığı için baştaki sıfır korunur ve ok tuşu değeri değiştirmez. Diğer değerler arasında decimal (ondalıklı sayı), tel, email, url ve search var.
enterkeyhint, sanal klavyedeki Enter tuşunun etiketini belirler: done, go, next, search, send gibi. Kupon alanına enterkeyhint="done" verdim; tuş "Bitti" gibi bir etiketle görünür. Bu yalnız bir ipucu: Enter'a basınca ne olacağını değiştirmez, onu form'un yapısı belirler. inputmode ve enterkeyhint 2024'ten beri Baseline widely available.
En yenisi autocorrect. Safari onu yıllardır destekliyordu, Firefox 136'da ve en son Chrome 153'te geldi; Eylül 2026'da Baseline newly available oldu. Hem Chrome 158'de hem WebKit 27.2'de 'autocorrect' in HTMLInputElement.prototype true dönüyor. Kupon kodu, kullanıcı adı, ISBN (International Standard Book Number) gibi sözlükte olmayan değerlerde autocorrect="off" ver; aksi hâlde mobil klavye BOOKS25 kuponunu bir kelimeye "düzeltebilir". Ad ve adres alanlarında kapatman gerekmez, ama yabancı isimlerin düzeltilmesinden şikâyet geliyorsa ilk bakılacak yer burası.
Bu attribute'ların mobil klavyedeki etkisini headless tarayıcıda göremiyorum; davranış işletim sisteminin klavyesine bağlı. Gördüğün şeyi doğrulamak için form'u gerçek bir telefonda aç ya da Chrome DevTools'un cihaz modu yerine gerçek cihaz kullan.
button'ın Varsayılan type'ı ve form Attribute'u
Tahmin sorusunun ikinci yarısı. type'ı yazılmamış bir button form'un içindeyse submit'tir; pitfalls.js çıktısının son satırı da bunu söylüyor: apply-coupon type: submit. Müşteri "Apply coupon"a bastığında kupon uygulanmıyor, sipariş form'u gönderiliyor: submit by #apply-coupon. Bir sonraki satır daha sinsi: kupon alanında Enter'a basmak da form'u gönderiyor ve submitter yine "Apply coupon". Tarayıcı Enter'da form'un ilk submit button'unu "basılmış" sayar; buna implicit submission denir.
Düzeltmenin ilk adımı her button'a type yazmak: form göndermeyen button type="button", gönderen type="submit". Ama checkout form'unda bu yetmiyor. Kupon alanında Enter'a basan kullanıcı kuponun uygulanmasını bekler, siparişin verilmesini değil. Kupon ayrı bir işlem, o yüzden ayrı bir form olmalı. Form'lar iç içe yazılamaz; çözüm, form attribute'u. Kupon form'u checkout form'unun dışında boş bir <form id="coupon-form"> olarak duruyor; kupon input'u ve "Apply coupon" button'u görsel olarak checkout'un içinde ama form="coupon-form" ile ona bağlı. Aynı attribute "Place order" button'unu da form'un dışına, sayfanın altına taşımamı sağladı.
checkout.js çıktısının ilk iki satırı sonucu gösteriyor: kupon alanında Enter kupon form'unu "Apply coupon" ile, şehir alanında Enter siparişi "Place order" ile gönderiyor. Son satırda form'un dışındaki "Place order" button'u da checkout form'unu gönderiyor.
Üretimde Dikkat
Autofill'i ölçmenin en iyi yolu gerçek bir tarayıcı profilinde kayıtlı bir adresle form'u açmak ve tek tıklamayla hangi alanların dolduğunu görmek. Dolmayan her alan, autocomplete token'ının eksik ya da yanlış olduğu yerdir. Bunu Bookstore'da her checkout değişikliğinde yapılan elle kontrol listesine koymak, analitikte "adres adımında terk" oranı yükseldiğinde aranacak ilk sebebi önceden kapatır.
Türkiye'ye özgü bir nokta: spec address-level1'i ülkenin en üst idari birimi, address-level2'yi şehir ya da ilçe düzeyi olarak tanımlıyor; Türkiye'de bu kabaca il ve ilçeye denk geliyor. Tarayıcıların kayıtlı adres modeli ülkeden ülkeye farklı olduğu için hangi alanın neyle dolduğunu Türkçe adresli bir tarayıcı profiliyle mutlaka dene. Posta kodunu sunucu tarafında da string sakla ve beş hane kuralını orada da uygula; HTML tarafındaki doğrulama bir sonraki yazıda.
Son olarak, ekip içinde bir "form alanı" component'i varsa (Angular'da bir component, Razor'da bir partial ya da Tag Helper), label, id, autocomplete ve inputmode'u o component'in zorunlu parametreleri yap. Bu kararları her form'da yeniden vermek yerine bir kez doğru vermek, yirmi form'luk bir uygulamada farkı belirler. Angular'ın form API'leri Angular serisinin form yazılarında.
Sık Düşülen Tuzaklar
Birincisi, label yerine placeholder. Yazmaya başlayınca kaybolur ve alanın ne istediği unutulur.
İkincisi, posta kodu, telefon ya da kart numarası için type="number". Baştaki sıfırı kaybeder, ok tuşu ve fare tekerleği değeri değiştirir. type="text" ve inputmode="numeric" kullan.
Üçüncüsü, type'ı yazılmamış button. Form içinde submit'tir; "Apply coupon", "Show password", "Add another address" gibi button'lar form'u gönderir.
Dördüncüsü, uydurma ya da eski autocomplete değerleri (zip, phone) ve adres alanlarında autocomplete="off". Biri autofill'i bozar, diğeri tamamen kapatır.
Beşincisi, ilgisiz işlemleri aynı form'a koymak. Kupon, bülten aboneliği ya da arama bir checkout form'unun içinde duruyorsa Enter tuşu yanlış şeyi gönderir; ayrı form ve form attribute'u kullan.
Küçük Bir Deneme
★☆☆ Yazıdaki checkout.html'i kaydet ve kayıtlı bir adresi olan kendi Chrome ya da Edge profilinde aç. E-posta alanına tıklayıp autofill önerisini seç ve hangi alanların dolduğuna bak. Sonra bir alanın autocomplete'ini sil ve farkı gör.
★★☆ Form'a "Billing address is the same as shipping" checkbox'ı ve onun altına bir fatura adresi fieldset'i ekle. Fatura alanlarının autocomplete token'larını billing ile yaz, Nu Html Checker'dan sıfır hatayla geçir ve accessibility tree'de iki ayrı "City" alanının hangi gruplarda göründüğünü kontrol et.
★★★ Kendi projendeki checkout ya da kayıt form'unu bu yazının tuzak listesiyle denetle: her alanın görünen bir label'ı var mı, rakamsal kimlikler number mı, her button'ın type'ı yazılmış mı, autocomplete token'ları spec'teki listeyle uyumlu mu? Bulduklarını checker çıktısı ve checkout.js benzeri bir Playwright script'iyle doğrula.
Form'un yapısı hazır. Bir sonraki yazıda ona kurallar ekliyoruz: required, pattern, min/max ve tarayıcının native validation'ı; kullanıcı daha dokunmadan alanları kırmızıya boyamamak için :user-invalid, ve "Şule" adını reddeden pattern tuzağı.