تنسيق العنصر الرئيسي باستخدام ‎ :has()‎

ميزات الويب: ‎:user-valid و‎ :user-invalid

المشكلة

غالبًا ما تتطلّب حالة الخطأ عناصر تصميم خارج حقل الإدخال نفسه، مثل تغيير لون حدود العنصر الرئيسي fieldset أو تمييز <label> أو عرض رمز خطأ عام في عنوان البطاقة. في السابق، كان ذلك يتطلّب استخدام JavaScript لتبديل الفئات في العناصر الرئيسية.

الحل

من خلال الجمع بين :has() و:user-invalid، يمكننا تصميم أي عنصر رئيسي بشكل تعريفي استنادًا إلى حالة صلاحية عنصر فرعي معيّن. يؤدي ذلك إلى الاحتفاظ بجميع منطق العرض التقديمي في CSS.

استراتيجية التنفيذ

  1. أداة الاختيار: استخدِم .parent:has(:user-invalid) لاستهداف الحاوية.
  2. النطاق: يجب أن تكون محدّدًا لتجنُّب حدوث مشاكل في الأداء. استهدِف .field-group بدلاً من body.
  3. الاحتياطي: يتطلب استخدام JS لتبديل الفئات في العنصر الرئيسي إذا لم يكن :has() متوافقًا.

دليل الاستخدام

1. بنية HTML

<form>
  <div class="card-section">
    <div class="header">
      <h3>Profile Settings</h3>
      <span class="status-icon"></span>
    </div>

    <div class="field">
      <label for="username">Username</label>
      <input type="text" id="username" required>
    </div>
  </div>
</form>

2. CSS

/* Default State */
.card-section {
  border: 1px solid #ccc;
  border-left: 4px solid #ccc;
}

/*
  Parent Styling Logic:
  If the card contains ANY user-invalid input, turn the whole card's edge red.
*/
.card-section:has(:user-invalid) {
  border-left-color: #d93025;
  background-color: #fff8f8;
}

/* Change the icon too */
.card-section:has(:user-invalid) .status-icon::after {
  content: "⚠️";
}

العودة إلى الإعدادات التلقائية والتوافق مع المتصفّح

CSS للإجراء الاحتياطي

نستخدم فئة .has-error في العنصر الرئيسي لمحاكاة سلوك :has().

/* Native */
.card-section:has(:user-invalid) {
  border-left-color: #d93025;
}

/* Fallback */
.card-section.has-error-fallback {
  border-left-color: #d93025;
}

الاحتياطي لـ 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. Initialize the generic fallback
const form = document.querySelector('#demo-form');
UserInvalidFallback.init(form);

// 2. Add specialized "parent styling" logic (Separate from fallback)
// Listen for changes to form validity after interaction
form.addEventListener('blur', (e) => {
  if (!e.target.matches('input, select, textarea')) return;

  // Find the container we want to style (sync with CSS)
  const container = e.target.closest('.card-section');
  if (!container) return;

  // Check if ANY fallbacked input in this container is invalid
  const hasError = container.querySelector('.user-invalid-fallback');
  container.classList.toggle('has-error-fallback', !!hasError);
}, true); // Capture phase to ensure we run after the fallback's blur listener

// Also handle input events for immediate cleanup
form.addEventListener('input', (e) => {
  const container = e.target.closest('.card-section');
  if (container) {
    const hasError = container.querySelector('.user-invalid-fallback');
    container.classList.toggle('has-error-fallback', !!hasError);
  }
});

// Handle form resets
form.addEventListener('reset', () => {
  form.querySelectorAll('.has-error-fallback').forEach(el => {
    el.classList.remove('has-error-fallback');
  });
});

اعتبارات أخرى

  1. تسهيل الاستخدام: لا تتم مزامنة سمات :user-invalid الأصلية تلقائيًا مع سمات ARIA. أضِف رمز JavaScript التالي لإبقاء aria-invalid متزامنًا مع الحالة المرئية:
// 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);
});