Прокачайте свои CSS навыки с селектором :has()

alexei25/06/2023 - 09:08
Прокачайте свои CSS навыки с селектором :has()

Реляционный CSS селектор :has() позволяет сделать то, что раньше можно было сделать только при помощи JavaScript. В этой статье мы рассмотрим, как комбинировать :has() с другими селекторами CSS, и какие волшебные возможности предоставляет этот селектор.

Использование селекторы :has() дает нам возможность как бы "заглядывать вперед" с помощью CSS и создавать стили для родительского элемента. Затем мы можем расширить селектор, чтобы он охватывал один или несколько одноуровневых или дочерних элементов. Учитывая положение или позиции элементов, мы можем стилизовать практически любую комбинацию элементов в виде уникальных одиночек или диапазонов.

Примечание: Поддержка селектора :has() постоянно расширяется. В настоящее время он поддерживается в браузерах Safari 15.4 и Chrome/Edge 105. Он также находится под флагом в Firefox начиная с версии 103.

Как :has() работает с комбинаторами и псевдоклассами

Чтобы лучше понять, как работают расширенные селекторы, которые мы будем создавать, давайте коротко рассмотрим наиболее подходящие комбинаторы и псевдоклассы.

"Комбинатор" - это специальный символ, который обозначает тип взаимосвязи между частями селектора. Вот основные комбинаторы, которые нужно знать:

  • символ пробела: комбинатор потомока соответствует прямому или вложенному дочернему элементу;
  • >: прямой дочерний комбинатор соответствует только дочерним элементам верхнего уровня, не вложенным друг в друга;
  • +: смежный комбинатор одноуровневых элементов соответствует только последующему элементу того же уровня;
  • ~: общий комбинатор одноуровневых элементов соответствует одному или нескольким элементам того же уровня, следующим за базовым селектором.

Первым этапом создания сложных селекторов является добавление псевдокласса к одной или нескольким частям селектора. "Псевдокласс" определяет особое состояние элемента, например :hover, и имеет формат одного двоеточия, за которым следует название. Псевдокласс :has() считается функциональным, поскольку он принимает параметр. В частности, он принимает список селекторов, будь то простые, вроде img, или сложные, с комбинаторами, вроде img + p.

Тем не менее, :has() это один из четырех функциональных псевдоклассов. Другими являются :is(), :where() и :not(). Каждый из них принимает список селекторов с несколькими другими уникальными функциями.

Если вы уже использовали :is() и :where(), то это скорее всего было для управления специфичностью. Использование :is() означает, что селектор в списке с наибольшей специфичностью придает свой вес всему селектору. При использовании :where() весь список селекторов не имеет специфичности, что позволяет легко отменять его последующими правилами каскада.

Кроме того, :is() и :where() обладают дополнительной специальной способностью пользоваться послаблением от браузеров. Это означает, что если вы используете (намеренно или нет) селекторы, которые браузер не понимает, то он все равно будет обрабатывать те части, которые он понимает. Без такого снисходительного поведения браузер отменил бы все правило.

Другое преимущество как :is(), так и :where() заключается в способности создавать короткие сложные селекторы. Это особенно удобно при использовании комбинаторов и задействовании множественных одноуровневых элементов или потомков, например, article :is(h1, h2, h3).

Наш последний псевдокласс, :not(), был доступен в CSS раньше всех. Однако, когда были запущены селекторы :is() и :where(), селектор :not() был расширен. Теперь он может принимать несколько селекторов вместо одного. Также, его поведение специфичности такое же, как у селектора :is().

И наконец, мы должны знать о недостаточно используемой, невероятно мощной функции селекторов :is(), :where() и :not(), которую мы будем использовать при создании наших продвинутых селекторов :has(). Так, использование символа * в этих селекторах, который в CSS является "универсальным селектором", в действительности относится к цели селектора. Это позволяет соотноситься с предыдущими одноуровневыми элементами целевого объекта селектора. Таким образом, в декларации img:not(h1 + *) мы выбираем изображения, которые не следуют непосредственно за элементом h1. А в p:is(h2 + *) мы выбираем абзацы, только если они следуют непосредственно за элементом h2. Мы будем использовать это поведение в нашем первом примере.

