Bir portfolyo ya da kurumsal sitede ziyaretçiyle kurulan asıl temas genellikle bir formdur: iletişim formu, teklif formu, bülten kaydı. Bu formu doldurmakta zorlanan her ziyaretçi, kaybedilmiş bir fırsattır. Ekran okuyucu kullanan biri, yalnız klavyeyle gezinen biri ya da telefonda tek eliyle form dolduran biri için küçük ayrıntılar büyük fark yaratır.

İşin güzel yanı, erişilebilir bir form için karmaşık bir kütüphaneye ihtiyacınız yok. Doğru HTML öğeleri ve birkaç satır JavaScript yeterli. Bu yazıda bir iletişim formunu baştan sona birlikte kuracağız.

1. Her alanın bir etiketi olsun

En temel kural ve en sık çiğnenen kural: her form alanı bir <label> ile eşleşmeli. placeholder bir etiket değildir: yazmaya başlayınca kaybolur, çoğu zaman düşük kontrastlıdır ve bazı ekran okuyucular onu hiç okumaz.

<!-- Yanlış: etiket yok, yalnız placeholder -->
<input type="text" placeholder="Adınız">

<!-- Doğru: for ve id ile eşleşen etiket -->
<label for="ad">Adınız</label>
<input id="ad" name="ad" type="text">

Eşleşen etiketin bir faydası daha var: etikete tıklamak alanı seçer. Özellikle küçük onay kutularında ve telefonlarda dokunma alanı böylece büyür.

2. Doğru type, autocomplete ve inputmode

Alanın türünü doğru seçmek, telefonda doğru klavyenin açılmasını ve tarayıcının otomatik doldurmayı doğru yapmasını sağlar:

  • type="email" telefonda @ işaretli klavyeyi açar ve temel biçim kontrolünü ücretsiz verir.
  • type="tel" sayı tuş takımını açar.
  • autocomplete="name", "email", "tel", "organization" gibi değerler tarayıcının kayıtlı bilgileri doğru alana koymasını sağlar. Hareket kısıtlılığı olan kullanıcılar için bu, dakikalar demektir.
  • inputmode="numeric", posta kodu gibi sayısal ama "sayı" olmayan alanlarda kullanılır.

3. Gruplayın ve açıklayın

Birbiriyle ilgili seçenekler (radyo düğmeleri, onay kutuları) <fieldset> içinde toplanmalı ve <legend> ile adlandırılmalı. Ekran okuyucu böylece "Konu, Yeni proje, radyo düğmesi, 1/2" gibi anlamlı bir bütün okur.

Bir alana ek açıklama gerekiyorsa (ör. "E-postanız yalnız dönüş için kullanılır") metni aria-describedby ile alana bağlayın. Alan odaklandığında açıklama da okunur.

4. Örnek form

<form id="iletisim" action="/iletisim" method="post">
  <input type="hidden" name="csrf" value="...">

  <div class="alan">
    <label for="ad">Adınız <span aria-hidden="true">*</span></label>
    <input id="ad" name="ad" type="text" autocomplete="name" required>
  </div>

  <div class="alan">
    <label for="eposta">E-posta <span aria-hidden="true">*</span></label>
    <input id="eposta" name="eposta" type="email" autocomplete="email"
           aria-describedby="eposta-ipucu" required>
    <p id="eposta-ipucu" class="ipucu">Yalnızca size dönüş yapmak için kullanılır.</p>
  </div>

  <fieldset>
    <legend>Konu</legend>
    <label><input type="radio" name="konu" value="proje" checked> Yeni proje</label>
    <label><input type="radio" name="konu" value="is"> İş teklifi</label>
  </fieldset>

  <div class="alan">
    <label for="mesaj">Mesajınız <span aria-hidden="true">*</span></label>
    <textarea id="mesaj" name="mesaj" rows="6" minlength="20" required></textarea>
  </div>

  <!-- Botlar için tuzak alan: insanlar görmez, klavyeyle de ulaşamaz -->
  <div class="tuzak" aria-hidden="true">
    <label for="web">Web siteniz</label>
    <input id="web" name="web" type="text" tabindex="-1" autocomplete="off">
  </div>

  <p class="not">* ile işaretli alanlar zorunludur.</p>
  <button type="submit">Gönder</button>
  <p id="durum" role="status" aria-live="polite"></p>
