Псевдокласс :has() — не только «родительский селектор»
Браузер Safari часто ругают за редкое обновление и задержку внедрения новинок, но есть у него «любимые» области, в которых он опережает всех. Например, CSS-селекторы 4 уровня. Псевдоклассы :matches()
— теперь это :is()
, :not()
с несколькими селекторами и :nth-child()
/:nth-last-child()
c добавочным параметром of <что угодно>
он поддерживает с 2015 года. И именно в его экспериментальной сборке появилась первая реализация долгожданного псевдокласса :has()
!
О :has()
часто говорят как о «родительском селекторе». Но он может быть не только селектором любого предка, но и селектором предыдущего соседа. О его непростой судьбе — задержке из-за проблемы с производительностью, странном ее «решении» (буквально по анекдоту «чтоб игрушки дольше не ломались, не давайте их детям»:) и его отмене, и первых попытках реализовать — распространяться не будем. Лучше сразу посмотрим…
Примеры использования
Вот несколько примеров из ответов на вопрос Джен Симмонс в Твиттере, для чего пригодился бы :has()
:
Индикатор подуровней в выпадающих меню
С давних времен на сайтах встречаются меню на вложенных списках, где по наведению на пункт раскрываются вложенные уровни. Возле пунктов, у которых есть дополнительные подуровни, традиционно рисуют треугольник или стрелку, чтобы отличить их от «конечных». С :has()
это можно будет делать автоматически, без всяких классов:
li:has(ul)::after { /* рисуем стрелку */ }
Одиночные картинки в абзацах на всю ширину
Дизайнеры любят растягивать картинки на всю ширину экрана. На CSS-гридах верстать такие блоки очень просто. Но многие интерпретаторы Markdown, визуальные редакторы и подобные инструменты принудительно оборачивают картинки в абзацы. Обычно блочная обертка скорее полезна, но здесь она мешает: абзац с одной картинкой не отличается от абзаца с текстом, а задавать классы в таких редакторах не всегда удобно. А с :has()
это легко решается:
/* в центре колонка для текста удобной для чтения ширины, по бокам поля до краев */ .content { display: grid; grid-template-columns: 1fr 60ch 1fr; } /* по умолчанию блочные элементы в контенте занимают только контентную колонку */ .content > :is(p, ul, ol, blockquote, pre) { grid-column: 2/3; } /* абзацы, в которых только картинка, растягиваем на всю ширину (синтаксис относительного селектора поясняется далее в статье) */ .content > p:has(> img:only-child) { grid-column: 1/-1; }
Подсветка выбранных строк в таблице
В интерфейсах популярны таблицы, в одной из колонок которых есть чекбокс, чтобы потом применить к отмеченным объектам одно и то же действие (например, пометить письма как прочитанные в GMail). Удобно, когда выбранные строки сразу бросаются в глаза — как-то подсвечены. До сих пор такое делалось скриптом (ну или хаками с потайными чекбоксами на грани безумия). С :has()
будет элементарно:
tr:has([type="checkbox"]:checked) { /* украшаем как хотим! */ }
Улучшение форм
Кстати, про input
-ы: часто нужно отразить статус и состояние поля в его подписи. Например, рядом с обязательными полями проставить «*», возле заполненных правильно расставить зеленые «галочки», а где неправильно — красные предупреждающие знаки. Формы бывают динамическими, и хорошо бы это автоматизировать и не дублировать логику. Вот только CSS раньше мог выбирать только следующие элементы, а label
обычно идет либо перед input
-ом, либо вообще оборачивает его.
Тут и выручает :has()
: во втором случае — как родительский селектор (label:has(> :invalid)
), а в первом — как селектор предыдущего соседа (label:has(+ :optional)
). Брамус ван Дамм, отметивший это в Твиттере, сделал красивое демо, где label
реагирует на 4 разных состояния следующего за ним input
-а. Вот видео, как оно работает в Safari:
И label
-ами дело не ограничивается! Можно подсветить целый блок полей, если все они заполнены правильно (fieldset:not(:has(:invalid))
) или, наоборот, вывести общее сообщение об ошибке для всей формы (form:has(:invalid)::after {...}
).
Секции без заголовков и наглядная помощь в отладке
Элементам типа <article>
и <section>
, неявно играющим роль «ориентиров» (landmarks), нужны заголовки, иначе навигация по разделам в скринридерах и т.п. будет пестрить ссылками вида «безымянный раздел» и запутает пользователя. Можно, конечно, отловить такие разделы скриптом или автотестами… но было бы удобно, если бы они сразу подсвечивались перед глазами у редактора контента, чтобы сразу такое исправлять. Теперь для этого достаточно добавить в стили редактора админки код типа
:is(aside, article, main, nav, section):not(:has(h1, … , h6)) { outline: 10px solid rgb(255 0 0 / 0.5); }
…и все проблемные разделы сразу станут видны по красным рамкам.
В общем, применений масса. Добавляйте свои в комментариях!
Что про :has()
говорит спецификация
Внимательные читатели могли заметить, что аргумент у :has()
особенный, не совсем такой, как у других функциональных псевдоклассов: иногда он начинается с комбинатора (>
, +
или ~
). Так и есть:
Псевдокласс отношения,
:has()
— функциональный псевдокласс, принимающий в качестве аргумента «список относительных селекторов».
Область видимости — элемент, для которого задан :has()
Относительный селектор отличается от обычного тем, что всегда вычисляется относительно того элемента, которому псевдокласс :has()
задан. Спецификация про него по традиции написана очень мудрёно, но полна сюрпризов: оказывается, относительные селекторы начинаются с комбинатора не иногда, а всегда! Даже когда его не видно, он есть: неявно подразумевается, что перед селектором стоит пробел — комбинатор потомка.
Поэтому, хотя обычный селектор в aside a:is(section a)
и относительный в aside:has(section a)
выглядят похоже, у них совершенно разный смысл. Селектор aside a:is(section a)
ищет ссылки, среди предков которых есть и aside
, и section
— всё равно в каком порядке (подойдет и ссылка в примечании aside
к разделу section
, и ссылка в одном из виджетов соответствующего section
сайдбара aside
). А aside:has(section a)
проверяет наличие только ссылок внутри section
, вложенных в aside
(то есть совпадающих с селектором aside section a
), но не наоборот.
(обновлено 10.12.2022) Невалидные селекторы в списке больше не разрешены
Изначально аргумент :has()
был определен как «прощающий». Это значит, что один невалидный селектор не «заражает» невалидностью весь список, как это происходит c обычным списком селекторов. Так было сделано ради будущей совместимости:
/* пример: надо одинаковым образом подсветить :target текущей ссылки и текущий элемент в расшифровке видео (:current) */ /* НЕПРАВИЛЬНО: в браузерах без поддержки :current отвалится всё правило */ :target, :current { /* какие-то стили, которые не применятся ни к чему */ } /* Так работает, но громоздко и некрасиво 😳 */ :target { /* какие-то стили */ } :current { /* и еще раз те же самые стили */ } /* ПРАВИЛЬНО и стильномодномолодёжно 😎 */ :is(:target, :current) { /* применится к тому, что поддерживается */ }
Но для :has()
такое поведение «сломало» логику jQuery. И в декабре 2022 г. редакторы стандарта изменили его. Теперь «прощать» неподдерживаемые селекторы в своих аргументах умеют только два псевдокласса: :is()
и :where()
. Очень похожие с виду :not()
и :nth-*-child(An + B of …)
, а теперь и :has()
— не умеют. Спасибо Софии Валитовой за своевременное напоминание об этом важном отличии!
Замечание: имейте в виду, что существующие реализации :has()
в браузерах сделаны еще по старому стандарту!
Вкладывать :has()
в :has()
нельзя
При работе над первой реализацией в Safari выяснилось, что обработка вложенных :has()
слишком сложна, и разработчики предложили просто запретить вкладывать :has()
друг в друга. Теперь это ограничение — часть стандарта. И псевдоэлементы внутри :has()
тоже запрещены.
Специфичность :has()
По новым правилам специфичность :has()
, как и у :is()
и :not()
, считается по самому специфичному селектору в списке внутри скобок, и не зависит от того, какой селектор реально совпал. Например, у :has(a, #SuperPuperID)
будет специфичность 1,0,0
(как у ID), а у :has(.item:visited:target)
— 0,3,0
(класс и два псевдокласса). Сам :has()
, как и :not()
c :is()
, вклад в специфичность не вносит.
Для полноты картины: у :where()
специфичность всегда равна нулю — для этого его и ввели, а у :nth-child
/:nth-last-child(An + B of …)
— специфичность самого специфичного селектора после of
плюс один псевдокласс (ради совместимости со старым вариантом без of
).
Поддержка (обновлено 10.12.2022)
Из браузеров :has()
поддерживают Safari 15.4+ и Chrome 105+ со всеми «родственниками». В Firefox над ним работают (есть экспериментальная поддержка за флагом, но пока неполная).
Еще раньше :has()
поддерживали некоторые PDF-рендереры для HTML, например, Prince (в прошлом PrinceXML). В сети можно найти онлайн-песочницы для них (например, PrintCSS.live) и поэкспериментировать там. Конечно, трюки с динамическими label
-ами для форм там не повторить, но лучше чем ничего.
Кроме того, метод .has()
с селектором в качестве параметра давно есть в jQuery. Забавно, но для меня стало сюрпризом, что он тоже воспринимает селектор как относительный и может выбирать не только потомков, но и предыдущих соседей (.label:has("+ input:checked")
) — из документации это совсем неочевидно. Впрочем, jQuery вдохновила немало нативных браузерных фич (тот же querySelector
), так что совпадение не удивительно.
Проверить поддержку :has()
в браузере можно так:
@supports ( selector( :has(*) ) ) { /* стили только для поддерживающих браузеров */ }
Функция selector()
— новое расширение директивы @supports
из модуля условных выражений 4 уровня, и сама поддерживается не так давно, но уже повсеместно.
Еще есть два важных частных случая, для которых ввели отдельные селекторы: проверка, есть ли в элементе элемент под фокусом (:focus-within
), и есть ли в нем целевой якорь текущего URL (:target-within
). Первый давно поддерживается везде, кроме IE и UC Browser, второй ввели совсем недавно и пока лишь в теории. С приходом :has()
они превращаются в синтаксический сахар (вместо :is(:focus, :has(:focus))
и т.п.), и потребность в новых селекторах :*-within
отпадает.
Баги
Куда же без них, особенно в первой экспериментальной реализации. Так, Лия Веру в первый же день нашла проблему с динамическими псевдоклассами — не все они обрабатывались адекватно (обн. 10.12.2022: уже испрввлено). Еще в Safari 15.4 неправильно считалась специфичность :has()
— к «весу» аргумента добавлялся лишний псевдокласс, этот баг случайно нашли редакторы проекта doka.guide (исправлено в Safari 16).
Не только :has()
!
Закончить статью хочется напоминанием о еще одной полезной новинке CSS-селекторов 4 уровня, которая давно работает в Safari, но почему-то упорно игнорируется другими. Уже упоминал вскользь, но хотелось бы заострить на ней внимание.
Я опять про многострадальные :nth-child(An + B of <список селекторов>)
и :nth-last-child(An + B of <список селекторов>)
😂. Иногда про них говорят как про «селектор :nth-of-class» — по наиболее частой задаче, где они нужны, и по аналогии с :nth-of-type
, который с их приходом превращается в «синтаксический сахар» для :nth-child(An + B of <имя тега>)
. Полезная ведь штука! Например, раскрасить «зеброй» динамическую таблицу с фильтром, в которой некоторые строки скрыты, чтобы порядок полос учитывал только видимые строки. С продвинутым псевдоклассом элементарно: tr:nth-child(2n of :not(hidden)) { /* стили для четных строк */ }
. А без… упс.
Пожалуйста, проголосуйте за приоритет реализации этих селекторов в Chrome и Firefox! Пусть браузеры вспомнят, что в CSS Selectors 4 есть и такое, и полезная штука станет наконец кроссбраузерной.
Ну и за сам :has()
в Firefox тоже, на всякий случай. Кто бы что ни говорил, а энтузиазм веб-разработчиков тоже стимул для создателей браузеров — всегда приятнее делать дело, которое кому-то нужно! :)
P.S. Это тоже может быть интересно: