Головна / Статті

Стилізуйте батьківські елементи за їхнім вмістом за допомогою CSS :has()

Дізнайтеся, як метод :has() нарешті дозволяє селекторам оглядати нащадків та братські елементи, де можна використовувати мікрофронтенд-шелли, та чому Shadow DOM у поєднанні з правилами класових імен все ще обмежує цю модель.

Видавець: REACTAPP.TOP6 хв читання1345 слів

Джерела: CSS Finally Learned to Look Up the Tree

Напрямки, у які ніколи не рухалися CSS-селектори

Протягом десятиліть CSS-селектор міг описувати предків та нащадків елементів, а також охоплювати сусідні елементи, проте він не міг стилізувати елемент через його вміст чи елементи, які знаходилися після нього. Використання .card img дозволяло шукати зображення всередині елемента типу card. Використання h2 + p означало переход від заголовка до наступного абзацу. Зворотні форми просто не існували: не було можливості стилізувати елемент на основі вкладених медіа-елементів чи заголовок на основі абзацу, який знаходився пізніше в структурі дерева.

Ця асиметрія не була випадковістю у підходах API. Історично движки стилізували документ, пройшовши його переважно вперед, зверху вниз. Правило, якому доводилося б перевіряти майбутні елементи або глибоких нащадків, перш ніж вирішити, чи підходить об’єкт, порушило б цю модель однокрокової обробки. Протягом приблизно двадцяти п’яти років кожен основний движок селекторів дотримувався цього обмеження, а кожна команда, якій потрібна була стилізація контейнера на основі його вмісту, знаходила обхідні шляхи замість того, щоб працювати безпосередньо з правилом: клас, який активувався через JavaScript, додатковий вузол-обгортка чи булеве значення, яке передавалося крізь кілька компонентів лише для позначки «у цьому екземплярі є футер».

:has() усуває цей обхідний шлях, дозволяючи самому правилу дивитися у напрямку, куди CSS раніше відмовлявся дивитися. Як тільки ці двері відчиняються, цікавим питанням стає те, що насправді може перевіряти движок селекторів.

Стилювання на основі взаємозв’язків також змінює спосіб, у який системи дизайну документують варіанти. Замість того, щоб вимагати від авторів пам’ятати клас модифікатора щоразу, коли карточка містить медіафайли, таблиця стилів може отримати варіант зі структури, яка вже існує в маркапі. Це зручно, і саме тому випадкові зміни структури призводять до візуального регресу.

Що насправді перевіряє :has()

Псевдоклас — це умова, що починається з двокрапки та приєднується до селектора. Відомі приклади включають :hover та :first-child: вони збігаються через стан чи структурне положення, а не через назву тега, клас чи id. :has() належить до тієї ж групи, але його аргументом є інший селектор. Він перевіряє, чи елемент-об’єкт містить — або якимось іншим чином пов’язаний — з чимось, що збігається з цим аргументом.

.card:has(img) {
  border: 2px solid royalblue;
}

Це правило можна зрозуміти майже буквально: застосовувати цей обрамлення до .card лише тоді, коли існує відповідний елемент img у зв’язку, який оцінюється за допомогою :has(). Селектор перед :has() вказує елемент, який отримує ці правила. Селектор у дужках вказує лише умову. Коли умова є хибною, :has() не знаходить нічого, і правило так і не застосовується.

Один факт розвіює більшість плутанин щодо першого використання: декларації приєднуються до суб’єкта перед :has(), тоді як вибірник у дужках є лише присудком. Поширеним неправильним тлумаченням є «вибрати зображення, а потім налаштувати стиль його картки». Насправді механізм працює навпаки: браузер спочатку оцінює умову, а потім вирішує, чи малювати зовнішній суб’єкт. Цей порядок тлумачення має значення під час навчання цій функції, адже демонстрації, які акцентують увагу на внутрішньому вибірнику, змушують людей застосовувати стилі до неправильного елемента.

Фраза «десьою мірою всередині нього» все ще приховує важливі деталі. Наскільки далеко шукає :has(), і які зв’язки враховуються?

Похідні, дочірні та братські елементи — три різні рівні досяжності

Аргумент, який передається до :has(), не обмежений поняттям „будь-який нащадок“. Це повний селектор. Комбінатори — символи, які визначають зв’язки між частинами селектора, — змінюють те, що перевіряє псевдоклас.

/* descendant: image anywhere inside, at any depth */
.card:has(img) { }

/* direct child only: image must be an immediate child of .card */
.card:has(> img) { }
/* next sibling: heading immediately followed by a paragraph */
h2:has(+ p) { }
/* any later sibling: some paragraph follows, not necessarily right after */
h2:has(~ p) { }

Кожен з цих селекторів аналізує різну частину дерева DOM. Вибір неправильного селектора призводить до безпомітної помилки: нічого не кидається. Формула .card:has(img), написана тоді, коли автор мав на увазі .card:has(> img), може здаватися правильною у простому тестовому середовищі, але згодом може підходити до картки, у якої зображення знаходиться на глибині трьох обгорток після того, як під час редизайну був доданий ще один контейнер.

По суті, словниковий запас є невеликим: перевірка на наявність нащадків на будь-якій глибині, обмеження щодо прямих дочірніх елементів та перевірка наявності братів чи сестер — безпосередньо наступного або будь-якого наступного. Ефекти наведення курсору, стилізація для перевірки форм, виявлення порожнього стану та подібні патерни є комбінаціями цих чотирьох механізмів. Більше нічого складного під ними не існує.

Наступне питання полягає у тому, де саме ці механізми приносять користь у реальному кодовому базисі, а не лише у дворядковій демонстрації.

Шелл, який реагує на те, що не може відобразити самостійно

Уявіть собі шелл-додаток, який керує макетом сторінки та вміщує в себе інші додатки: панель навігації, віджет зі списком продуктів та панель сповіщень. Ці елементи створюються та розгортаються незалежно один від одного. Така архітектура роботи додатків зазвичай називається мікрофронтендом. Перевага полягає у тому, що команда, яка займається сповіщеннями, може їх розгорнути без необхідності координації з командою шеллу.

Незалежність має свою ціну, яку повинен нести шелл. Шелл не може заздалегідь знати, що буде відображатися на панелі сповіщень, оскільки обидва елементи розгортаються за різними графіками. Якщо шелл хоче оновлювати основний контент щоразу, коли на панелі з’являється щось нове, раніше використовувалися спільні події, які викликалися під час розгортання контенту, або спільний сховище стану, яке читалося обома сторонами. Кожен з цих варіантів призводив до зв’язку між репозиторіями, які були створені саме для зменшення такого зв’язку.

/* shell/layout.css */
.main-content:has(~ .notification-panel:not(:empty)) {
  margin-right: 320px;
  transition: margin-right 0.2s ease;
}
<!-- shell/index.html -->
<div class="app-shell">
  <main class="main-content">...</main>
  <div class="notification-panel" id="notification-slot"></div>
</div>
// notification-panel/mount.js
import { renderAlert } from './alert.js';

export function mount(targetId, alertData) {
  const slot = document.getElementById(targetId);
  slot.appendChild(renderAlert(alertData));
}

Використання :has() тут означає, що відступи змінюються лише тоді, коли панель сповіщень містить справжній контент, а не у момент з’явлення порожнього вузла-хоста. Зверніть увагу, що ці таблиці стилів не враховують: немає шини подій, немає спільного сховища даних, а також немає процедури взаємодії, яку кожна з команд мусила б реалізувати для іншої. Дві команди, які ніколи не читали код одна одної, все одно можуть координувати зміни лейауту, оскільки обидві працюють з одним і тим самим динамічним DOM.

Саме відсутність прямої координації робить наступні обмеження вартими уваги.

Де закінчується досяжність та що вона припускає без прямих формулювань

Два обмеження стають ще важливішими, коли логіка лейауту поширюється на окремо розповсюджувані фрагменти.

Спочатку йде технічна ізоляція. Фрагменти, загорнуті в Shadow DOM, утримують непрозору структуру. Зовнішні стилі не можуть отримати доступ до цієї структури, тому селектор оболонки, який залежить від відсутності вмісту всередині, ніколи не буде активований. Спільний легкий DOM є передумовою; стандартна інкапсуляція в тіні у Web Components приховано вимикає цю модель.

Друга обмеження є операційним. Правила макетування, які базуються на .notification-panel, передбачають, що назва класу та модель контейнування залишатимуться стабільними. Якщо перейменувати клас, перемістити вміст у портал або відобразити його в іншому місці, оболонка перестане реагувати без появи помилок типу та без провалу тестів. У такому разі відлагодження означає повторне виявлення неявної угоди CSS, якою керує інший репозиторій.

Порівняно з явними подіями чи спільними сховищами даних це не обов’язково гірше. Зв’язок все одно існує; він просто знаходиться у тексті селектора, а не в імпортованому символі.

Повернення елементів на місце

Історичні двигуни селекторів були оптимізовані для прямого оброблення даних, тому запити до контейнерів такого роду з’явилися пізно. :has() нарешті дозволяє елементу реагувати на нащадків, дочірні елементи та сусідні елементи. Цих трьох типів взаємозв’язків достатньо, щоб пояснити зміни в структурі шелу — додаткової псевдомови не потрібно.

Ця сама можливість пояснює й моди збоїв. Перехід у „тіньові“ дерева призводить до невдачі. Використання імен сторонніх класів також призводить до проблем. Команди, які розуміють суть, розглядають селектори з взаємозв’язками як інструменти для представлення даних, пов’язані з задокументованими правилами DOM, а не як заміну узгодженим контрактам між власниками мікрофронтендів.