Полифилл для :only-of-selector

Хотя :only-of-type является допустимым псевдоклассом, он работает только для выбора внутри элементов того же типа. Если определить селектор .highlight:only-of-type и использовать его для следующего HTML кода, то мы не получим никакого совпадения, поскольку класс не влияет на уменьшение области видимости.


<p>Не подсвечено</p>
<p class="highlight">подсвечено</p>
<p>Не подсвечено</p>

Если бы внутри родительского элемента был бы только один абзац с классом highlight, то могло бы ложно показаться, что он работает. Но в данном случае это потому, что тип корневого элемента, к которому присоединен класс, является абзацем, поэтому он соответствует истинным условиям, поскольку в нем нет родственных абзацев.

Комбинируя :has() и :not(), мы можем эффективно смоделировать псевдокласс :only-of-selector, который будет соответствовать статическому классу в диапазоне родственных элементов на основе класса или другого допустимого селектора.

В конечном итоге мы хотим, чтобы наш селектор соответствовал, когда нет подходящих родственных элементов, существующих до или после целевого объекта.

Сильной стороной псевдокласса :has() является проверка того, что следует за элементом. Поскольку мы хотим протестировать любое количество последующих элементов одного уровня, мы будем использовать общий комбинатор одноуровневых элементов ~ для создания первого условия.


.highlight:not(:has(~ .highlight))

Пока что это дает нам совпадение по условию "элементы с классам .highlight, за которыми не следуют одноуровневые элементы с классом .highlight".

Теперь нам нужно проверить предыдущие элементы того же уровня, и мы будем использовать возможности псевдокласса :not(), чтобы добавить это условие.


.highlight:not(:has(~ .highlight)):not(.highlight ~  *)

Второй псевдокласс :not() - это условие И для нашего селектора, в котором говорится "И сам по себе не является элементом того же уровня по отношению к предыдущему элементу с классом .highlight".

Таким образом мы создали полифилл несуществующего псевдокласса :only-of-selector!

Селектор предшествующего элемента того же уровня

Мы обсуждали проверку предшествующих элементов одного уровня при помощи псевдоклассов :not(), :is() и :where(). С помощью же псевдокласса :has() мы действительно можем выбирать и стилизовать предшествующие элементы того же уровня, основываясь на условиях, которые существуют после них!

Чтобы продемонстрировать это, мы создадим список элементов. Мы хотели бы, чтобы при наведении курсора мыши на элемент списка он увеличивался в размерах, а элементы до и после него также немного увеличивались. Остальные элементы списка, не попадающие в наведение, должны немного уменьшиться. Также, все элементы списка, кроме тех, на которые наводится курсор, должны стать прозрачнее. Следующий видеоролик демонстрирует такой список.

Цель первого селектора - соотнести элемент списка с тем, на который наведен указатель мыши, что и делает псевдокласс :has(). Следующий пример можно прочитать так: "выбрать элемент списка, у которого на соседний элемент наведен указатель мыши".


li:has(+ li:hover)

Мы свяжем его с базовым смежным селектором, чтобы также получить элемент списка, находящийся после того, на который наведен указатель мыши, а затем применим наши стили:


/* Выбрать элемент списка, который перед тем, на который наведен указатель */
li:has(+ li:hover),
/* Выбрать элемент списка, который после того, на который наведен указатель */
li:hover + li {
  /* ...изменить размер и прозрачность */
}

Третий сложный селектор, который мы создадим, использует мощную комбинацию псевдоклассов :has() и :not(), но по-новому. Сначала мы определяем, что селектор должен применяться только при наведении курсора на прямой дочерний элемент тега ul (который будет элементом списка). И если это так, мы выбираем элементы списка на основе исключения того, на который наведен указатель мыши, а также элементов перед и после того, на который наведен курсор.


