Mert Özen Her Satırda Daha İleri
Frontend HTML5 ve Semantic HTML — #05

HTML Form'ları 2: Native Validation, Constraint Attribute'ları, :user-invalid ve Server ile İş Bölümü

Mert Özen 14 Kas 2026 13 dk 3 görüntülenme
HTML Form'ları 2: Native Validation, Constraint Attribute'ları, :user-invalid ve Server ile İş Bölümü

Tarayıcı required, pattern, min/max ve input type kurallarını JavaScript'siz uygular, ama bu doğrulama kullanıcı deneyimi içindir, güvenlik değil; hata stili için :invalid yerine kullanıcı dokunduktan sonra eşleşen :user-invalid kullanılır. Bu yazıda checkout form'unda native mesajların tarayıcı diline bağlı olduğunu, formnovalidate'i, "Şule"yi reddeden pattern'i ve v flag tuzağını, field-sizing ile büyüyen textarea'yı Chromium ve WebKit'te ö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 ad soyad alanı için birisi "yalnız harf ve boşluk olsun" diyerek pattern="[a-zA-Z ]+" yazmış. Ankara'dan Şule Yılmaz siparişini tamamlayabilir mi? İkinci soru: form'daki zorunlu alanları kırmızı çerçeveyle göstermek için CSS'te (Cascading Style Sheets) input:invalid kullanılmış. Müşteri sayfayı açtığı anda, henüz hiçbir şeye dokunmadan kaç alan kırmızı görünür?

Önceki yazıda checkout form'unun yapısını kurduk. Bu yazıda ona kurallar ekliyoruz: tarayıcının JavaScript'siz yaptığı native validation, bu kuralları kullanıcıyı rahatsız etmeden göstermenin modern yolu ve tarayıcının nerede durup sunucunun nerede devraldığı. Doğrulama mesajlarını JavaScript ile özelleştirmek, yani Constraint Validation API (Application Programming Interface), JavaScript Orta serisinin "İleri Formlar" yazısında; burada yalnız HTML ve CSS var.

Constraint Attribute'ları

Checkout form'unun doğrulama kuralları eklenmiş hâli:

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>Checkout | Bookstore</title>
    <style>
        input:user-invalid, textarea:user-invalid {
            border-color: crimson;
        }
        input:user-valid {
            border-color: seagreen;
        }
        textarea {
            field-sizing: content;
            min-block-size: 3lh;
        }
    </style>
</head>
<body>
    <form id="checkout" action="/orders" method="post">
        <label for="full-name">Full name</label>
        <input type="text" id="full-name" name="fullName" autocomplete="shipping name"
               required minlength="2" maxlength="80" pattern="[\p{L}\p{M} '\-]+">
        <label for="email">Email</label>
        <input type="email" id="email" name="email" autocomplete="email" required>
        <label for="postal-code">Postal code</label>
        <input type="text" id="postal-code" name="postalCode" autocomplete="shipping postal-code"
               inputmode="numeric" required pattern="[0-9]{5}">
        <label for="quantity">Quantity</label>
        <input type="number" id="quantity" name="quantity" value="1" min="1" max="10" step="1">
        <label for="gift-note">Gift note</label>
        <textarea id="gift-note" name="giftNote" maxlength="300"></textarea>
        <button type="submit" formnovalidate formaction="/orders/draft">Save draft</button>
        <button type="submit">Place order</button>
    </form>
</body>
</html>

Kurallar attribute olarak duruyor. required alanın boş kalamayacağını söyler. minlength ve maxlength metnin uzunluğunu sınırlar; maxlength kullanıcının sınırdan fazla yazmasını zaten engeller, minlength ise yalnız kullanıcı alanı düzenlediyse kontrol edilir. pattern değerin bir regular expression ile eşleşmesini ister. min, max ve step sayı ve tarih alanlarının aralığını ve adımını belirler: adet 1 ile 10 arasında ve tam sayı olmalı.

