Aviso de erro acessível

Recursos da Web: :user-valid e :user-invalid

O problema

A validação padrão do HTML5 fornece feedback visual (via :invalid ou :user-invalid), mas não sincroniza automaticamente com atributos de acessibilidade, como aria-invalid.

Se você usar o estilo padrão de :invalid, os leitores de tela poderão anunciar "Entrada inválida" no momento em que um usuário acessar um campo obrigatório que esteja vazio. Isso cria uma experiência ruim para os usuários que usam tecnologias adaptativas, já que o erro é anunciado antes que a interação ocorra.

A solução

Queremos que o estado programático (aria-invalid="true") seja aplicado no exato momento em que o estado visual (:user-invalid) é aplicado. Como :user-invalid depende da flag interna "user-interacted" do navegador, podemos usar o JavaScript para verificar se esse seletor corresponde durante eventos de interação padrão.

Consulte MDN aria-invalid para mais detalhes.

Estratégia de implementação

  1. Camada visual: use o CSS :user-invalid para mostrar bordas/ícones.
  2. Camada de acessibilidade: use aria-invalid e aria-errormessage para comunicar o estado à tecnologia adaptativa (TA).
  3. Camada visual e de acessibilidade de ponte: crie um utilitário JavaScript leve que detecte eventos blur e input, verifique se o elemento corresponde a :user-invalid e atualize os atributos ARIA de acordo.

Guia de implementação

1. Estrutura HTML

Vincule a entrada à mensagem de erro usando aria-errormessage (ou aria-describedby para um suporte mais amplo).

<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

Controle a visibilidade da mensagem de erro usando a pseudoclasse nativa :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

Como não há um evento "UserInvalidChanged", use eventos de formulário padrão para verificar o estado.

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 e suporte ao navegador

Detecção de recursos

É possível verificar a compatibilidade em CSS e JavaScript.

Verificação de JavaScript:

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

CSS para substituição

Para garantir que a lógica de substituição seja visualmente indistinguível do comportamento nativo, aplique os estilos de erro à pseudoclasse e à classe de substituição.

/* 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;
}

Lógica de substituição

Se :user-invalid estiver ausente, rastreie manualmente o estado de interação usando um 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();

Outras considerações

  1. aria-live x aria-errormessage:

    • aria-errormessage conecta a entrada ao texto, mas os leitores de tela podem não anunciá-la imediatamente após o aparecimento (somente ao focar na entrada).
    • Se você precisar de um aviso imediato quando o erro aparecer (por exemplo, ao desfocar), adicione role="alert" ou aria-live="polite" ao contêiner da mensagem de erro, mas teste bem para evitar um "aviso duplo" quando o usuário focar no campo para corrigir o problema.
  2. Internacionalização:

    • Verifique se o conteúdo de texto da mensagem de erro (#email-error) está traduzido. A lógica permanece a mesma.