Тот, у кого из всех инструментов есть только молоток, склонен на любую проблему смотреть, как на гвоздь.
Абрахам Маслоу
Мы склонны использовать знакомые решения. Когда речь заходит о переключении контента, мы обычно используем свойства display: none
или opacity: 0
с добавлением JavaScript. Однако современный веб стремительно развивается, и, возможно, настало время рассмотреть другие подходы к переключению контента — узнать, какие нативные API на сегодняшний день поддерживаются, их достоинства и недостатки, а также некоторые нюансы, о которых мы могли и не подозревать (включая псевдоэлементы и другие малоизвестные вещи).
Давайте уделим внимание раскрывающимся элементам (<details>
и <summary>
), API диалоговых окон, API всплывающих окон и другим. Рассмотрим, когда лучше применять каждое из этих решений в зависимости от конкретных потребностей. Модальные или немодальные окна? JavaScript или чистый HTML/CSS? Есть сомнения? Не переживайте, мы во всем этом разберемся.
❯ Раскрывающиеся элементы (<details>
и <summary>
)
Сценарий использования: доступное "резюмирование" контента с возможностью переключения деталей содержимого независимо друг от друга или в виде "аккордеона".
В соответствии с порядком релизов, раскрывающиеся элементы — известные как теги <details>
и <summary>
— стали первой возможностью переключать контент без использования JS или сложных решений с чекбоксами. Однако на начальных этапах эти возможности плохо поддерживались браузерами и не были доступны с помощью клавиатуры. Поэтому я прекрасно понимаю тех, кто не пользовался этой функцией с момента ее появления в Chrome 12 еще в 2011 году. С глаз долой — из сердца вон, так ведь?
Основные моменты:
- работает без JS
- является полностью стилизуемой без применения
appearance: none
или аналогичных свойств - имеется возможность скрыть маркер без использования нестандартных псевдоселекторов
- можно объединять несколько раскрывающихся элементов для создания "аккордеона"
- и… начиная с 2024 года, поддерживает анимацию
Разметка раскрывающихся элементов
Вот, что нам нужно:
<details>
<summary>Content summary (всегда видно)</summary>
Content (видимость переключается при нажатии на summary)
</details>
Под капотом содержимое оборачивается в псевдоэлемент, который с 2024 года может быть выбран с помощью ::details-content
. Кроме того, существует псевдоэлемент ::marker
, который указывает, открыт раскрывающийся список или закрыт, и его можно стилизовать в соответствии с нашими потребностями.
Таким образом, на техническом уровне раскрывающиеся элементы выглядят так:
<details>
<summary><::marker></::marker>Content summary (всегда видно)</summary>
<::details-content>
Content (видимость переключается при нажатии на summary)
</::details-content>
</details>
Чтобы элемент был открыт по умолчанию, достаточно добавить атрибут open
к <details>
. Это же происходит и под капотом, когда элемент открыт:
<details open>...</details>
Стилизация раскрывающихся элементов
Будем честны, нам всем хочется избавиться от этого раздражающего маркера. Сделать это можно, установив свойство display
для <summary>
в любое значение, кроме list-item
:
summary {
display: block; /* Или любое другое значение, кроме list-item*/
}
Другим вариантом может быть изменение внешнего вида маркера. В приведенном ниже примере используется библиотека Font Awesome для замены стандартного маркера на другую иконку. Однако стоит учитывать, что псевдоэлемент ::marker
поддерживает ограниченное количество свойств стилизации. Поэтому более гибким решением является оборачивание содержимого элемента <summary>
в контейнер и применение к нему стилей через CSS:
<details>
<summary><span>Content summary</span></summary>
Content
</details>
details {
/* Маркер */
summary::marker {
content: "\f150";
font-family: "Font Awesome 6 Free";
}
/* Маркер открытого <details> */
&[open] summary::marker {
content: "\f151";
}
/* Поскольку ::marker поддерживает ограниченное количество свойств */
summary span {
margin-left: 1ch;
display: inline-block;
}
}
Создание "аккордеона" с несколькими раскрывающимися элементами
Для создания "аккордеона" необходимо присвоить нескольким раскрывающимся элементам атрибут name
с одинаковым значение (аналогично тому, как реализуется <input type="radio">
):
<details name="starWars" open>
<summary>Prequels</summary>
<ul>
<li>Episode I: The Phantom Menace</li>
<li>Episode II: Attack of the Clones</li>
<li>Episode III: Revenge of the Sith</li>
</ul>
</details>
<details name="starWars">
<summary>Originals</summary>
<ul>
<li>Episode IV: A New Hope</li>
<li>Episode V: The Empire Strikes Back</li>
<li>Episode VI: Return of the Jedi</li>
</ul>
</details>
<details name="starWars">
<summary>Sequels</summary>
<ul>
<li>Episode VII: The Force Awakens</li>
<li>Episode VIII: The Last Jedi</li>
<li>Episode IX: The Rise of Skywalker</li>
</ul>
</details>
С помощью контейнера их можно превратить в горизонтальные вкладки:
<div> <!-- Flex-контейнер -->
<details name="starWars" open> ... </details>
<details name="starWars"> ... </details>
<details name="starWars"> ... </details>
</div>
div {
gap: 1ch;
display: flex;
position: relative;
details {
min-height: 106px; /* Предотвращает смещение контента */
&[open] summary,
&[open]::details-content {
background: #eee;
}
&[open]::details-content {
left: 0;
position: absolute;
}
}
}
…или, используя Anchor Positioning API 2024 года, в вертикальные вкладки (разметка такая же):
div {
display: inline-grid;
anchor-name: --wrapper;
details[open] {
summary,
&::details-content {
background: #eee;
}
&::details-content {
position: absolute;
position-anchor: --wrapper;
top: anchor(top);
left: anchor(right);
}
}
}
Добавление функциональности с помощью JS
Хотите добавить некоторую функциональность с помощью JS?
// Перебираем раскрывающиеся элементы
document.querySelectorAll("details").forEach((details) => {
// Обрабатываем переключение видимости контента
details.addEventListener("toggle", () => {
if (details.open) {
// Элемент открыт
} else {
// Элемент закрыт
}
});
});
Создание доступных раскрывающихся списков
Раскрывающиеся элементы считаются доступными, если соблюдается несколько ключевых правил. Например, элемент <summary>
выполняет функцию <label>
. Это означает, что его содержимое озвучивается устройствами чтения с экрана, когда он находится в фокусе. Если <summary>
отсутствует или не является прямым дочерним элементом <details>
, браузер создаст метку по умолчанию, зачастую отображаемую как "Подробности" как визуально, так и во вспомогательных технологиях. Важно учитывать, что старые браузеры могут требовать, чтобы этот элемент был первым дочерним элементом (лучше следовать этому правилу).
Кроме того, элемент <summary>
имеет роль кнопки, поэтому все, что недопустимо для <button>
, также недопустимо для <summary>
. К примеру, можно стилизовать элемент <summary>
как заголовок, однако вставлять заголовки внутрь него нельзя.
❯ Диалоговые окна (<dialog>
)
Сценарий использования: модальные окна
Теперь, когда у нас есть Popover API для создания немодальных окон (non-modal overlays), диалоговые окна следует считать модальными, хотя метод show()
по-прежнему позволяет реализовывать немодальные диалоговые окна. Преимуществом атрибута popover
по сравнению с элементом <dialog>
является возможность создания немодальных оверлеев без использования JS. Таким образом, немодальные диалоговые окна теряют свою актуальность, так как требуют выполнения кода на JS.
Для ясности: модальное окно — это оверлей, который делает основное содержание страницы неактивным (инертным), тогда как немодальные оверлеи позволяют взаимодействовать с основной страницей.
Модальные окна имеют несколько дополнительных функций, среди которых:
- настраиваемый фон
- автоматический фокус на первом доступном элементе внутри
<dialog>
(в качестве запасного варианта — на самом<dialog>
, при этом стоит добавитьaria-label
) - захват фокуса (в результате инертности основной страницы)
- закрытие диалогового окна клавишей
Esc
- анимация как самого окна, так и фона
Начнем с элемента <dialog>
:
<dialog>...</dialog>
По умолчанию элемент <dialog>
скрыт, и, подобно <details>
, его можно открыть при загрузке страницы. Однако в этом случае он не будет являться модальным окном, так как не содержит интерактивного содержимого и не открывается с помощью метода showModal()
.
<dialog open>...</dialog>
Трудно представить, что такая функциональность может быть сильно востребована. Скорее всего, диалоговое окно целесообразно открывать в ответ на определенные действия, например, при нажатии кнопки. Вот пример такой кнопки:
<button data-dialog="dialogA">Open dialogA</button>
Зачем нужен дата-атрибут? Он позволяет передать идентификатор, который указывает JS, какое диалоговое окно следует открыть. Это решение дает возможность интегрировать функциональность окна для всех элементов с помощью одного фрагмента кода, например:
// Перебираем все элементы с атрибутом data-dialog
document.querySelectorAll("[data-dialog]").forEach(button => {
// Обрабатываем взаимодействие (клик)
button.addEventListener("click", () => {
// Выбираем соответствующее диалоговое окно
const dialog = document.querySelector(`#${ button.dataset.dialog }`);
// Открываем его
dialog.showModal();
// Закрываем
dialog.querySelector(".closeDialog").addEventListener("click", () => dialog.close());
});
});
Не забываем присвоить элементу <dialog>
соответствующий идентификатор, чтобы он был связан с кнопкой <button>
, которая управляет отображением его содержимого:
<dialog id="dialogA"> <!-- id and data-dialog = dialogA --> ... </dialog>
Наконец, добавляем кнопку:
<dialog id="dialogA">
<button class="closeDialog">Close dialogA</button>
</dialog>
Блокировка прокрутки страницы при открытом диалоговом окне
Чтобы предотвратить прокрутку страницы, когда диалоговое окно открыто, достаточно добавить одну строчку CSS:
body:has(dialog:modal) {
overflow: hidden;
}
Стилизация фона диалогового окна
Фон играет важную роль в снижении отвлечения внимания от контента, находящегося под верхним слоем (это касается только модальных окон). Его стили можно изменить следующим образом:
::backdrop {
background: hsl(0 0 0 / 90%);
backdrop-filter: blur(3px); /* Интересное свойство, предназначенное только для фонов */
}
Элемент <dialog>
имеет рамку (border
), фон (background
) и отступы (padding
), которые можно менять. Аналогичное поведение наблюдается и у всплывающих окон (popover
).
Немодальные диалоговые окна
Для реализации немодального диалогового окна необходимо использовать:
-
show()
вместоshowModal()
-
dialog[open]
(охватывает оба варианта) вместоdialog:modal
.
Как уже упоминалось, Popover API не требует использования JS, поэтому для немодальных оверлеев лучше всего применять именно его.
❯ Popover API (<element popover>
)
Сценарий использования: немодальные оверлеи
По сути, это всплывающие окна. Могут использоваться в качестве таких элементов, как подсказки (tooltips) (или переключаемые подсказки (toggletips) — важно понимать разницу между ними), уведомления, переключаемая навигация и другие немодальные оверлеи, которые позволяют сохранять доступ к основной странице. Хотя эти варианты и отличаются от сценариев использования диалоговых окон, поповеры обладают множеством достоинств. Функционально они аналогичны диалоговым окнам, но не являются модальными и не требуют использования JS.
Разметка поповеров
Для начала поповер должен иметь идентификатор, а также атрибут popover
с одним из значений: manual
(это означает, что клик вне поповера не закрывает его), auto
(клик вне поповера закрывает его) или без значения (что эквивалентно значению auto
). Для соблюдения семантики поповер можно оформить в виде элемента <dialog>
.
<dialog id="tooltipA" popover> ... </dialog>
Следующим шагом добавляем атрибут popovertarget
к элементу <button>
или <input type="button">
, который будет управлять видимостью поповера. Значение этого атрибута должно совпадать со значением атрибута popover
:
<dialog id="tooltipA" popover>
<button popovertarget="tooltipA">Hide tooltipA</button>
</dialog>
Поместим еще одну такую кнопку на основную страницу, чтобы можно было отображать поповер. Дело в том, что атрибут popovertarget
фактически выполняет функцию переключателя (если только не указано другое с помощью атрибута popovertargetaction
, который принимает значения show
, hide
или toggle
— подробнее об этом ниже).
Стилизация поповеров
По умолчанию поповеры располагаются по центру экрана на верхнем слое (также как диалоговые окна), однако, скорее всего, нам захочется изменить их расположение, так как они не являются модальными окнами.
<main>
<button popovertarget="tooltipA">Show tooltipA</button>
</main>
<dialog id="tooltipA" popover>
<button popovertarget="tooltipA">Hide tooltipA</button>
</dialog>
Поповеры можно легко разместить в углу с помощью фиксированной позиции, но для поповеров в стиле подсказок следует использовать позиционирование относительно элемента, который их открывает. С помощью CSS Anchor Positioning сделать это очень просто:
main [popovertarget] {
anchor-name: --trigger;
}
[popover] {
margin: 0;
position-anchor: --trigger;
top: calc(anchor(bottom) + 10px);
justify-self: anchor-center;
}
/* Тоже сработает, но не требуется, если не используется свойство display [popover]:popover-open {
...
}
*/
Проблема заключается в том, каждый якорь (anchor) должен иметь уникальное имя. Это имеет смысл для компонентов, вроде вкладок, но становится излишним для сайтов с большим количеством подсказок. К счастью, существует возможность сопоставить атрибут id
на кнопке с атрибутом anchor
на поповере. Хотя на ноябрь 2024 года поддержка этой функции оставляет желать лучшего, для демонстрации этого подхода ее вполне достаточно:
<main>
<!-- id должен совпадать с атрибутом anchor -->
<button id="anchorA" popovertarget="tooltipA">Show tooltipA</button>
<button id="anchorB" popovertarget="tooltipB">Show tooltipB</button>
</main>
<dialog anchor="anchorA" id="tooltipA" popover>
<button popovertarget="tooltipA">Hide tooltipA</button>
</dialog>
<dialog anchor="anchorB" id="tooltipB" popover>
<button popovertarget="tooltipB">Hide tooltipB</button>
</dialog>
main [popovertarget] { anchor-name: --anchorA; } /* Больше не нужен */
[popover] {
margin: 0;
position-anchor: --anchorA; /* Больше не нужен */
top: calc(anchor(bottom) + 10px);
justify-self: anchor-center;
}
Следующая проблема заключается в том, что мы ожидаем отображения подсказок при наведении курсора, однако текущая реализация этого не поддерживает, что требует использования JS. Хотя может показаться, что это усложняет задачу, учитывая возможность создания подсказок с помощью псевдоэлементов ::before/::after/content:
, поповеры позволяют использовать любой HTML (в данном случае наши подсказки можно считать "переключаемыми"), в то время как свойство content:
поддерживает только текст.
Добавление функциональности с помощью JS
Разберемся, что здесь происходит. Во-первых, мы используем атрибуты anchor
, чтобы избежать написания отдельного CSS-кода для каждого элемента. Поскольку поповеры преимущественно ориентированы на HTML, мы применяем аналогичный подход к их позиционированию. Во-вторых, мы используем JS для отображения поповеров (метод showPopover()
) при наведении курсора на элементы. Также мы используем JS для скрытия поповеров (метод hidePopover()
) при снятии курсора, однако не скрываем их, если они содержат ссылки, поскольку они должны оставаться кликабельными. В этой ситуации мы также не скрываем кнопку, отвечающую за закрытие поповера.
<main>
<button id="anchorLink" popovertarget="tooltipLink">Открыть tooltipLink</button>
<button id="anchorNoLink" popovertarget="tooltipNoLink">Открыть tooltipNoLink</button>
</main>
<dialog anchor="anchorLink" id="tooltipLink" popover>Содержит <a href="#">ссылку</a>, поэтому не скрывается при снятии курсора
<button popovertarget="tooltipLink">Скрыть tooltipLink вручную</button>
</dialog>
<dialog anchor="anchorNoLink" id="tooltipNoLink" popover>Не содержит ссылку, поэтому автоматически скрывается при снятии курсора
<button popovertarget="tooltipNoLink">Скрыть tooltipNoLink</button>
</dialog>
[popover] {
margin: 0;
top: calc(anchor(bottom) + 10px);
justify-self: anchor-center;
/* Нет ссылки? Тогда кнопка не нужна. */
&:not(:has(a)) [popovertarget] {
display: none;
}
}
/* Перебираем все триггеры поповеров */
document.querySelectorAll("main [popovertarget]").forEach((popovertarget) => {
/* Выбираем соответствующий поповер */
const popover = document.querySelector(`#${popovertarget.getAttribute("popovertarget")}`);
/* Отображаем поповер при наведении курсора на триггер */
popovertarget.addEventListener("mouseover", () => {
popover.showPopover();
});
/* Скрываем поповер при снятии курсора с триггера, если он не содержит ссылку */
if (popover.matches(":not(:has(a))")) {
popovertarget.addEventListener("mouseout", () => {
popover.hidePopover();
});
}
});
Реализация временного фона (и последовательных поповеров)
Изначально мне казалось, что наличие фона у поповеров — это недоразумение, так как они не должны скрывать содержание основной страницы. Тем не менее, это может быть оправдано на протяжении нескольких секунд, если у пользователя есть возможность продолжать работу без необходимости закрывать что-либо. По крайней мере, такое решение может оказаться пригодным для нескольких последовательных подсказок:
<!-- Повторное отображение элемента 'A' возвращает процесс обучения к началу -->
<button popovertarget="onboardingTipA" popovertargetaction="show">Перезапустить обучение</button>
<!-- Скрытие элемента 'A' также скрывает последующие подсказки, если атрибут popover установлен в значение auto -->
<button popovertarget="onboardingTipA" popovertargetaction="hide">Отменить обучение</button>
<ul>
<li id="toolA">Tool A</li>
<li id="toolB">Tool B</li>
<li id="toolC">Another tool, "C"</li>
<li id="toolD">Another tool - let’s call this one "D"</li>
</ul>
<!-- Кнопка onboardingTipA активирует onboardingTipB -->
<dialog anchor="toolA" id="onboardingTipA" popover>
onboardingTipA <button popovertarget="onboardingTipB" popovertargetaction="show">Следующая подсказка</button>
</dialog>
<!-- Кнопка onboardingTipB активирует onboardingTipC -->
<dialog anchor="toolB" id="onboardingTipB" popover>
onboardingTipB <button popovertarget="onboardingTipC" popovertargetaction="show">Следующая подсказка</button>
</dialog>
<!-- Кнопка onboardingTipC активирует onboardingTipD -->
<dialog anchor="toolC" id="onboardingTipC" popover>
onboardingTipC <button popovertarget="onboardingTipD" popovertargetaction="show">Следующая подсказка</button>
</dialog>
<!-- Кнопка onboardingTipD скрывает onboardingTipA, что, в свою очередь, приводит к скрытию всех подсказок -->
<dialog anchor="toolD" id="onboardingTipD" popover>
onboardingTipD <button popovertarget="onboardingTipA" popovertargetaction="hide">Закончить обучение</button>
</dialog>
::backdrop {
animation: 2s fadeInOut;
}
[popover] {
margin: 0;
align-self: anchor-center;
left: calc(anchor(right) + 10px);
}
/*
После того, как пользователи получили несколько секунд на размышления,
запускаем процесс обучения
*/
setTimeout(() => {
document.querySelector("#onboardingTipA").showPopover();
}, 2000);
Во-первых, функция setTimeout()
позволяет показать первую обучающую подсказку через две секунды. Во-вторых, на заднем плане запускается простая анимация затухания, которая применяется к текущему фону и ко всем последующим. При этом основная страница остается активной, а затемненный фон не задерживается на экране, что помогает сосредоточить внимание на подсказках.
В-третьих, каждый поповер имеет кнопку, запускающую следующую подсказку, создавая цепочку, которая формирует полноценный поток обучения на HTML. Обычно, когда открывается один поповер, остальные закрываются, но в данном случае это не так, если новое событие инициируется из уже открытого поповера. Кроме того, повторное отображение видимого поповера возвращает пользователя на этот шаг, а скрытие поповера одновременно закрывает его и все последующие — хотя это работает только если popover
установлен в auto
. Я не до конца понимаю это поведение, но оно дало возможность добавить кнопки "Перезапустить обучение" и "Отменить обучение".
И все это реализовано исключительно с помощью HTML. Кроме того, пользователи могут переключаться между подсказками, используя клавиши esc
и return
.
Создание модальных поповеров
Если вам нравится простота и удобство HTML-поповеров, но вы также цените семантическую значимость элемента <dialog>
, то с помощью этого однострочного кода на JS можно сделать основную страницу инертной, превращая поповеры в модальные окна:
document.querySelectorAll("dialog[popover]").forEach(dialog => dialog.addEventListener("toggle", () => document.body.toggleAttribute("inert")));
Тем не менее, поповеры должны располагаться после содержания основной страницы, иначе они также станут инертными. Лично я придерживаюсь этого принципа и для модальных окон, так как они не являются частью содержимого страницы:
<body>
<!-- Все это станет неактивным -->
</body>
<!-- Таким образом, модальные окна должны располагаться ниже основного содержимого -->
<dialog popover> ... </dialog>
И… можно выдохнуть
Да, информации действительно было много. Однако я полагаю, что важно проанализировать все эти API в совокупности, особенно теперь, когда они начинают стремительно развиваться. Это позволит глубже понять их возможности и ограничения, а также понять, когда и какой API использовать.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