Interakcja z menu wyboru

Funkcje internetowe: :user-valid i :user-invalid

Problem

W przypadku obowiązkowych menu (np. „Wybierz kraj”) standardowa weryfikacja natychmiast oznacza pole jako nieprawidłowe, jeśli opcja domyślna ma pustą wartość. Może to powodować szum wizualny. Chcemy wyświetlać błąd tylko wtedy, gdy użytkownik otworzy menu i zamknie je bez wybierania opcji lub spróbuje przesłać formularz.

Rozwiązanie

Pseudoklasa :user-invalid działa bezproblemowo z elementami <select>. Uwzględnia proces interakcji użytkownika: samo wczytanie strony lub ustawienie/usunięcie zaznaczenia bez wprowadzania zmian nie jest traktowane jako interakcja, więc pole pozostaje neutralne, dopóki użytkownik nie spróbuje dokonać wyboru.

Strategia wdrażania

  1. Ograniczenie HTML: użyj elementu <select> z atrybutem required. Pierwsza opcja powinna mieć wartość value="" i najlepiej, aby była wyłączona lub ukryta, co wymusi prawidłowy wybór.
  2. Wizualne informacje zwrotne: użyj :user-invalid, aby nadać styl obramowaniu pola wyboru.
  3. Czas: przeglądarka uznaje, że użytkownik „wszedł w interakcję” z polem, jeśli zmieni on wartość (nawet z powrotem na domyślny nieprawidłowy stan), zanim usunie zaznaczenie elementu sterującego lub prześle formularz.

Przewodnik po implementacji

1. Struktura HTML

Opcja „obiekt zastępczy” jest tutaj kluczowa.

<form>
  <div class="field">
    <label for="country">Country</label>
    <select
      id="country"
      name="country"
      required
      aria-errormessage="country-error"
    >
      <option value="" disabled selected>Select a country...</option>
      <option value="us">United States</option>
      <option value="ca">Canada</option>
      <option value="uk">United Kingdom</option>
    </select>
    <div id="country-error" class="error-msg">
      Please select a country.
    </div>
  </div>
</form>

2. CSS

.error-msg {
  display: none;
  color: #d93025;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

/*
  Only show error after the user visits the select menu.
*/
select:user-invalid {
  border-color: #d93025;
  background-color: #fce8e6;
}

select:user-invalid + .error-msg {
  display: block;
}

select:user-valid {
  border-color: #188038;
}

Kreacje zastępcze i obsługa przeglądarek

Zastępcze i obsługiwane przez przeglądarkę dla :user-valid i :user-invalid

CSS dla kreacji zastępczej

input:user-invalid,
input.user-invalid-fallback {
  border-color: #d93025;
  background-color: #fce8e6;
}

input:user-invalid + .error-msg,
input.user-invalid-fallback + .error-msg {
  display: block;
}

Zastępczy tag JavaScript

Użyj narzędzia wielokrotnego użytku, które śledzi stan interakcji za pomocą WeakMap. Dzięki temu unikniesz zaśmiecania modelu DOM „brudnymi” klasami lub atrybutami danych.

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') {
      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.checkValidity) 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 = (root = document) => {
    if (CSS.supports('selector(:user-invalid)')) return;

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

  return { init };
})();

// Initialize for a specific form
const form = document.querySelector('#demo-form');
UserInvalidFallback.init(form);

Inne rzeczy, które warto wziąć pod uwagę

  1. Zachowanie na urządzeniach mobilnych: na urządzeniach mobilnych „rozmycie” może działać inaczej w zależności od selektora systemu operacyjnego. Zalecamy testowanie na rzeczywistych urządzeniach.
  2. Ułatwienia dostępu: natywny element :user-invalid nie synchronizuje się automatycznie z atrybutami ARIA. Dodaj ten kod JavaScript, aby zachować synchronizację aria-invalid ze stanem wizualnym:
// Sync aria-invalid with the CSS :user-invalid state
const syncAria = (el) => {
  el.setAttribute?.('aria-invalid', el.matches(':user-invalid') ? 'true' : 'false');
};

// Update on blur (to show error) and input (to clear it)
document.addEventListener('blur', (e) => syncAria(e.target), true);
document.addEventListener('input', (e) => {
  if (e.target.hasAttribute('aria-invalid')) syncAria(e.target);
});