سبک والد با :has()‎

ویژگی‌های وب: :user-valid و :user-invalid

مشکل

اغلب، حالت خطا به عناصر سبک‌دهی خارج از خود ورودی نیاز دارد—برای مثال، تغییر رنگ حاشیه fieldset، برجسته کردن <label>، یا نمایش نماد خطای سراسری در سرصفحه کارت. در گذشته، این کار به جاوا اسکریپت نیاز داشت تا کلاس‌ها را در عناصر والد تغییر دهد.

راه‌حل

با ترکیب کردن :has() با :user-invalid، می‌توانیم هر عنصر والد را به‌صورت اعلانی براساس وضعیت اعتبار یک عنصر فرزند خاص سبک‌بندی کنیم. این کار همه منطق ارائه را در CSS نگه می‌دارد.

راهبرد پیاده‌سازی

  1. انتخابگر: از .parent:has(:user-invalid) برای هدف‌یابی محتوی استفاده کنید.
  2. محدوده: برای جلوگیری از مشکلات عملکرد، دقیق باشید. .field-group را به‌جای body هدف قرار دهید.
  3. جایگزین: اگر :has() پشتیبانی نشود، برای جابه‌جایی کلاس‌ها در والد به JS نیاز است.

راهنمای پیاده‌سازی

۱. ساختار 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>

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

بازگشت جاوا اسکریپت

از ابزار کمکی قابل‌استفاده مجددی استفاده کنید که وضعیت تعامل را بااستفاده از 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 همگام‌سازی نمی‌شود. برای همگام نگه داشتن 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);
});