Создание компонента Toggle
Приветствую. Представляю вашему вниманию перевод статьи «Building a switch component», опубликованной 11 августа 2021 года автором Adam Argyle.
От переводчика
Некоторое время назад на Habr уже был перевод хорошей статьи на похожую тему под названием "Доступный toggle".
Здесь же описывается немного другой метод
В данной статье автор делится мыслями об одном из способов создания компонента переключателя. Демонстрация
Если вы предпочитаете видео, ниже представлена видео-версия статьи на YouTube
Введение
Переключатель работает подобно чекбоксу и явно представляет одно из двух состояний: Включено или Выключено.
В приведённой демонстрации используется именно чекбокс <input type="checkbox" role="switch">
, преимуществом которого является то, что для полноценной работы и доступности он не нуждается в дополнительном CSS или JavaScript. CSS добавляет поддержку языков, которые пишутся справа налево, вертикального позиционирования, анимации и другое. JavaScript позволяет сделать перетаскиваемым ползунок переключателя.
Кастомные свойства
Будучи самым верхним классом, .gui-switch
содержит кастомные свойства, значения которых наследуются дочерними элементами, а потому позволяют задать основные параметры переключателя.
Параметры Трека
Размеры (--track-size
), внутренние отступы и два цвета:
.gui-switch {
--track-size: calc(var(--thumb-size) * 2);
--track-padding: 2px;
--track-inactive: hsl(80 0% 80%);
--track-active: hsl(80 60% 45%);
--track-color-inactive: var(--track-inactive);
--track-color-active: var(--track-active);
@media (prefers-color-scheme: dark) {
--track-inactive: hsl(80 0% 35%);
--track-active: hsl(80 60% 60%);
}
}
Параметры Ползунка
Размер, цвет фона и цвет при взаимодействии
.gui-switch {
--thumb-size: 2rem;
--thumb: hsl(0 0% 100%);
--thumb-highlight: hsl(0 0% 0% / 25%);
--thumb-color: var(--thumb);
--thumb-color-highlight: var(--thumb-highlight);
@media (prefers-color-scheme: dark) {
--thumb: hsl(0 0% 5%);
--thumb-highlight: hsl(0 0% 100% / 25%);
}
}
Уменьшение анимаций
С помощью PostCSS-плагина, основанного на черновике спецификации Media Queries 5, для медиазпроса, обозначающего снижение количества анимации, можно задать понятный псевдоним --motionOK
в виде кастомного свойства.
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Разметка
В рассматриваемом примере вместо связи элементов с помощью id
, я помещу <input type="checkbox" role="switch">
внутрь <label>
, оставляя пользователю возможность взаимодействовать с элементом через его подпись.
<label for="switch" class="gui-switch">
Label text
<input type="checkbox" role="switch" id="switch">
</label>
<input type="checkbox">
имеет встроенный API и нужное состояние. Браузер работает со свойством checked и событиями ввода oninput
и onchanged
.
Раскладка
Flexbox, CSS Grid и кастомные свойства критически важны в стилизации данного компонента. Они позволяют собрать в одном месте все значения, дать простые имена сложным вычислениям или областям, а также включить API кастомных свойств для облегчения настройки компонента.
.gui-switch
Раскладка для самого верхнего элемента переключателя будет задаваться с помощью Flexbox. Класс .gui-switch
содержит приватные и публичные кастомные свойства, которые потомки используют для вычисления своей раскладки.
.gui-switch {
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
}
Применяемый в данном случае Flexbox позволяет менять положение подписи перед или после переключаетеля с помощью свойства flex-direction
:
<label for="light-switch" class="gui-switch" style="flex-direction: column">
Default
<input type="checkbox" role="switch" id="light-switch">
</label>
Трек
Чекбокс стилизуется под трек переключателя путём удаления стандартного внешнего вида appearance: checkbox
и задания нужных размеров и формы:
.gui-switch > input {
appearance: none;
inline-size: var(--track-size);
block-size: var(--thumb-size);
padding: var(--track-padding);
flex-shrink: 0;
display: grid;
align-items: center;
grid: [track] 1fr / [track] 1fr;
}
Трек представляет собой CSS Grid, в котором будет перемещаться ползунок.
Ползунок
Свойство appearance: none
скрывает не только сам элемент checkbox
, но также и стандартную для него галочку. Поэтому вместо данного индикатора наш компонент будет использовать псевдоэлемент и псевдокласс :checked
.
Роль ползунка будет выполнять псевдоэлемент, добавленный к input[type="checkbox"]
.
.gui-switch > input::before {
content: "";
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
}
Внимание
Не все элементы<input>
могут иметь псевдоэлементы. Подробности смотрите здесь
Стили
Кастомные свойства позволяют сделать универсальный компоненты переключателя, который адаптируется под цветовые схемы, языки с направлением написания справа налево, а также поддерживает настройку снижения анимации.
Стили для устройств с сенсорным вводом
На сенсорных экранах мобильных устройств переключение чекбокса сопровождает бликом, а выделение текста — подсветкой. Это отрицательно влияет на стилизацию и визуальный отклик при взаимодействии с переключателем. С помощью пары строк CSS-кода можно удалить данные эффекты и добавить собственный стиль в виде cursor: pointer
:
.gui-switch {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
Так как данные стили могут быть ценным откликом визуального взаимодействия, их удаление не всегда рекомендовано. Убедитесь, что в случае их удаления вы обеспечили альтернативное оформление.
Трек
Стили данного элемента в основном касаются цвета и формы, которые он, благодаря каскаду, наследует от родительского элемента с классом .gui-switch
.gui-switch > input {
appearance: none;
border: none;
outline-offset: 5px;
box-sizing: content-box;
padding: var(--track-padding);
background: var(--track-color-inactive);
inline-size: var(--track-size);
block-size: var(--thumb-size);
border-radius: var(--track-size);
}
Четыре кастомных свойства обеспечивает широкое разнообразие настроек трека нашего переключателя. Поскольку использованное ранее свойство appearance: none
не во всех браузерах удаляет рамку, на всякий случай дополнительно нужно добавить border: none
.
Ползунок
Элемент ползунка уже находится на треке, но требует стилей скругления:
.gui-switch > input::before {
background: var(--thumb-color);
border-radius: 50%;
}
Взаимодействие
С помощью кастомных свойств подготовим стилизацию подсветки при наведении на ползунок, а также его поведение при перемещении. Не забывайте проверять предпочтения пользователей.
.gui-switch > input::before {
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
@media (--motionOK) { & {
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}}
}
Позиция ползунка
Кастомные свойства позволяют в одном месте определить все настройки позиционирования ползунка на треке. В нашем распоряжении есть размеры трека и ползунка, которые мы будем использовать в расчётах, чтобы правильно смещать ползунок в пределах трека от 0% до 100%.
Для элемента input
задана переменная --thumb-position
, которую реализованный в виде псевдоэлемента ползунок использует для позиционирования с помощью translateX
:
.gui-switch > input {
--thumb-position: 0%;
}
.gui-switch > input::before {
transform: translateX(var(--thumb-position));
}
Теперь мы можем свободно менять значение переменной --thumb-position
с помощью CSS и псевдоклассов, отражающих состояние чекбокса. Поскольку до этого мы условно установили transition: transform var(--thumb-transition-duration) ease
при изменении значения будет происходить анимация.
/* Позиционирование в конце трека: длина трека - 100% (ширина ползунка) */
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
}
/* Позиционирование в центре трека: половина трека - половина ползунка */
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
}
Вертикальное положение
Поддержка данного функционала была реализована с помощью класса-модификатора -vertical
, который поворачивает элемент input
.
При таком повороте только элемента трека, высота всего компонента не меняется, что может приводить к нарушению раскладки. Чтобы этого избежать, с помощью переменных --track-size
и --track-padding
можно вычислить и задать минимальное количество пространства, требуемого для вертикально повёрнутой кнопки.
.gui-switch.-vertical {
min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));
& > input {
transform: rotate(-90deg);
}
}
(RTL) right-to-left
Мы с Elad Schecter сделали прототип меню, выезжающего с помощью CSS Transforms, которое благодаря изменению одной переменной переключается на отображение, соответствующее RTL-языкам (в которых слова записываются справа налево). Нам пришлось выбрать именно такой способ, потому что в CSS нет и, возможно, никогда не будет логического свойства transform
. У Элада была отличная идея использовать кастомное свойство для инвертирования процентов, чтобы настройка происходила из одного места. Я использовал ту же технику в переключаете и думаю, что здесь она также отлично отрабатывает:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Кастомное свойство --isLTR
изначально имеет значение 1
, означаюдее true
, поскольку наша раскладка по умлочанию предназначена для языков, которые записываюся слева направо (LTR). Затем, в том случае, если компонент находится внутри раскладки с написанием справа налево (RTL) мы с помощью CSS-псевдокласса :dir() устанавливаем значение в -1
.
С помощью функции calc()
при изменении компонента с помощью свойства transform
можно применить эффект свойства --isLTR
:
.gui-switch.-vertical > input {
/* Предыдущее значение (не учитывает RTL) */
transform: rotate(-90deg);
/* Новое значение (учитывает RTL) */
transform: rotate(calc(90deg * var(--isLTR) * -1));
}
Теперь вращение вертикального переключателя располагает панель на противоположной стороне, что требуется для RTL-раскладки.
Свойство translateX
, перемещающее ползунок, также нужно обновить, чтобы учесть, что расположение теперь будет на противоположной стороне:
.gui-switch > input:checked {
/* Предыдущее значение (не учитывает RTL) */
--thumb-position: calc(var(--track-size) - 100%);
/* Новое значение (учитывает RTL) */
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
.gui-switch > input:indeterminate {
/* Предыдущее значение (не учитывает RTL) */
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
/* Новое значение (учитывает RTL) */
--thumb-position: calc(
((var(--track-size) / 2) - (var(--thumb-size) / 2))
* var(--isLTR)
);
}
Хотя этот подход не может являться полной заменой концепции логических CSS-свойств трансформирования элементов, всё же во многих случаях он предлагает некоторые принципы DRY.
Состояния
Использование встроенного input[type="checkbox"]
было бы неполным без обработки различных состояний, в которых может находиться элемент: :checked
, :disabled
, :indeterminate
и :hover
. Для состояния :focus
меняется только смещение; кольцо фокуса и так смотрится отлично.
Checked
<label for="switch-checked" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>
Это состояние соответствует состоянию включено
. Фону трека задаётся активный цвет, а ползунок перемещается "в конец".
.gui-switch > input:checked {
background: var(--track-color-active);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
Disabled
<label for="switch-disabled" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>
Отключённый (disabled) элемент не только иначе выглядит, но ещё и должен становиться неизменяемым. Неизменяемость каждый браузер реализует самостоятельно, а вот визуальную часть нужно задавать, так как ранее мы использовали appearance: none.
.gui-switch > input:disabled {
cursor: not-allowed;
--thumb-color: transparent;
&::before {
cursor: not-allowed;
box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 50%);
@media (prefers-color-scheme: dark) { & {
box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 50%);
}}
}
}
Здесь возникают сложности, поскольку отключённое и выбранное состояние требует стилизации под светлую и тёмную темы. Для этих состояний я задал минимальные стили, чтобы в будущем облегчить поддержку самих состояний и их комбинаций.
Indeterminate
Часто забываемое состояние :indeterminate
, в котором чекбокс ни установлен, ни снят. Это интересное состояние, напоминающее о том, что булевы состояния могут иметь ещё и коварное третье промежуточное состояние.
Установить чекбокс в состояние indeterminate
трудно, это можно сделать лишь с помощью JavaScrit:
<label for="switch-indeterminate" class="gui-switch">
Indeterminate
<input type="checkbox" role="switch" id="switch-indeterminate">
<script>document.getElementById('switch-indeterminate').indeterminate = true</script>
</label>
Поскольку данное состояние само по себе является неочевидным, то и при его стилизации я решил расположить ползунок посередине:
.gui-switch > input:indeterminate {
--thumb-position: calc(
calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
* var(--isLTR)
);
}
Hover
Наведение должно визуально как-то обозначаться. В нашем случае при наведении на сам переключатель или на его подпись, происходит выделение ползунка. При переключении происходит анимация движения в определённом направлении.
Эффект выделения ползунка реализуется с помощью box-shadow
. При наведении на не отключённый input переключателя увеличивается значение --highlight-size
. Если пользователь нормально воспринимает движения, мы анимируем увеличение box-shadow
, если нет — выделение происходит моментально.
.gui-switch > input::before {
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
@media (--motionOK) { & {
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}}
}
.gui-switch > input:not(:disabled):hover::before {
--highlight-size: .5rem;
}
JavaScript
Задавать JavaScript необязательно, но мне кажется, что возможность перетаскивать сам ползунок, изначально внедрённая в iOS, снижает вероятность того, что пользователь по ошибке воспримет интерфейс нерабочим, если не сможет этот самый ползунок перетянуть.
Перемещаемый ползунок
Позиция ползунка задаётся в var(--thumb-position)
, находящейся в области видимости .gui-switch > input
. JavaScript может изменять значения инлайновых стилей, чтобы динамически обновлять положение ползунка, создавая видимость его следования за указателем. Когда указатель отпускается, удалите инлайновые стили и определите, к какому состоянию он оказался ближе: выключенному или включённому.
touch-action
Перетаскивание не является встроенным жестом, становясь отличным претендентом на то, чтобы использовать преимущества touch-action
. В ситуации с переключателем, горизонтальный и вертикальный жесты должны быть обработаны нашим скриптом. С помощью touch-action
мы можем указать браузеру, какие жесты нужно обрабатывать.
Следующий фрагмент CSS-кода сообщает браузеру, что если жест начинается внутри трека переключателя, нужно обрабатывать только вертикальные жесты.
.gui-switch > input {
touch-action: pan-y;
}
Здесь мы стремимся достичь поведения при котором при горизонтальном жесте не будет происходить перемещение или прокрутка страницы. При вертикальных жестах никаких ограничений нет, но у горизонтальных — только заданное поведение.
Утилиты вычисления значений элемента
При нажатии и во время перетаскивания от элементов нужно будет получить различные вычисленные значения. Следующие JavaScript-функции возвращают вычисленные значения данного CSS-свойства. Они используются в установке скрипта вроде getStyle(checkbox, 'padding-left')
.
const getStyle = (element, prop) => {
return parseInt(window.getComputedStyle(element).getPropertyValue(prop));
}
const getPseudoStyle = (element, prop) => {
return parseInt(window.getComputedStyle(element, ':before').getPropertyValue(prop));
}
export {
getStyle,
getPseudoStyle,
}
Обратите внимание, что window.getComputedStyle()
принимает второй аргумент, являющийся целевым псевдоэлементом. Довольно удобно, что JavaScript можем считывать так много значений из элементов, даже являющихся псевдоэлементами.
Внимание!
Эти функции использут parseInt()
, который предполагает, что вы запрашваете значение, которое возвращает значение пикселя. Это значит, что вы не можете использовать данную функцию, например, вместе с getStyle(element, "dislpay")
.
Перетаскивание
Это ключевой момент для логики перетаскивания, и из обработчика событий функции следует отметить несколько моментов.
const dragging = event => {
if (!state.activethumb) return
let {thumbsize, bounds, padding} = switches.get(state.activethumb.parentElement)
let directionality = getStyle(state.activethumb, '--isLTR')
let track = (directionality === -1)
? (state.activethumb.clientWidth * -1) + thumbsize + padding
: 0
let pos = Math.round(event.offsetX - thumbsize / 2)
if (pos < bounds.lower) pos = 0
if (pos > bounds.upper) pos = bounds.upper
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
}
Скрипт работает с state.activethumb
, маленьким кружком, который перемещается вслед за указаелем. Объект swiches
представляет собой Map()
, где ключами являются .gui-switch
, а значениями — кешированные границы и размеры, которые обесечивают эффективность скрипта. LTR написание обрабатывается с помощью того же свойства --isLTR
, которое использовалось в CSS и которое может применяться для инвертирования логики и продолжения использования RTL. Значение event.offsetX
также ценно, поскольку содержит значение дельты, полезное для позиционирования ползунка.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Последняя строка устанавливает кастомное свойство, используемое элементом ползунка. В противном лучае это присваивание значения со временем изменилось бы, но предудыщее событие указателя временно установило бы для переменной --thumb-transition-duration
значение 0, устраняя то, что могло бы быть вялым взаимодействием.
dragEnd
Чтобы при перетаскивании ползунка пользователь мог уводить указатель за пределы элемента переключателя, нужно глобальное событие окна:
window.addEventListener('pointerup', event => {
if (!state.activethumb) return
dragEnd(event)
})
Я думаю очень важной возможность пользователю свободно перетаскивать ползунок и чтобы интерфейс был достаточно умным, чтобы это учитывать.
const dragEnd = event => {
if (!state.activethumb) return
state.activethumb.checked = determineChecked()
if (state.activethumb.indeterminate)
state.activethumb.indeterminate = false
state.activethumb.style.removeProperty('--thumb-transition-duration')
state.activethumb.style.removeProperty('--thumb-position')
state.activethumb.removeEventListener('pointermove', dragging)
state.activethumb = null
padRelease()
}
Настройка взаимодействия с элементом выполнена, теперь можно задать элементу input
состояние "checked" и удалить все события жестов. Чекбокс изменяется с помощью state.activethumb.checked = determineChecked()
.
determineChecked()
Данная функция, вызываемая функцией dragEnd
, определяет, где текущий ползунок располагается в рамках границ его трека, и возвращает true
, если он находится на половине пути или больше.
const determineChecked = () => {
let {bounds} = switches.get(state.activethumb.parentElement)
let curpos =
Math.abs(
parseInt(
state.activethumb.style.getPropertyValue('--thumb-position')))
if (!curpos) {
curpos = state.activethumb.checked
? bounds.lower
: bounds.upper
}
return curpos >= bounds.middle
}
Заключение
Создание этого крохотного элемента переключателя оказалось очень трудоёмким процессом.
Давайте разнообразим и рассмотрим разные подходы к его созданию. Создайте демонстрационный пример, напишите мне в твиттере и добавлю его в раздел примеров от пользователей ниже (в оригинальной статье).