Различные (и современные) способы переключения контента
Легко ориентироваться на то, что знакомо. Когда речь идёт о переключении содержимого, это может быть display: none
или opacity: 0
с использованием JavaScript. Но сегодня веб стал более современным
, поэтому, возможно, самое время взглянуть на различные способы переключения содержимого с высоты птичьего полёта — какие нативные API поддерживаются сейчас, их плюсы и минусы, а также некоторые вещи, о которых вы могли не знать (например, псевдоэлементы и другие неочевидные вещи).
Итак, давайте потратим некоторое время на рассмотрение раскрытия (<details>
и <summary>
), Dialog API, Popover API и прочего. Рассмотрим, как правильно использовать каждый из них в зависимости от требований. Модальный или немодальный? JavaScript или чистый HTML/CSS? Не уверены? Не волнуйтесь, мы расскажем обо всём этом.
Раскрытие (<details>
и <summary>
)
Пример использования: Доступное раскрытие контента details
/summary
независимо или в виде аккордеона.
Если идти в порядке появления, то раскрытие — известное по элементам <details>
и <summary>
— ознаменовало собой первый случай, когда можно было переключать содержимое без JavaScript или странных хаков с checkbox
. Но отсутствие поддержки веб-браузеров, очевидно, сдерживает появление новых функций, и эта, в частности, появилась без возможности использования клавиатуры. Поэтому я пойму, если вы не пользовались этой функцией с тех пор, как она появилась в Chrome 12 в 2011 году. С глаз долой, из сердца вон, верно?
Вот краткая информация:
- Функционирует без JavaScript (без каких-либо компромиссов).
- Полностью стилизуется без использования
appearance: none
и т. п. - Вы можете скрыть маркер без нестандартных псевдоселекторов.
- Вы можете соединить несколько раскрытий, чтобы создать аккордеон.
- А ещё… он полностью анимирован, начиная с 2024 года.
Разметка раскрытия
То, что вы ищете, заключается в следующем:
<details>
<summary>Content summary (always visible)</summary>
Content (visibility is toggled when summary is clicked on)
</details>
За кулисами содержимое обёрнуто в псевдоэлемент, который, начиная с 2024 года, можно выбрать с помощью ::details-content
. В дополнение к этому есть псевдоэлемент ::marker
, указывающий, открыто или закрыто раскрытие, который можно настроить.
Учитывая это, раскрытие в действительности выглядит следующим образом:
<details>
<summary><::marker></::marker>Content summary (always visible)</summary>
<::details-content>
Content (visibility is toggled when summary is clicked on)
</::details-content>
</details>
Чтобы раскрытие было открыто по умолчанию, присвойте атрибуту <details>
значение open
— в любом случае, именно это происходит за кулисами при открытии раскрытия.
<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, вертикальные вкладки (с тем же HTML):
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);
}
}
}
Добавление функциональности JavaScript
Хотите добавить функциональность JavaScript?
// Дополнительно: выбор и обход нескольких раскрытий
document.querySelectorAll("details").forEach(details => {
details.addEventListener("toggle", () => {
// Раскрытие было переключено
if (details.open) {
// Раскрытие было открыто
} else {
// Раскрытие было закрыто
}
});
});
Создание доступных раскрытий
Раскрытие доступно, пока соблюдаются несколько правил. Например, <summary>
— это, по сути, <label>
, то есть его содержимое объявляется программами чтения с экрана, когда оно находится в фокусе. Если <summary>
отсутствует или не является прямым дочерним элементом <details>
, то user agent создаст <label>
Details
как визуально, так и с помощью вспомогательных технологий. Старые веб-браузеры могут настаивать на том, чтобы это был first-child
, поэтому лучше так и сделать.
Кроме того, <summary>
играет роль кнопки, поэтому всё, что недопустимо внутри <button>
, недопустимо и внутри <summary>
. Это относится и к заголовкам, поэтому можно стилизовать <summary>
под заголовок, но нельзя вставить заголовок в <summary>
.
Элемент Dialog (<dialog>
)
Пример использования: Модальные окна
Теперь, когда появился Popover API для немодальных оверлеев, я думаю, что лучше начать рассматривать диалоги как модальные, даже если метод show()
позволяет создавать немодальные диалоги. Преимущество атрибута popover
перед элементом <dialog>
заключается в том, что его можно использовать для создания немодальных оверлеев без JavaScript поэтому на мой взгляд, больше нет никаких преимуществ у немодальных диалогов, требующих JavaScript. Для ясности, модальный — это оверлей, делающий основной документ инертным, в то время как при использовании немодальных оверлеев основной документ остаётся интерактивным. У модальных диалогов есть и несколько других возможностей, включая:
- стилизуемый фон,
- автофокус на первый фокусируемый элемент в
<dialog>
(или, как запасной вариант, на сам<dialog>
— в этом случае включитеaria-label
), - ловушка фокуса (в результате инерции основного документа),
- клавиша Esc закрывает диалог, и
- и диалог, и фон являются анимируемыми. Разметка и активация диалогов
Начнём с элемента <dialog>
:
<dialog> ... </dialog>
По умолчанию он скрыт, и, как и <details>
, его можно сделать открытым при загрузке страницы, хотя в данном сценарии он не является модальным, поскольку не содержит интерактивного содержимого, так как не открывается с помощью showModal()
.
<dialog open> ... </dialog>
Не могу сказать, что мне когда-либо требовалась эта функциональность. Вместо этого вы, скорее всего, захотите открывать диалог при взаимодействии, например при нажатии кнопки — вот вам и кнопка:
<button data-dialog="dialogA">Open dialogA</button>
Подождите, почему мы используем атрибуты данных? Ну, потому что может понадобиться передать идентификатор, сообщающий JavaScript, какой диалог следует открыть, что позволит добавить функциональность диалога ко всем диалогам в одном сниппете, например, так:
// Выбор и обход всех элементов с этим атрибутом data
document.querySelectorAll("[data-dialog]").forEach(button => {
// Прослушивание взаимодействия ( click)
button.addEventListener("click", () => {
// Выбор соответствующего диалога
const dialog = document.querySelector(`#${ button.dataset.dialog }`);
// Открытие диалога
dialog.showModal();
// Закрытие диалога
dialog.querySelector(".closeDialog").addEventListener("click", () => dialog.close());
});
});
Не забудьте добавить соответствующий id
к <dialog>
, чтобы он был связан с <button>
, показывающей его:
<dialog id="dialogA"> <!-- id и 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 не требует JavaScript, поэтому для немодальных оверлеев лучше использовать его.
Popover API (<element popover>
)
Пример использования: Немодальные оверлеи
По сути, всплывающие окна. Подходящие варианты использования включают в себя всплывающие подсказки (или переключаемые всплывающие подсказки — важно знать разницу), ознакомительные инструкции, уведомления, переключаемые навигации и другие немодальные оверлеи, когда не хочется терять доступ к основному документу. Очевидно, что эти случаи использования отличаются от диалоговых окон, но, тем не менее, popover'ы очень удобны. Функционально они похожи на диалоги, но не модальны и не требуют JavaScript.
Разметка popover'ов
Для начала popover должен иметь id
, а также атрибут popover
с значением manual
(это означает, что клик за пределами popover не закрывает его), значением auto
(клик за пределами popover закрывает его) или без значения (что означает auto
). Чтобы быть семантичным, popover может быть <dialog>
.
<dialog id="tooltipA" popover> ... </dialog>
Затем добавьте атрибут popovertarget
к <button>
или <input type="button">
, которым необходимо переключить видимость popover'а, со значением, соответствующим атрибуту id popover'а (это необязательно, поскольку клик за пределами popover'а всё равно закроет его, если только popover не установлен на manual
):
<dialog id="tooltipA" popover>
<button popovertarget="tooltipA">Hide tooltipA</button>
</dialog>
Поместите ещё одну такую кнопку в основной документ, чтобы можно было показать popover. Все верно, popovertarget
— это на самом деле переключатель (если вы не указали иное с помощью атрибута popovertargetaction
, принимающего в качестве значения show
, hide
или toggle
— подробнее об этом позже).
Стилизация popover'ов
По умолчанию popover`ы центрируются в верхнем слое (как и диалоги), но вам, скорее всего, они там не нужны, ведь это не модалы, в конце концов.
<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 {
...
}
*/
Проблема заключается в том, что приходится присваивать имена всем этим якорям, что хорошо для компонента с вкладками, но излишне для сайта с большим количеством всплывающих подсказок. К счастью, можно сопоставить атрибут id
на кнопке с атрибутом anchor
на popover
, что не очень хорошо поддерживается по состоянию на ноябрь 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;
}
Следующая проблема заключается в том, что ожидается, что всплывающие подсказки будут отображаться при наведении, а это не происходит, что означает, что необходимо использовать JavaScript. Хотя это кажется сложным, учитывая, что можно создавать всплывающие подсказки гораздо проще, используя ::before
/::after
/content:
, popover позволяет использовать HTML-контент (в этом случае наши всплывающие подсказки на самом деле являются переключаемыми всплывающими подсказками, кстати), в то время как content:
принимает только текст.
Добавление функциональности JavaScript
Что приводит нас к следующему…
Итак, давайте посмотрим, что здесь происходит. Во-первых, мы используем атрибуты anchor
, чтобы не писать блок CSS для каждого элемента якоря. Popover'ы очень ориентированы на HTML, так что давайте использовать позиционирование якорей таким же образом. Во-вторых, используем JavaScript для показа всплывающих popover'ов (showPopover()
) при mouseover
. И, наконец, используем JavaScript для скрытия popover'ов (hidePopover()
) при mouseout
, но не в том случае, если они содержат ссылку, поскольку, очевидно, необходимо, чтобы они были кликабельны (в этом сценарии также не скрывается кнопка, скрывающая popover).
<main>
<button id="anchorLink" popovertarget="tooltipLink">Open tooltipLink</button>
<button id="anchorNoLink" popovertarget="tooltipNoLink">Open tooltipNoLink</button>
</main>
<dialog anchor="anchorLink" id="tooltipLink" popover>Has <a href="#">a link</a>, so we can’t hide it on mouseout
<button popovertarget="tooltipLink">Hide tooltipLink manually</button>
</dialog>
<dialog anchor="anchorNoLink" id="tooltipNoLink" popover>Doesn’t have a link, so it’s fine to hide it on mouseout automatically
<button popovertarget="tooltipNoLink">Hide tooltipNoLink</button>
</dialog>
[popover] {
margin: 0;
top: calc(anchor(bottom) + 10px);
justify-self: anchor-center;
/* Нет ссылки? Кнопка не нужна */
&:not(:has(a)) [popovertarget] {
display: none;
}
}
/* Выбор и обход всех popover триггеров */
document.querySelectorAll("main [popovertarget]").forEach((popovertarget) => {
/* Выбираем соответствующий popover */
const popover = document.querySelector(`#${popovertarget.getAttribute("popovertarget")}`);
/* Показываем popover при срабатывании mouseover */
popovertarget.addEventListener("mouseover", () => {
popover.showPopover();
});
/* Скрываем popover при срабатывании mouseout, но если он не содержит ссылки */
if (popover.matches(":not(:has(a))")) {
popovertarget.addEventListener("mouseout", () => {
popover.hidePopover();
});
}
});
Реализация фонов с таймером (и последовательных popover)
Сначала я был уверен, что наличие фона у popover — это упущение, аргументированное тем, что они не должны заслонять основной документ. Но может, допустимо на пару секунд затенить фон и продолжить работу без необходимости что-то закрывать? Думаю, это хорошо подходит для набора советов по введению в курс дела:
<!-- Повторное отображение 'A' возвращает процесс ознакомления к этому шагу. -->
<button popovertarget="onboardingTipA" popovertargetaction="show">Restart onboarding</button>
<!-- Скрытие 'A' также скрывает последующие подсказки, пока атрибут popover равен auto -->
<button popovertarget="onboardingTipA" popovertargetaction="hide">Cancel onboarding</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">Next tip</button>
</dialog>
<!-- Кнопка onboardingTipB запускает onboardingTipC -->
<dialog anchor="toolB" id="onboardingTipB" popover>
onboardingTipB <button popovertarget="onboardingTipC" popovertargetaction="show">Next tip</button>
</dialog>
<!-- Кнопка onboardingTipC запускает onboardingTipD -->
<dialog anchor="toolC" id="onboardingTipC" popover>
onboardingTipC <button popovertarget="onboardingTipD" popovertargetaction="show">Next tip</button>
</dialog>
<!-- Кнопка onboardingTipD скрывает onboardingTipA, которая, в свою очередь, скрывает все подсказки -->
<dialog anchor="toolD" id="onboardingTipD" popover>
onboardingTipD <button popovertarget="onboardingTipA" popovertargetaction="hide">Finish onboarding</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()
показывает первую подсказку при входе в систему через две секунды. Во-вторых, запускается простая фоновая анимация с затуханием. Основной документ не становится инертным, а фон не сохраняется, поэтому внимание переключается на подсказки, но при этом не кажется навязчивым.
В-третьих, каждый popover содержит кнопку, запускающую следующий совет, который запускает ещё один, и так далее, объединяя их в цепочку, чтобы создать полностью HTML-поток обучения. Обычно отображение popover закрывает другие popover, но это не так, если он запускается изнутри другого popover. Кроме того, повторный показ видимого popover возвращает процесс знакомства к этому шагу, а скрытие popover скрывает его и все последующие popover — хотя, похоже, это работает только в том случае, если popover
равен auto
. Я не до конца понимаю это, но оно позволило создать кнопки restart onboarding
и cancel onboarding
.
С помощью простого HTML. А перебирать подсказки можно с помощью клавиш Esc и Enter.
Создание модальных popover'ов
Послушайте Если вам нравится HTML popover
, но семантическая ценность <dialog>
, этот JavaScript-однострочник может сделать основной документ инертным, тем самым сделав popover'ы модальными:
document.querySelectorAll("dialog[popover]").forEach(dialog => dialog.addEventListener("toggle", () => document.body.toggleAttribute("inert")));
Однако popover'ы должны располагаться после основного документа, иначе они тоже станут неактивными. Я именно так и поступаю с модалами, поскольку они не являются частью контента страницы.
<body>
<!-- Всё это станет инертным -->
</body>
<!-- Поэтому модалы должны идти следом -->
<dialog popover> ... </dialog>
И… дышите
Да, это слишком много. Но… считаю, что важно рассмотреть все эти API вместе, когда они начинают формироваться, чтобы понять, для чего они могут, не могут, должны и не должны использоваться. В качестве прощального подарка я оставлю версию каждого API с поддержкой перехода:
- Скользящее раскрытия
- Всплывающий диалог (с затухающим фоном)
- Выдвигающийся popover (гамбургер-навигация, потому что, а почему бы и нет?)