접근성 오류 공지

문제

표준 HTML5 유효성 검사는 :invalid 또는 :user-invalid를 통해 시각적 피드백을 제공하지만 aria-invalid와 같은 접근성 속성과 자동으로 동기화되지 않습니다.

표준 :invalid 스타일을 사용하는 경우 사용자가 현재 비어 있는 필수 입력란으로 탭하는 순간 스크린 리더에서 '잘못된 항목'이라고 알릴 수 있습니다. 이로 인해 상호작용이 발생하기 전에 오류가 공지되므로 보조 기술을 사용하는 사용자에게 불편한 환경이 조성됩니다.

해결 방법

시각적 상태 (:user-invalid)가 적용되는 바로 그 순간에 프로그래매틱 상태 (aria-invalid="true")가 적용되기를 원합니다. :user-invalid는 브라우저의 내부 'user-interacted' 플래그를 사용하므로 JavaScript를 사용하여 표준 상호작용 이벤트 중에 이 선택기가 일치하는지 확인할 수 있습니다.

자세한 내용은 MDN aria-invalid를 참고하세요.

구현 전략

  1. 시각적 레이어: CSS :user-invalid를 사용하여 테두리/아이콘을 표시합니다.
  2. 접근성 레이어: aria-invalid 및 aria-errormessage를 사용하여 보조 기술 (AT)에 상태를 전달합니다.
  3. 시각적 레이어와 접근성 레이어 연결: blur 및 input 이벤트를 수신 대기하고, 요소가 :user-invalid와 일치하는지 확인하고, 이에 따라 ARIA 속성을 업데이트하는 가벼운 JavaScript 유틸리티를 만듭니다.

구현 가이드

1. HTML 구조

aria-errormessage (또는 더 광범위한 지원을 위해 aria-describedby)를 사용하여 입력을 오류 메시지에 연결합니다.

<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

네이티브 의사 클래스 :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

'UserInvalidChanged' 이벤트가 없으므로 표준 양식 이벤트에 후크하여 상태를 확인합니다.

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);
  }
});

대체 및 브라우저 지원

기능 감지

CSS 및 JavaScript에서 지원 여부를 확인할 수 있습니다.

자바스크립트 확인:

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

대체 CSS

대체 로직이 시각적으로 네이티브 동작과 구별되지 않도록 하려면 의사 클래스와 대체 클래스 모두에 오류 스타일을 적용해야 합니다.

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

대체 로직

:user-invalid가 누락된 경우 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();

기타 고려사항

  1. aria-live 대 aria-errormessage:

    • aria-errormessage는 입력을 텍스트에 연결하지만 스크린 리더는 표시되는 즉시 이를 알리지 않을 수 있습니다 (입력에 포커스를 맞출 때만 알림).
    • 오류가 표시될 때 (예: 블러 시) 즉각적인 알림이 필요한 경우 오류 메시지 컨테이너에 role="alert" 또는 aria-live="polite"를 추가하는 것이 좋습니다. 하지만 사용자가 필드에 포커스를 맞춰 수정할 때 '이중 알림'이 발생하지 않도록 철저히 테스트하세요.
  2. 국제화:

    • 오류 메시지 (#email-error)의 텍스트 콘텐츠가 번역되었는지 확인합니다. 로직은 동일하게 유지됩니다.