Pengumuman Error yang Dapat Diakses

Masalah

Validasi HTML5 standar memberikan masukan visual (melalui :invalid atau :user-invalid), tetapi tidak otomatis disinkronkan dengan atribut aksesibilitas seperti aria-invalid.

Jika Anda menggunakan gaya :invalid standar, pembaca layar mungkin akan mengucapkan "Entri tidak valid" saat pengguna berpindah ke kolom wajib diisi yang saat ini kosong. Hal ini menciptakan pengalaman yang mengganggu bagi pengguna yang menggunakan teknologi pendukung, karena error diumumkan sebelum interaksi terjadi.

Solusi

Kita ingin status terprogram (aria-invalid="true") diterapkan pada saat yang sama persis dengan penerapan status visual (:user-invalid). Karena :user-invalid mengandalkan flag "user-interacted" internal browser, kita dapat menggunakan JavaScript untuk memeriksa apakah pemilih ini cocok selama peristiwa interaksi standar.

Lihat MDN aria-invalid untuk mengetahui detail selengkapnya.

Strategi Penerapan

  1. Lapisan Visual: Gunakan :user-invalid CSS untuk menampilkan batas/ikon.
  2. Lapisan Aksesibilitas: Gunakan aria-invalid dan aria-errormessage untuk mengomunikasikan status ke Teknologi Pendukung (AT).
  3. Lapisan Aksesibilitas & Visual Bridge: Buat utilitas JavaScript ringan yang memproses peristiwa blur dan input, memeriksa apakah elemen cocok dengan :user-invalid, dan memperbarui atribut ARIA sebagaimana mestinya.

Panduan Penerapan

1. Struktur HTML

Tautkan input Anda ke pesan errornya menggunakan aria-errormessage (atau aria-describedby untuk dukungan yang lebih luas).

<form>
  <div class="field">
    <label for="email">Email</label>
    <input 
      type="email" 
      id="email" 
      required 
      aria-errormessage="email-error"
    >
    <span id="email-error" class="error-msg">
      Please enter a valid email address.
    </span>
  </div>
</form>

2. CSS

Kontrol visibilitas pesan error menggunakan pseudo-class native :user-invalid.

.error-msg {
  display: none;
  color: #d93025;
}

/* Show error message when input is user-invalid */
input:user-invalid ~ .error-msg {
  display: block;
}

/* Optional: Visual cues on the input itself */
input:user-invalid {
  border-color: #d93025;
}

3. JavaScript

Karena tidak ada peristiwa "UserInvalidChanged", kaitkan ke peristiwa formulir standar untuk memeriksa status.

const updateAriaState = (event) => {
  const input = event.target;
  if (!input.matches?.('input, textarea, select')) return;

  // Check if the browser currently considers this input "user-invalid"
  const isUserInvalid = input.matches(':user-invalid');
  
  if (isUserInvalid) {
    input.setAttribute('aria-invalid', 'true');
  } else {
    input.removeAttribute('aria-invalid');
  }
};

// Listen on the document to handle dynamically added fields.
// 'blur' and 'focus' do not bubble, so we must use the capture phase (true).
document.addEventListener('blur', updateAriaState, true);
document.addEventListener('focus', updateAriaState, true);

// Also update on input if we've already shown the error, 
// so the error clears immediately when fixed.
document.addEventListener('input', (event) => {
  const input = event.target;
  if (!input.matches?.('input, textarea, select')) return;

  const hasAriaInvalid = input.hasAttribute('aria-invalid');
  const ariaInvalid = input.getAttribute('aria-invalid');
  if (hasAriaInvalid && ariaInvalid === 'true') {
    updateAriaState(event);
  }
});

Fallback & Dukungan Browser

Deteksi Fitur

Anda dapat memeriksa dukungan di CSS dan JavaScript.

Pemeriksaan JavaScript:

if (!CSS.supports('selector(:user-invalid)')) {
  // Fallback logic here
}

CSS untuk Penggantian

Untuk memastikan logika penggantian Anda secara visual tidak dapat dibedakan dari perilaku native, Anda harus menerapkan gaya error ke class pseudo dan class penggantian.

/* Apply error styles to both native selector and fallback class */
input:user-invalid,
input.user-invalid-fallback {
  border-color: #d93025;
  background-color: #fce8e6;
}

/* Show error message for both cases */
input:user-invalid ~ .error-msg,
input.user-invalid-fallback ~ .error-msg {
  display: block;
}

Logika Penggantian

Jika :user-invalid tidak ada, lacak status interaksi secara manual menggunakan WeakMap.

const UserInvalidFallback = (() => {
  const dirtyState = new WeakMap();

  const updateState = (input) => {
    const isValid = input.checkValidity();

    // Update both visual and ARIA state
    input.classList.toggle('user-invalid-fallback', !isValid);
    input.classList.toggle('user-valid-fallback', isValid);

    if (!isValid) {
      input.setAttribute('aria-invalid', 'true');
    } else {
      input.removeAttribute('aria-invalid');
    }
  };

  const handleEvent = (event) => {
    const input = event.target;

    if (event.type === 'reset' && input.matches?.('form')) {
      const controls = input.elements || [];
      for (const control of controls) {
        dirtyState.delete(control);
        control.classList.remove('user-invalid-fallback');
        control.classList.remove('user-valid-fallback');
        control.removeAttribute('aria-invalid');
      }
      return;
    }

    if (!input.matches?.('input, textarea, select')) return;

    if (event.type === 'input' || event.type === 'change') {
      const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
      state.hasInteracted = true;
      dirtyState.set(input, state);
      if (state.hasBlurred) {
        updateState(input);
      }
    } else if (event.type === 'blur') {
      const state = dirtyState.get(input) || { hasInteracted: false, hasBlurred: false };
      state.hasBlurred = true;
      dirtyState.set(input, state);
      if (state.hasInteracted) {
        updateState(input);
      }
    }
  };

  const init = () => {
    if (CSS.supports('selector(:user-invalid)')) return;

    document.addEventListener('blur', handleEvent, true); // Capture phase required
    document.addEventListener('input', handleEvent, true);
    document.addEventListener('change', handleEvent, true);
    document.addEventListener('reset', handleEvent, true); // Capture resets
  };

  return { init };
})();

// Initialize globally
UserInvalidFallback.init();

Pertimbangan Lainnya

  1. aria-live vs. aria-errormessage:

    • aria-errormessage menghubungkan input ke teks, tetapi pembaca layar mungkin tidak langsung membacakannya saat muncul (hanya saat memfokuskan input).
    • Jika Anda memerlukan pengumuman segera saat error muncul (misalnya, saat blur), pertimbangkan untuk menambahkan role="alert" atau aria-live="polite" ke penampung pesan error, tetapi uji secara menyeluruh untuk menghindari "pengumuman ganda" saat pengguna memfokuskan kolom untuk memperbaikinya.
  2. Internasionalisasi:

    • Pastikan konten teks pesan error (#email-error) diterjemahkan. Logikanya tetap sama.