Bunlara ek olarak input type'ın kendisi de bir kuraldır. type="email" değerin e-posta biçiminde olmasını, type="url" mutlak bir URL (Uniform Resource Locator) olmasını ister; bunun için ayrıca pattern yazmana gerek yok. Kurallardan herhangi biri ihlal edildiğinde alan geçersiz sayılır ve form gönderilmez.

Nu Html Checker bu form için tek bir hata veriyor ve o da HTML'le ilgili değil:

:14.27-14.33: error: CSS: “field-sizing”: Property “field-sizing” doesn't exist.

Checker'ın CSS denetleyicisi, Baseline'a Haziran 2026'da giren field-sizing'i henüz tanımıyor. Buna Üretimde Dikkat bölümünde döneceğim.

Tarayıcı Ne Diyor? Native Mesajlar

Her alana birkaç değer yazıp tarayıcının kararını ve mesajını okuyan bir script ile aynı sayfayı arayüz dili İngilizce ve Türkçe olan iki Chromium'da çalıştırdım. Sayfanın lang'i her iki koşuda da en:

--- English UI
#full-name = "Şule Yılmaz" -> valid
#full-name = "Zoë O'Neil-Åberg" -> valid
#full-name = "Ada 2" -> Please match the requested format.
#postal-code = "06100" -> valid
#postal-code = "061000" -> Please match the requested format.
#email = "ada@" -> Please enter a part following '@'. 'ada@' is incomplete.
#quantity = "12" -> Value must be less than or equal to 10.
#quantity = "1.5" -> Please enter a valid value. The two nearest valid values are 1 and 2.
--- Turkish UI
#full-name = "Şule Yılmaz" -> valid
#full-name = "Zoë O'Neil-Åberg" -> valid
#full-name = "Ada 2" -> Lütfen istenen biçimi eşleştirin.
#postal-code = "06100" -> valid
#postal-code = "061000" -> Lütfen istenen biçimi eşleştirin.
#email = "ada@" -> Lütfen "@" işaretinden sonra gelen kısmı ekleyin. "ada@", tam bir adres değil.
#quantity = "12" -> Değer 10 veya daha küçük olmalıdır.
#quantity = "1.5" -> Lütfen geçerli bir değer girin. En yakın iki geçerli değer şunlardır: 1 ve 2.

İlk iki satır tahmin sorusuna bir ipucu veriyor: bizim form'daki pattern "Şule Yılmaz"ı da "Zoë O'Neil-Åberg"i de kabul ediyor; nedenine pattern bölümünde geleceğiz. Asıl dikkat çeken, mesajların dili: sayfa İngilizce olduğu hâlde Türkçe arayüzlü tarayıcı Türkçe mesaj gösteriyor. Native mesajlar sayfanın değil, tarayıcının dilini konuşur. Türkçe bir sitede İngilizce arayüzlü tarayıcı kullanan bir müşteri İngilizce mesaj görür ve sen bunu HTML ile değiştiremezsin.

Mesajların içeriği de senin kontrolünde değil. "Lütfen istenen biçimi eşleştirin." posta kodu için yeterince açık değil; kullanıcı neyi yanlış yaptığını bilmiyor. Bunun için en basit HTML çözümü, alanın yanına beklenen biçimi baştan yazmak: label'ın altında "5 haneli, ör. 06100" gibi bir yardım metni. Mesajı tamamen değiştirmek setCustomValidity() ile JavaScript'in işi. Baloncuğun görünümü de tarayıcıya ait, CSS ile stillenemez.

Submit Anında: invalid Event'leri, Odak ve formnovalidate

Kullanıcı "Place order"a bastığında tarayıcı form'u göndermeden önce her alanı kontrol eder. Geçersiz her alan için bir invalid event'i atılır, ilk geçersiz alana odaklanılır ve onun mesajı gösterilir. Taslak kaydetmek için ise doğrulamayı atlamak isteriz: yarım bir form'u kaydetmek tam da bunun için var. Bunu ölçen script:

const { chromium, webkit } = require('playwright');
const fs = require('fs');

const html = fs.readFileSync('checkout.html', 'utf8');

