- PVSM.RU - https://www.pvsm.ru -
Приветствую. Представляю вашему вниманию перевод статьи «Building a switch component [1]», опубликованной 11 августа 2021 года автором Adam Argyle.
От переводчика
Некоторое время назад на Habr уже был перевод хорошей статьи на похожую тему под названием "Доступный toggle [2]".
Здесь же описывается немного другой метод
В данной статье автор делится мыслями об одном из способов создания компонента переключателя. Демонстрация [3]
Если вы предпочитаете видео, ниже представлена видео-версия статьи на YouTube
Переключатель [4] работает подобно чекбоксу и явно представляет одно из двух состояний: Включено или Выключено.
В приведённой демонстрации используется именно чекбокс <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-плагина [5], основанного на черновике спецификации Media Queries 5 [6], для медиазпроса, обозначающего снижение количества анимации, можно задать понятный псевдоним --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 [7] и нужное состояние [8]. Браузер работает со свойством checked [7] и событиями ввода [9] oninput
и onchanged
.
Flexbox [10], CSS Grid [11] и кастомные свойства [12] критически важны в стилизации данного компонента. Они позволяют собрать в одном месте все значения, дать простые имена сложным вычислениям или областям, а также включить API кастомных свойств для облегчения настройки компонента.
Раскладка для самого верхнего элемента переключателя будет задаваться с помощью 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
, но также и стандартную для него галочку. Поэтому вместо данного индикатора наш компонент будет использовать псевдоэлемент [13] и псевдокласс [14] :checked
.
Роль ползунка будет выполнять псевдоэлемент, добавленный к input[type="checkbox"]
.
.gui-switch > input::before {
content: "";
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
}
Внимание
Не все элементы<input>
могут иметь псевдоэлементы. Подробности смотрите здесь [15]
Кастомные свойства позволяют сделать универсальный компоненты переключателя, который адаптируется под цветовые схемы, языки с направлением написания справа налево, а также поддерживает настройку снижения анимации.
На сенсорных экранах мобильных устройств переключение чекбокса сопровождает бликом, а выделение текста — подсветкой. Это отрицательно влияет на стилизацию и визуальный отклик при взаимодействии с переключателем. С помощью пары строк CSS-кода можно удалить данные эффекты и добавить собственный стиль в виде cursor: pointer
:
.gui-switch {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
Так как данные стили могут быть ценным откликом визуального взаимодействия, их удаление не всегда рекомендовано. Убедитесь, что в случае их удаления вы обеспечили альтернативное оформление.
Стили данного элемента в основном касаются цвета и формы, которые он, благодаря каскаду [16], наследует от родительского элемента с классом .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%;
}
С помощью кастомных свойств подготовим стилизацию подсветки при наведении на ползунок, а также его поведение при перемещении. Не забывайте проверять предпочтения пользователей [17].
.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);
}
}
Мы с Elad Schecter [18] сделали прототип меню, выезжающего с помощью CSS Transforms [19], которое благодаря изменению одной переменной переключается на отображение, соответствующее RTL-языкам (в которых слова записываются справа налево). Нам пришлось выбрать именно такой способ, потому что в CSS нет и, возможно, никогда не будет логического свойства transform
. У Элада была отличная идея использовать кастомное свойство для инвертирования процентов, чтобы настройка происходила из одного места. Я использовал ту же технику в переключаете и думаю, что здесь она также отлично отрабатывает:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Кастомное свойство --isLTR
изначально имеет значение 1
, означаюдее true
, поскольку наша раскладка по умлочанию предназначена для языков, которые записываюся слева направо (LTR). Затем, в том случае, если компонент находится внутри раскладки с написанием справа налево (RTL) мы с помощью CSS-псевдокласса :dir() [20] устанавливаем значение в -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
меняется только смещение; кольцо фокуса и так смотрится отлично.
<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));
}
<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
трудно, это можно сделать лишь с помощью 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)
);
}
Наведение должно визуально как-то обозначаться. В нашем случае при наведении на сам переключатель или на его подпись, происходит выделение ползунка. При переключении происходит анимация движения в определённом направлении.
Эффект выделения ползунка реализуется с помощью 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 необязательно, но мне кажется, что возможность перетаскивать сам ползунок, изначально внедрённая в iOS, снижает вероятность того, что пользователь по ошибке воспримет интерфейс нерабочим, если не сможет этот самый ползунок перетянуть.
Позиция ползунка задаётся в var(--thumb-position)
, находящейся в области видимости .gui-switch > input
. JavaScript может изменять значения инлайновых стилей, чтобы динамически обновлять положение ползунка, создавая видимость его следования за указателем. Когда указатель отпускается, удалите инлайновые стили и определите, к какому состоянию он оказался ближе: выключенному или включённому.
Перетаскивание не является встроенным жестом, становясь отличным претендентом на то, чтобы использовать преимущества 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, устраняя то, что могло бы быть вялым взаимодействием.
Чтобы при перетаскивании ползунка пользователь мог уводить указатель за пределы элемента переключателя, нужно глобальное событие окна:
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()
.
Данная функция, вызываемая функцией 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
}
Создание этого крохотного элемента переключателя оказалось очень трудоёмким процессом.
Давайте разнообразим и рассмотрим разные подходы к его созданию. Создайте демонстрационный пример, напишите мне в твиттере [21] и добавлю его в раздел примеров от пользователей ниже (в оригинальной статье).
Автор: Зварич Рома
Источник [22]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/368390
Ссылки в тексте:
[1] Building a switch component: https://web.dev/building-a-switch-component/
[2] Доступный toggle: https://habr.com/ru/company/otus/blog/557006/
[3] Демонстрация: https://gui-challenges.web.app/switch/dist/
[4] Переключатель: https://w3c.github.io/aria/#switch
[5] PostCSS-плагина: https://github.com/postcss/postcss-custom-media
[6] Media Queries 5: http://draft%20spec%20in%20Media%20Queries%205
[7] API: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox
[8] состояние: https://developer.mozilla.org/en-US/docs/Web/CSS/:checked
[9] событиями ввода: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement#input_events
[10] Flexbox: https://web.dev/learn/css/flexbox/
[11] CSS Grid: https://web.dev/learn/css/grid/
[12] кастомные свойства: https://developer.mozilla.org/en-US/docs/Web/CSS/--*
[13] псевдоэлемент: https://web.dev/learn/css/pseudo-elements/
[14] псевдокласс: https://web.dev/learn/css/pseudo-classes/
[15] здесь: https://webplatform.news/issues/2020-08-26
[16] каскаду: https://web.dev/learn/css/the-cascade/
[17] предпочтения пользователей: https://web.dev/prefers-reduced-motion/
[18] Elad Schecter: https://twitter.com/eladsc
[19] меню, выезжающего с помощью CSS Transforms: https://codepen.io/argyleink/pen/LYbjZJv
[20] :dir(): https://developer.mozilla.org/docs/Web/CSS/:dir
[21] напишите мне в твиттере: https://twitter.com/argyleink
[22] Источник: https://habr.com/ru/post/578764/?utm_source=habrahabr&utm_medium=rss&utm_campaign=578764
Нажмите здесь для печати.