משוב על שדות חובה

תכונות אינטרנט: ‎:user-valid ו-‎ :user-invalid

הבעיה

סימון שדות חובה במצב שגיאה מיד עם טעינת הדף עלול לבלבל. באופן אידיאלי, שדה חובה צריך להיראות "לא תקין" רק אם המשתמש ניסה למלא אותו ונכשל.

הפתרון

הפסאודו-מחלקה :user-invalid פותרת את הבעיה הזו בצורה מושלמת. אם מדובר בשדה חובה, לא תהיה התאמה בטעינת דף. ההתאמה תתבצע רק אם:

  1. המשתמש יוצר אינטראקציה עם השדה (למשל, מקליד תו ומוחק אותו) ואז יוצא ממנו (מטשטש אותו) ומשאיר אותו ריק.
  2. המשתמש מנסה לשלוח את הטופס כשהשדה ריק.

אסטרטגיית הטמעה

  1. אילוץ HTML: מוסיפים את המאפיין required לקלט.
  2. משוב חזותי: משתמשים ב-:user-invalid כדי לעצב את הגבול באדום ולהציג טקסט עזר "חובה".
  3. תזמון: הסתמכו על התזמון המקורי של הדפדפן למשוב חזותי. כבר לא צריך פונקציות טיפול של onBlur כדי להוסיף מחלקה של touched, אבל עדיין צריך קצת JavaScript כדי לסנכרן מאפייני ARIA (ראו בהמשך).

מדריך הטמעה

1. מבנה HTML

<form id="feedback-form">
  <div class="field">
    <label for="full-name">Full Name</label>
    <input
      type="text"
      id="full-name"
      name="full-name"
      required
      aria-errormessage="name-error"
    >
    <!-- MANDATORY: Include an icon or distinct non-color indicator alongside error text -->
    <div id="name-error" class="error-msg">
      <span aria-hidden="true">❌</span> This field is required.
    </div>
  </div>
</form>

2. CSS

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

/*
  Only highlight empty required fields AFTER the user visits them.
  MANDATORY: Provide multiple indicators (border shift + helper text/icon) to avoid color-only state communication.
*/
input:user-invalid {
  border-color: #d93025;
  background-color: #fce8e6;
}

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

/* Optional: Subtle indicator for required fields that are valid */
input:required:user-valid {
  border-color: #188038;
  border-width: 2px;
}

3. סנכרון מצב JavaScript

חובה: מכיוון ש-:user-invalid הוא מצב חזותי, חובה לספק גשר JavaScript כדי לסנכרן את aria-invalid="true" באופן דינמי לטכנולוגיות מסייעות כשמשתמש מטשטש שדה לא חוקי או מנסה לשלוח.

const form = document.getElementById('feedback-form');

const syncAriaInvalid = (input) => {
  if (!input.checkValidity()) {
    input.setAttribute('aria-invalid', 'true');
  } else {
    input.removeAttribute('aria-invalid');
  }
};

// Sync on blur when a user finishes interacting
form.addEventListener('blur', (e) => {
  if (e.target.matches('input[required]')) {
    syncAriaInvalid(e.target);
  }
}, true);

// Sync all required fields when submission is attempted
form.addEventListener('submit', () => {
  form.querySelectorAll('input[required]').forEach(syncAriaInvalid);
});

// Remove error state immediately upon correction
form.addEventListener('input', (e) => {
  if (e.target.matches('input[required]') && e.target.checkValidity()) {
    e.target.removeAttribute('aria-invalid');
  }
});

תמיכה בדפדפנים וחלופות

תמיכה בדפדפנים ובמצבי חוסר זמינות של ‎ :user-valid ו-‎ :user-invalid

CSS חלופי

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

גיבוי JavaScript

משתמשים בכלי שניתן לשימוש חוזר שעוקב אחרי מצב האינטראקציה באמצעות WeakMap. כך אפשר להימנע מ"זיהום" של ה-DOM עם מאפייני נתונים או מחלקות "מלוכלכות".

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

שיקולים נוספים

  1. כוכביות: עדיין מומלץ לציין שדות חובה באופן חזותי (למשל, עם כוכבית *) בתווית, כדי שהמשתמשים ידעו למה לצפות לפני שהם יוצרים אינטראקציה.
  2. כפתורי שליחה: בניגוד לכפתורי disabled, כפתור השליחה צריך להיות מופעל. אם המשתמש לוחץ עליו, הדפדפן יפעיל באופן אוטומטי את :user-invalid בכל שדות החובה הריקים ויתמקד בשדה הראשון. זה מצוין לנגישות ולחוויית המשתמש.
  3. נגישות: רכיב :user-invalid מקורי לא מסתנכרן באופן אוטומטי עם מאפייני ARIA. כדי לסנכרן את aria-invalid עם המצב החזותי, מוסיפים את קוד ה-JavaScript הבא:
// 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);
});