(async () => {
  for (const engine of [chromium, webkit]) {
    const browser = await engine.launch();
    const page = await browser.newPage();
    await page.setContent(html);
    page.on('console', m => console.log(`${engine.name()}: ${m.text()}`));
    await page.evaluate(() => {
      document.forms.checkout.addEventListener('submit', e => {
        e.preventDefault();
        console.log(`submitted by "${e.submitter.textContent}", formaction=${e.submitter.getAttribute("formaction") ?? "(form action)"}`);
      });
      document.forms.checkout.addEventListener('invalid', e => console.log(`invalid: #${e.target.id}`), true);
    });
    await page.click('text=Place order');
    await page.click('text=Save draft');

    // field-sizing: content ile textarea içeriğe göre büyür
    const note = page.locator('#gift-note');
    const before = await note.evaluate(el => el.offsetHeight);
    await note.fill('Happy birthday!\nEnjoy the book.\nSee you soon.\nAda\n\nP.S. Chapter 22.');
    console.log(`${engine.name()}: gift-note height ${before}px -> ${await note.evaluate(el => el.offsetHeight)}px`);
    await browser.close();
  }
})();
chromium: invalid: #full-name
chromium: invalid: #email
chromium: invalid: #postal-code
chromium: submitted by "Save draft", formaction=/orders/draft
chromium: gift-note height 51px -> 96px
webkit: invalid: #full-name
webkit: invalid: #email
webkit: invalid: #postal-code
webkit: submitted by "Save draft", formaction=/orders/draft
webkit: gift-note height 45px -> 84px

"Place order" üç invalid event'i üretti ve submit event'i hiç gelmedi. "Save draft" ise aynı geçersiz form'u gönderdi, çünkü üzerinde formnovalidate var; formaction ile de form'u farklı bir endpoint'e yönlendirdi. Form'un tamamında doğrulamayı kapatmak için form element'ine novalidate yazılır; tek bir button için doğrulamayı atlamak için o button'a formnovalidate. Taslak, "geri", "iptal" gibi işlemler ikincisinin tipik kullanımı.

Çıktının son satırlarına field-sizing bölümünde döneceğim.

:invalid Değil, :user-invalid

Tahmin sorusunun ikinci yarısı. :invalid pseudo-class'ı alanın o anki durumunu yansıtır; boş bir required alan sayfa açıldığı anda geçersizdir. Yani input:invalid ile kırmızı çerçeve veren bir form, müşteri daha hiçbir şey yazmadan "hata yaptın" der. Uzun yıllar bu sorun JavaScript ile "touched" durumu takip edilerek çözüldü. Artık CSS'te bunun için iki pseudo-class var: :user-invalid ve :user-valid. İkisi de yalnız kullanıcı alanla etkileşime girdikten ya da form'u göndermeyi denedikten sonra eşleşir. Farkı ölçen script:

const { chromium, webkit } = require('playwright');
const fs = require('fs');

const html = fs.readFileSync('checkout.html', 'utf8');

(async () => {
  for (const engine of [chromium, webkit]) {
    const browser = await engine.launch();
    const page = await browser.newPage();
    await page.setContent(html);
    const count = sel => page.locator(sel).count();
    console.log(`--- ${engine.name()} ${browser.version()}`);
    console.log('on load      :invalid', await count(':invalid'), '| :user-invalid', await count(':user-invalid'));

    await page.fill('#email', 'ada@');
    await page.locator('#email').blur();
    console.log('after email  :invalid', await count(':invalid'), '| :user-invalid', await count(':user-invalid'));

    await page.click('text=Place order');
    console.log('after submit :invalid', await count(':invalid'), '| :user-invalid', await count(':user-invalid'),
      '| focused:', await page.evaluate(() => document.activeElement.id));
    await browser.close();
  }
})();
--- chromium 158.0.8127.18
on load      :invalid 4 | :user-invalid 0
after email  :invalid 4 | :user-invalid 1
after submit :invalid 4 | :user-invalid 3 | focused: full-name
--- webkit 27.2
on load      :invalid 4 | :user-invalid 0
after email  :invalid 4 | :user-invalid 1
after submit :invalid 4 | :user-invalid 3 | focused: full-name

