Vấn đề
Thông thường, trạng thái lỗi yêu cầu các phần tử tạo kiểu bên ngoài chính dữ liệu đầu vào, ví dụ: thay đổi màu của đường viền fieldset mẹ, làm nổi bật <label> hoặc hiển thị biểu tượng lỗi chung trong tiêu đề thẻ. Trước đây, việc này cần có JavaScript để bật/tắt các lớp trên phần tử mẹ.
Giải pháp
Bằng cách kết hợp :has() với :user-invalid, chúng ta có thể tạo kiểu cho bất kỳ thành phần cấp trên nào dựa trên trạng thái hợp lệ của một thành phần con cụ thể. Điều này giúp giữ tất cả logic trình bày trong CSS.
Chiến lược triển khai
- Bộ chọn: Sử dụng
.parent:has(:user-invalid)để nhắm đến vùng chứa. - Phạm vi: Hãy nêu cụ thể để tránh các vấn đề về hiệu suất. Nhắm đến
.field-groupthay vìbody. - Dự phòng: Yêu cầu JS bật/tắt các lớp trên phần tử mẹ nếu không hỗ trợ
:has().
Hướng dẫn triển khai
1. Cấu trúc 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: "⚠️";
}
Dự phòng và hỗ trợ trình duyệt
CSS cho quảng cáo dự phòng
Chúng tôi sử dụng một lớp .has-error trên phần tử mẹ để bắt chước hành vi của :has().
/* Native */
.card-section:has(:user-invalid) {
border-left-color: #d93025;
}
/* Fallback */
.card-section.has-error-fallback {
border-left-color: #d93025;
}
Dự phòng JavaScript
Sử dụng một tiện ích có thể tái sử dụng để theo dõi trạng thái tương tác bằng WeakMap. Điều này giúp tránh làm ô nhiễm DOM bằng các lớp hoặc thuộc tính dữ liệu "bẩn".
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');
});
});
Những điều cần xem xét khác
- Hỗ trợ tiếp cận:
:user-invalidgốc không tự động đồng bộ hoá với các thuộc tính ARIA. Thêm JavaScript sau để đồng bộ hoáaria-invalidvới trạng thái hình ảnh:
// 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);
});