/* Когда на элемент списка наводится указатель мыши,
выбираем элементы списка, на который указатель
не наведен, или которые перед/после наведения */
ul:has(> :hover) li:not(:hover, :has(+ :hover), li:hover + *) {
  /* ...изменить размер и прозрачность */
}

Здесь мы продемонстрировали не только выбор предыдущего одноуровневого элемента при помощи псевдокласса :has(), но и его использование для выбора на основе состояния элемент. В конце статьи мы покажем более сложный пример использования псевдокласса :has() и состояния элемента.

Выбор внутри диапазона

Давайте рассмотрим диапазон элементов одного уровня, например, между элементами h2 или hr.


<article>
  <h2>Lorem, ipsum.</h2>
  <!-- начало диапазона h2 -->
  <p>Lorem ipsum, dolor sit amet consectetur adipisicing elit.</p>
  <p>Nobis iusto voluptates reiciendis molestias, illo inventore ipsum?</p>
  <!-- конец диапазона h2 -->
  <h2>Lorem, ipsum dolor.</h2>
  <p>Lorem ipsum dolor sit amet.</p>
  <hr>
  <!-- начало диапазона hr -->
  <p>Lorem ipsum dolor sit.</p>
  <p>Dolor animi nisi ut?</p>
  <p>Sunt consectetur esse quia.</p>
  <!-- конец диапазона hr -->
  <hr>
  <p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
</article>

Используя псевдокласс :has() мы можем определить стили

  • для первого элемента в диапазоне,
  • для последнего элемента в диапазоне,
  • для всех элементов одного уровня в диапазоне.

Эти селекторы будут в значительной степени зависеть от общего комбинатора родственных элементов ~, который позволяет нам одновременно "заглядывать вперед" и стилизовать несколько родственных элементов.

ВЫБОР ПЕРВОГО ЭЛЕМЕНТА В ДИАПАЗОНЕ

Следующую декларацию можно расшифровать так: "выбрать соседний с h2 элемент при условии, что есть другой h2 того же уровня, расположенный позже". Если смотреть на наш пример HTML, приведенный выше, то будет выбран абзац, следующий сразу же за первым элементом h2.


article h2 + :has(~ h2)

ВЫБОР ПОСЛЕДНЕГО ЭЛЕМЕНТА В ДИАПАЗОНЕ

Следующая декларация гласит: "выбрать элемент, который следует за h2 при условии, что его следующим родственным элементом является h2". Если смотреть на наш пример HTML, приведенный выше, то будет выбран абзац, находящийся сразу же перед вторым элементом h2.


article h2 ~ :has(+ h2)

ВЫБОР ВСЕХ ЭЛЕМЕНТОВ ОДНОГО УРОВНЯ В ДИАПАЗОНЕ

Следующий селектор ограничен тем, что работает только для одного диапазона внутри родителя. Это связано с тем, что при использовании общего комбинатора родственных элементов без жесткой остановки эти родственные элементы могут находиться в любом месте после данного элемента. Таким образом, сюда попадают "скачкообразное переключение" других элементов, которые могут находиться между ними, что приводит к расширению диапазона для этого селектора.

Тем не менее, такой селектор может быть полезен в том случае, если вы уверены, что у вас есть только один диапазон возможных элементов в рамках данного родителя. Этот селектор гласит: "выберите все элементы одного уровня, следующие за hr, за которыми в свою очередь есть элемент hr того же уровня". Если смотреть на наш пример HTML, приведенный выше, то будут выбраны все три абзаца, находящиеся между элементами hr.


article hr ~ :has(~ hr)

Ниже мы рассмотрим, как смягчить проблему необходимости жесткого ограничения нашего диапазона и разрешить многодиапазонные группы в пределах одного родительского элемента.

Выбор одиночного полного диапазона