Sayfa açılırken :invalid dört element'le eşleşiyor: üç zorunlu alan ve form'un kendisi, çünkü içinde geçersiz alan olan form da :invalid'dir. Tahmin sorusunun cevabı bu: dokunmadan üç alan kırmızı. :user-invalid ise sıfır. Kullanıcı e-postaya ada@ yazıp alandan çıkınca yalnız o alan eşleşiyor; diğer boş alanlar hâlâ sessiz. "Place order"a basıldıktan sonra üç alanın üçü de eşleşiyor ve odak ilk geçersiz alana, ada gidiyor. Chromium ve WebKit aynı sonucu veriyor; :user-valid ve :user-invalid Kasım 2023'te Baseline newly available oldu ve Mayıs 2026'da widely available'a geçti, bugün fallback'siz kullanılabilir.

Form'daki CSS bu yüzden :invalid'e değil :user-invalid'e kırmızı, :user-valid'e yeşil çerçeve veriyor. Bir öneri: rengi tek sinyal yapma. Renk körlüğü olan kullanıcı için kırmızı ile yeşil çerçeve aynı görünebilir; hatayı metinle de göster.

pattern Tuzakları: Türkçe Karakterler, v Flag ve Gereksiz ^/$

pattern'in nasıl çalıştığını görmek için farklı pattern'leri farklı değerlerle deneyen küçük bir script yazdım:

const { chromium } = require('playwright');

const tests = [
  ['[a-zA-Z ]+', 'Şule Yılmaz'],
  ['\\w+( \\w+)*', 'Şule Yılmaz'],
  ['[a-z-]+', 'not-a-name 123'],
  ['[\\p{L}\\p{M} \'\\-]+', 'Şule Yılmaz'],
  ['^[0-9]{5}$', '06100'],
  ['[0-9]{5}', '061000'],
];

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  page.on('console', m => console.log('console:', m.text()));
  for (const [pattern, value] of tests) {
    const valid = await page.evaluate(([p, v]) => {
      const input = document.createElement('input');
      input.pattern = p;
      input.value = v;
      document.body.append(input);
      return input.validity.valid;
    }, [pattern, value]);
    console.log(`pattern="${pattern}" value="${value}" -> ${valid ? 'valid' : 'patternMismatch'}`);
  }
  await browser.close();
})();
pattern="[a-zA-Z ]+" value="Şule Yılmaz" -> patternMismatch
pattern="\w+( \w+)*" value="Şule Yılmaz" -> patternMismatch
console: Pattern attribute value [a-z-]+ is not a valid regular expression: Uncaught SyntaxError: Failed to read the 'valid' property from 'ValidityState': Invalid regular expression: /[a-z-]+/v: Invalid character class
pattern="[a-z-]+" value="not-a-name 123" -> valid
pattern="[\p{L}\p{M} '\-]+" value="Şule Yılmaz" -> valid
pattern="^[0-9]{5}$" value="06100" -> valid
pattern="[0-9]{5}" value="061000" -> patternMismatch

İlk satır tahmin sorusunun cevabı: [a-zA-Z ]+ "Şule Yılmaz"ı reddediyor, çünkü "Ş" ve "ı" ASCII (American Standard Code for Information Interchange) aralığında değil. \w da çözüm değil; JavaScript'te yalnız ASCII harf, rakam ve alt çizgiyi kapsar. Doğru yol Unicode property escape'leri: \p{L} her dildeki harfleri, \p{M} da harfe eklenen birleşik işaretleri kapsar. Bizim form'daki [\p{L}\p{M} '\-]+ Türkçe, Almanca, İskandinav isimlerini, kesme işaretini ve tireyi kabul ediyor, ama "Ada 2"deki rakamı reddediyor. Ad soyad gibi bir alanda pattern'in faydası zaten sınırlı; dünyadaki isimler senin tahmin edebileceğinden çeşitlidir. Emin değilsen bu alanda pattern'i hiç kullanmamak da meşru bir karar.

Üçüncü satırdaki konsol mesajı daha az bilinen bir kuralı gösteriyor. Spec, pattern'i JavaScript'in v flag'iyle derliyor; Chromium'un hata mesajındaki /[a-z-]+/v bunu açıkça yazıyor. v modunda character class içindeki tek başına - gibi bazı karakterlerin escape edilmesi gerekiyor. Geçersiz bir pattern'de tarayıcı hata vermiyor; konsola bir uyarı yazıp pattern'i yok sayıyor ve "not-a-name 123" gibi bir değer geçerli sayılıyor. Nu Html Checker da bu hatayı yakalamıyor. Bu yüzden form'daki pattern'de tire \- olarak yazılı.

Son iki satır da ^ ve $'ın gereksiz olduğunu gösteriyor. Tarayıcı pattern'i her zaman değerin tamamıyla eşleşecek şekilde başına ve sonuna çapa koyarak derler; [0-9]{5} altı haneli 061000'ı reddediyor. ^...$ yazmak zarar vermez ama bir şey de katmaz.

field-sizing ile İçeriği Kadar Büyüyen textarea

Hediye notu alanı küçük başlıyor ama müşteri uzun bir not yazarsa büyümeli. Yıllarca bunun için input event'ini dinleyip scrollHeight'e göre yükseklik ayarlayan JavaScript yazdık. Artık tek bir CSS property'si var: field-sizing: content. Form'daki CSS'te min-block-size: 3lh ile üç satırlık bir alt sınır da verdim. draft.js çıktısının son satırı farkı gösteriyor: altı satırlık not yazılınca alan Chromium'da 51 pikselden 96 piksele, WebKit'te 45 pikselden 84 piksele büyüdü. field-sizing Haziran 2026'da Baseline newly available oldu; desteklemeyen eski bir tarayıcıda alan sabit boyutta kalır ve kaydırma çubuğu çıkar, yani güvenli bir progressive enhancement. Modern CSS'in geri kalanı ilerideki CSS3: Flexbox, Grid ve Modern CSS serisinde.

Client Validation Güvenlik Değildir: Sunucu ile İş Bölümü

Yukarıdaki "Save draft" deneyi önemli bir şeyi de kanıtladı: tek bir attribute ile bütün kurallar atlanabiliyor. Kullanıcı aynısını DevTools'ta form'a novalidate ekleyerek ya da isteği curl ile göndererek yapabilir. HTML'deki doğrulama kullanıcı deneyimi içindir: hatayı sunucuya gitmeden, anında gösterir. Güvenlik ve veri bütünlüğü için her kuralın sunucuda da uygulanması gerekir. ASP.NET Core'da bu, DataAnnotations ve ModelState ile yapılır; ayrıntısı ASP.NET Core MVC (Model-View-Controller) ve Razor Pages serisinin "Server-Side ve Client-Side Validation" yazısında.

İş bölümü için pratik bir kural: biçim kuralları (zorunlu, uzunluk, aralık, pattern) iki tarafta da olsun, HTML'de anında geri bildirim için, sunucuda güvenlik için. İş kuralları (kupon geçerli mi, stok yeterli mi, posta kodu teslimat bölgesinde mi) yalnız sunucuda yaşar; HTML bunları bilemez.

Framework'ler bu dengeyi kendi yollarıyla kurar. Angular, form'larına varsayılan olarak novalidate ekler ve doğrulamayı kendi validator'larıyla yapar; native davranışı korumak için ngNativeValidate gerekir. ASP.NET Core'un unobtrusive validation'ı da jQuery Validation ile çalışır ve o da form'a novalidate ekler. Yani framework'lü bir projede bu yazıdaki native mesajları görmüyorsan sebep büyük ihtimalle bu; ama constraint attribute'ları ve :user-invalid hâlâ işe yarar. Angular'ın form validation'ı Angular serisinin form yazılarında.

Üretimde Dikkat

Nu Html Checker'ı CI'a (Continuous Integration) aldıysan field-sizing hatası bir gün build'ini kırar. Bu, yeni CSS özelliklerinde tekrarlanacak bir durum: checker'ın CSS denetleyicisi tarayıcıların gerisinden gelir. İki çözüm var: stilleri harici bir CSS dosyasına taşımak (checker yalnız HTML'i denetler, harici CSS'e bakmaz) ya da checker'ın --filterpattern seçeneğiyle bilinen bu mesajı bir regular expression ile filtrelemek. İkincisini seçersen filtreyi dar tut; bütün CSS hatalarını susturma.

Doğrulama hatalarını ölç. Kullanıcıların hangi alanda en çok takıldığını bilmek, form'u iyileştirmenin en doğrudan yolu. invalid event'i her geçersiz alan için atıldığı için bunu analitiğe göndermek birkaç satır JavaScript; posta kodu alanında yüksek bir oran görüyorsan, sorun büyük ihtimalle kullanıcıda değil, kuralda ya da yardım metnindedir.

Son olarak, aynı kuralı iki yerde tutmanın bedeli tutarsızlıktır. HTML'de maxlength="80", sunucuda [StringLength(60)] varsa kullanıcı 70 karakterlik bir adı yazabilir ama kaydedemez. ASP.NET Core Tag Helper'ları DataAnnotations'tan data-val-* attribute'larını kendisi üretir; ne ürettiklerini render edilmiş HTML'de kontrol et ve kuralları tek bir kaynaktan türetmeye çalış.

Sık Düşülen Tuzaklar

Birincisi, isim alanında [a-zA-Z] ya da \w. Türkçe ve diğer dillerdeki harfleri reddeder; \p{L} kullan ya da pattern'i hiç koyma.

İkincisi, :invalid ile hata stili vermek. Sayfa açılır açılmaz zorunlu alanlar kırmızı görünür; :user-invalid kullan.

Üçüncüsü, v flag'iyle geçersiz olan bir pattern yazmak. Tarayıcı hata vermez, pattern'i sessizce yok sayar; konsolu kontrol et ve tireyi \- olarak yaz.

Dördüncüsü, native mesajın senin dilinde ve senin tasarımında görüneceğini varsaymak. Mesaj tarayıcının dilindedir ve baloncuk stillenemez; beklenen biçimi label'ın yanında baştan yaz.

Beşincisi, HTML doğrulamasını güvenlik sanmak. Tek bir novalidate ile atlanır; her kural sunucuda da olmalı.

Küçük Bir Deneme

★☆☆ Yazıdaki checkout.html'i Chrome'da aç. Hiçbir şeye dokunmadan "Place order"a bas ve hangi alanın odak aldığını, hangi mesajın çıktığını not et. Sonra CSS'teki :user-invalid'i :invalid yap, sayfayı yenile ve farkı gör.

★★☆ Ad soyad alanının pattern'ini önce [a-zA-Z ]+, sonra [\p{L} -]+ yap ve her ikisinde "Şule Yılmaz" ile "Ayşe Nur-Kaya" yaz. İkinci pattern neden hiç çalışmıyor? DevTools Console'daki uyarıyı oku ve pattern'i v flag kurallarına göre düzelt.

★★★ Kendi projendeki bir form'un HTML kurallarını sunucu tarafındaki kurallarla karşılaştır: her required, maxlength, min/max ve pattern'in sunucuda bir karşılığı var mı, değerler aynı mı? Bulduğun her tutarsızlık için hangisinin doğru olduğuna karar ver ve ikisini aynı kaynaktan üretmenin yolunu düşün.

Form'lar burada bitiyor. Bir sonraki yazıda tarayıcının JavaScript'siz verdiği etkileşimlere geçiyoruz: details/summary ile accordion, dialog ile modal, popover attribute'u ve Invoker Commands ile "Kitabı sil" onay penceresini tek satır JavaScript yazmadan açıp kapatmak.