</form>

Yıldız işaretleri aria-hidden ile ekran okuyuculardan gizlendi; çünkü alanların zorunlu olduğunu required özniteliği zaten bildiriyor. Görsel kullanıcılar için ise formun altında yıldızın anlamı yazılı olarak açıklanıyor.

5. Hata mesajları: görünür, okunur, alana bağlı

Tarayıcının yerleşik doğrulama baloncukları hızlı bir başlangıçtır ama kısa sürede kaybolur, biçimlendirilemez ve her tarayıcıda farklı görünür. Daha iyi bir deneyim için doğrulamayı yine tarayıcının Constraint Validation API'sine bırakıp mesajları kendimiz gösterebiliriz. Bunun için üç şey gerekiyor:

  • Hatalı alana aria-invalid="true" eklemek.
  • Hata metnini alanın hemen altına yazıp aria-describedby ile alana bağlamak.
  • Gönderimde ilk hatalı alana odaklanmak ve kaç alanın düzeltilmesi gerektiğini canlı bölgede duyurmak.
const form = document.getElementById('iletisim');
const durum = document.getElementById('durum');
form.noValidate = true; // JavaScript çalışıyorsa mesajları biz gösteririz

const mesajlar = {
  valueMissing: () => 'Bu alan zorunludur.',
  typeMismatch: () => 'Geçerli bir e-posta adresi yazın.',
  tooShort: (alan) => `En az ${alan.minLength} karakter yazın.`,
};

function bagla(alan, kimlik, ekle) {
  const liste = (alan.getAttribute('aria-describedby') || '').split(' ').filter((k) => k && k !== kimlik);
  if (ekle) liste.push(kimlik);
  liste.length ? alan.setAttribute('aria-describedby', liste.join(' ')) : alan.removeAttribute('aria-describedby');
}

function denetle(alan) {
  const kimlik = alan.id + '-hata';
  let kutu = document.getElementById(kimlik);
  if (alan.validity.valid) {
    alan.removeAttribute('aria-invalid');
    kutu?.remove();
    bagla(alan, kimlik, false);
    return true;
  }
  const tur = Object.keys(mesajlar).find((k) => alan.validity[k]);
  if (!kutu) {
    kutu = document.createElement('p');
    kutu.id = kimlik;
    kutu.className = 'hata';
    alan.insertAdjacentElement('afterend', kutu);
  }
  kutu.textContent = tur ? mesajlar[tur](alan) : alan.validationMessage;
  alan.setAttribute('aria-invalid', 'true');
  bagla(alan, kimlik, true);
  return false;
}

Ne zaman doğrulayacağınız da önemli. Kullanıcı daha yazarken "geçersiz e-posta" diye bağırmak rahatsız edicidir. En dengeli yöntem, alan terk edildiğinde (yalnız bir şey yazılmışsa) ve gönderimde denetlemektir:

form.addEventListener('focusout', (e) => {
  if (e.target.willValidate && e.target.value !== '') denetle(e.target);
});

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const alanlar = [...form.elements].filter((a) => a.willValidate);
  const hatali = alanlar.filter((a) => !denetle(a));
  if (hatali.length) {
    durum.textContent = `${hatali.length} alanda düzeltme gerekiyor.`;
    hatali[0].focus();
    return;
  }

  const dugme = form.querySelector('button[type="submit"]');
  dugme.disabled = true;
  durum.textContent = 'Gönderiliyor…';
  try {
    const yanit = await fetch(form.action, { method: 'POST', body: new FormData(form) });
    if (!yanit.ok) throw new Error(String(yanit.status));
    form.reset();
    durum.textContent = 'Teşekkürler, mesajınız ulaştı.';
  } catch {
    durum.textContent = 'Mesaj gönderilemedi. Lütfen biraz sonra tekrar deneyin.';
  } finally {
    dugme.disabled = false;
  }
});

novalidate özniteliğini HTML'e yazmak yerine JavaScript ile (form.noValidate = true) eklemek, betik yüklenemediğinde tarayıcının kendi doğrulamasının yine çalışmasını sağlar. Buna aşamalı geliştirme (progressive enhancement) denir.

Tarayıcıdaki doğrulama yalnız kullanıcıya kolaylıktır. Aynı kuralların hepsi sunucuda da kontrol edilmelidir; tarayıcıyı atlayıp doğrudan istek göndermek çok kolaydır.

6. Odak görünür kalsın

Klavyeyle gezinen biri için odak çerçevesi, fare imlecinin ta kendisidir. Tasarım kaygısıyla outline: none yazmak formu klavye kullanıcıları için kullanılamaz hâle getirir. Bunun yerine :focus-visible kullanın: odak çerçevesi yalnız klavyeyle gezinirken görünür, fareyle tıklayınca çıkmaz.

:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}
.hata { color: #b91c1c; font-size: .9rem; margin: .25rem 0 0; }
[aria-invalid="true"] { border-color: #b91c1c; }

/* Tuzak alan: görsel olarak gizli ama display:none değil */
.tuzak { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }

Hata durumunu yalnız kırmızı renkle anlatmayın; renk körlüğü olan kullanıcılar farkı görmeyebilir. Yanındaki metin mesajı bu yüzden zorunludur.

7. Spam için CAPTCHA yerine tuzak alan

CAPTCHA'lar botları durdururken insanları da yorar ve görme engelli kullanıcılar için ciddi bir engel olabilir. Küçük siteler için çoğu zaman yeterli bir alternatif, örnekteki gibi bir tuzak alandır (honeypot). İnsanlar bu alanı görmez ve tabindex="-1" sayesinde klavyeyle de ulaşamaz; formu kör doldurup gönderen botlar ise genellikle doldurur. Sunucuda kontrolü tek satırdır:

if (($_POST['web'] ?? '') !== '') {
    // Bot: hata vermeden başarılı gibi yanıt ver, mesajı kaydetme
    http_response_code(200);
    exit;
}

Buna IP başına basit bir hız sınırı da eklerseniz otomatik gönderimlerin büyük kısmı durur.

Peki bunun SEO ile ilgisi ne?

Dürüst olmak gerekirse, formun kendisi bir sıralama faktörü değil. Ama erişilebilir bir formu oluşturan alışkanlıklar, arama motorlarının ve yapay zekâ tabanlı arama araçlarının sayfanızı anlamasını kolaylaştıran alışkanlıklarla aynı: anlamlı HTML öğeleri, açık etiketler, düzgün başlık sırası ve JavaScript'e bağımlı olmadan çalışan içerik. İletişim sayfanızda formun yanında adresinizi, e-postanızı ve çalışma şeklinizi düz metin olarak yazmak hem insanlara hem botlara aynı bilgiyi verir.

Dolaylı etkiler de var: telefonda kolay doldurulan, hatasını açıkça söyleyen bir form daha az terk edilir. Sayfa deneyimi iyileştikçe ziyaretçi sitede daha uzun kalır.

Kontrol listesi

  • Her alanın eşleşen bir <label> etiketi var mı?
  • type, autocomplete ve gerekiyorsa inputmode doğru mu?
  • İlgili seçenekler fieldset ve legend ile gruplandı mı?
  • Hata mesajları metin olarak görünüyor, aria-invalid ve aria-describedby ile alana bağlı mı?
  • Gönderim sonucu canlı bölgede (role="status") duyuruluyor mu?
  • Odak çerçevesi görünür mü, form yalnız klavyeyle doldurulabiliyor mu?
  • Bütün kurallar sunucuda da kontrol ediliyor mu?

Formunuzu bir kez de ekran okuyucuyla (Windows'ta NVDA, macOS'ta VoiceOver) ve yalnız klavyeyle doldurmayı deneyin. Beş dakikalık bu test, hiçbir otomatik aracın bulamayacağı sorunları ortaya çıkarır.