В следующей серии селекторов мы предположим, что у нас есть дополнительные данные идентификации, которые можно использовать для определения начала и конца нашего диапазона. Для демонстрации этого, возьмем список, в котором два элемента имеют дополнительный атрибут data-range. Этот метод подойдет для визуализации функциональности множественного выбора для пользовательского элемента управления, чтобы показать диапазон элементов в пределах выделения.


<ul>
  <li>Lorem</li>
  <li data-range>Veritatis</li>
  <li>Eos</li>
  <li>Debitis</li>
  <li>Autem</li>
  <li data-range>Atque</li>
  <li>Eius</li>
  <li>Lorem</li>
  <li>Nostrum</li>
</ul>

Хотя мы использовали атрибут данных для обозначения начала и конца диапазона, мы не задаем для него значение. Имейте это в виду при рассмотрении конструкции, в частности, селекторов начального и конечного элементов.

Чтобы выбрать начало и конец диапазона одновременно, мы можем просто использовать селектор атрибутов [data-range].

Затем, в качестве шаблона, мы можем использовать селектор, который мы уже создали в предыдущем разделе, чтобы выбрать все элементы одного уровня в пределах диапазона.


[data-range] ~ :has(~ [data-range])

Чтобы задать стили начального элемента диапазона, создаем декларацию, которую можно прочитать как: "выбрать элемент с атрибутом [data-range], после которого где-то есть элемент с атрибутом [data-range] того же уровня":


[data-range]:has(~ [data-range])

И, наконец, для выбора конечного элемента диапазона пишем декларацию, которая гласит: "выбрать элемент с атрибутом [data-range], перед которым где-то есть элемент с атрибутом [data-range] того же уровня".


[data-range] ~ [data-range]

В следующей демонстрации CodePen мы также повторно использовали наши ранее созданные селекторы для определения первого и последнего элемента в диапазоне.

Выбор многодиапазонных групп

Теперь мы улучшим нашу декларацию из предыдущего раздела и решим проблему выбора нескольких диапазонов.

Ранее, наша проблема заключалась в том, что, если элементов h2 или hr было больше двух, то они не могли определять несколько диапазонов, потому что не было способа определить границу для областей за пределами предполагаемого диапазона.

Ключом к созданию многодиапазонных групп в пределах одного родительского элемента является наличие различимых начальных и конечных индикаторов.

Мы снова будем использовать атрибуты данных для элементов нашего списка, но на этот раз присвоим им фактические значения start - начало и end - конец.


<ul>
  <li>Lorem</li>
  <li data-range="start">Veritatis</li>
  <li>Eos</li>
  <li>Debitis</li>
  <li>Autem</li>
  <li data-range="end">Atque</li>
  <li>Eius</li>
  <li>Lorem</li>
  <li>Nostrum</li>
</ul>

Поскольку мы задали явные значения атрибутов, наши селекторы начала и конда диапазона довольно просты:


/* Начальный и конечный элементы диапазона */
[data-range]

/* Начальный элемент диапазона */
[data-range="start"]

/* Конечный элемент диапазона */
[data-range="end"]

Давайте продолжим и отметим первый и последний элементы в диапазоне. Во-первых, обновим предыдущую версию кода добавив атрибутам данных значения start/end. Во-вторых, добавим условие, по которому стили не должны применяться к нашим индикаторам "начало/конец" при помощи исключения :not([data-range]).


/* Первый элемент внутри диапазона */
[data-range="start"] + :has(~ [data-range="end"]):not([data-range])

/* Последний элемент внутри диапазона */
[data-range="start"] ~ :has(+ [data-range="end"]):not([data-range])

Наконец, нам нужен селектор для сопоставления элементов в пределах нашего диапазона. Сначала он начинается аналогично тому, что мы создали ранее для селекторов "в пределах диапазона". Опять же, мы добавляем условие, что он не соответствует элементу, который сам по себе является [data-range].


[data-range="start"] ~ :has(~ [data-range="end"]):not([data-range])

Но, если вы помните, то мы уже говорили о том, что у общего селектора одноуровневых элементов есть способность скачкообразно перепрыгивать, так что в настоящее время этот селектор будет стилизовывать элементы за пределами нашего предполагаемого диапазона. На рисунке показано, как работает это правило без дополнительных ограничений.

Стили вне диапазона
Стили вне диапазона

На рисунке показан список элементов, содержащий один полный диапазон, несколько промежуточных элементов и начало другого диапазона. Селектор пытается применить фоновый цвет к элементам в диапазоне, но, как показывают пояснения на рисунке, цвет фона применяется не только в пределах диапазона, но также добавляется к элементам между диапазонами

Чтобы решить эту проблему, нам нужно добавить сложное условие И, использующее псевдокласс :not() для исключения элементов, которые не находятся между индикаторами диапазона [data-range="end"] и [data-range="start"], в таком порядке.

Сама по себе эта часть селектора читается следующим образом: "не выбирать элементы, которые стоят после элемента с атрибутом [data-range="end"] и за которыми также идет элемент того же уровня с атрибутом [data-range="start"]".


/* Внимание: это нужно присоединить к предыдущему селектору,
 а не использовать самостоятельно */
:not([data-range="end"] ~ :has(~ [data-range="start"]))

В целом, получается довольно длинный, но очень мощный селектор, который раньше до появления псевдокласса :has() без использования JavaScript был невозможен из-за отсутствия у инструментария CSS способности "смотреть вперед" и "заглядывать за пределы".


/* Выбрать все внутри диапазона */
[data-range="start"] ~ :has(~ [data-range="end"]):not([data-range]):not([data-range="end"] ~ :has(~ [data-range="start"]))

Имейте в виду, что, как и другие селекторы, вы можете использовать :has() при создании селекторов в JavaScript. Возможность выбирать предыдущие элементы того же уровня, предков и другие функции, о которых мы узнали, также повысят эффективность ваших JS-селекторов!

Линейный селектор диапазона на основе состояния элемента

Давайте объединим некоторые возможности селекторов и комбинаторов с использованием псевдокласса :has(), о которых мы узнали, чтобы создать компонент звездного рейтинга.

В качестве "звездочки" у нас будет служить элемент ввода типа радиокнопка (переключатель), который даст нам доступ к состоянию :checked, на основе чего мы и будем строить наши селекторы.


<div class="star-rating">
  <fieldset>
    <legend>Оцените это демо</legend>
    <div class="stars">
      <label class="star">
        <input type="radio" name="rating" value="1">
        <span>1</span>
      </label>
      <!-- ...еще 4 звезды -->
    </div>
  </fieldset>
</div>

Как показано в следующем видео, когда пользователь наводит курсор на звездочки, диапазон от начальной (самой левой) до наведенной звездочки должен заполниться цветом. При выборе, когда у "звездочки" устанавливается состояние :checked, звезда и номер метки увеличиваются в размере и сохраняют цвет заливки. Если пользователь наводит курсор на звездочки после отмеченной звезды, диапазон должен заполнять звезды до наведения курсора. Если пользователь наводит курсор на звездочки перед отмеченной звездочкой, диапазон должен заполняться только до наведенной звездочки, а у звездочек между наведенной и ранее отмеченной звездочкой цвет заливки должен быть светлее.

Здесь необходимо отслеживать множество диапазонов, но при помощи псевдокласса :has() мы можем очень быстро разбить их на сегментированные селекторы!

Следующая серия селекторов применяется ко всем состояниям, где мы хотим, чтобы звезда или диапазон звезд заполнялись или доходили до заезды с состоянием :checked. Правило обновляет набор пользовательских свойств, которые будут влиять на форму звезды, созданную с помощью комбинации ::before и ::after на элементах label.star.

В целом, это правило выбирает диапазон звезд между первой звездой и звездой, на которую наведен курсор, или первой звездой и нажатой звездой.


.star:hover,
/* Предшествующий элемент одного уровня с звездой, на которую наведен курсор */
.star:has(~ .star:hover),
/* Нажатая звезда */
.star:has(:checked),
/* Предшествующий элемент одного уровня с нажатой звездой */
.star:has(~ .star :checked) {
  --star-rating-bg: dodgerblue;
}

Далее мы хотим осветлить цвет заливки звезд в диапазоне между звездой, на которую наводится курсор, и ранее нажатой звездой, и нажатой звездой, которая следуют за звездой, на которую наводится курсор.


/* Одноуровневые элементы между звездой, на которую наводится курсор, и нажатой звездой */
.star:hover ~ .star:has(~ .star :checked),
/* Нажатая звезда следующая за звездой, на которую наводится курсор */
.star:hover ~ .star:has(:checked) {
  --star-rating-bg: lightblue;
}

Что касается селекторов состояний для нашего компонента звездного рейтинга, то это все, что нужно!

Группы выбора нескольких диапазонов с динамической фильтрацией

В то время как компонент "звездный рейтинг" демонстрировал динамическое изменение стиля в зависимости от состояния, использование :has() также упрощает создание визуальных границ для элементов с динамической фильтрацией.

Наши предыдущие селекторы, работавшие с несколькими диапазонами, полагались на "зацепки", добавленные в HTML код вручную, чтобы можно было правильно оформить нужные диапазоны и исключить утечки в промежуточные области. Но если у нас есть набор полей, содержащих флажки, мы можем снова использовать состояние :checked для четкого определения границ вокруг отмеченных и неотмеченных элементов.

На следующем видео при нажатии на флажок или чекбокс, для создания визуальной границы он получает рамку и зеленый фон. Благодаря :has() эта граница расширяется и охватывает группы отмеченных элементов, так что визуальное поле выглядит так, как будто оно охватывает всю группу. У границы вокруг первого элемента верхние углы закруглены, а у последнего элемента закруглены нижние углы, а также есть небольшая тень.

Нам нужно создать правила для определения внешнего вида верха, середины и низа в зависимости от того, где в наборе находится выбранный элемент. Отдельные элементы должны иметь все три стиля.

Наш HTML настроен так, чтобы у каждого флагового поля ввода была своя метка, поэтому все наши селекторы будут начинаться с конструкции label:has(:checked), чтобы проверить, содержит ли метка отмеченное поле.

Чтобы определить является ли элемент первым в наборе или отдельным, нам нужно проверить, что перед ним нет другого отмеченного элемента. Это правило будет определять внешний вид верхнего элемента.


/* Первый отмеченный элемент в диапазоне
 ИЛИ верх отдельного отмеченного элемента */
label:has(:checked):not(label:has(:checked) + label)

Чтобы определить является ли элемент последним в наборе или отдельным, мы переворачиваем предыдущее условие, чтобы убедиться, что за этим элементом не следует другой отмеченный элемент. Это правило изменит внешний вид нижней части.


/* Последний отмеченный элемент в диапазоне
 ИЛИ низ отдельного отмеченного элемента */
label:has(:checked):not(label:has(+ label :checked))

Для внешнего вида середины мы создадим правило, которое фактически охватывает группу от начала до конца, поскольку все элементы в правиле должны иметь цвет фона и боковые границы.

Учитывая контекст для этого селектора мы могли бы просто использовать конструкцию label:has(:checked). Однако мы учимся выбирать и стилизовать диапазоны, поэтому для завершения нашего упражнения мы напишем расширенные селекторы.

У следующего первого селектора такая логика: "выбираем метки с отмеченными элементами, за которыми следуют одноуровневые метки, содержащие отмеченные элементы ввода". Это охватывает все элементы диапазона, кроме последнего. Для этого мы повторим селектор, который мы только что создали, для стилизации последнего отмеченного элемента в диапазоне.


/* Диапазон отмеченных элементов */
label:has(:checked):has(~ label :checked),
label:has(:checked):not(label:has(+ label :checked))