CSS-live.ru

Псевдокласс :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. Это тоже может быть интересно:

Оставить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Получать новые комментарии по электронной почте. Вы можете подписаться без комментирования.