Базовый обзор того, как создать гибкий и доступный компонент коммутатора.
В этом посте я хочу поделиться мыслями о том, как создавать компоненты коммутатора. Попробуйте демо .
Если вы предпочитаете видео, вот версия этого поста на 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 :
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Разметка
Я решил обернуть свой элемент <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 , сетка и пользовательские свойства имеют решающее значение для поддержания стилей этого компонента. Они централизуют значения, дают имена неоднозначным вычислениям или областям и включают небольшой API настраиваемых свойств для простой настройки компонентов.
.gui-switch
Макет верхнего уровня для переключателя — flexbox. Класс .gui-switch
содержит частные и общедоступные пользовательские свойства, которые дети используют для расчета своих макетов.
.gui-switch {
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
}
Расширение и изменение макета флексбокса похоже на изменение любого макета флексбокса. Например, чтобы разместить метки над или под переключателем или изменить 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;
}
Дорожка также создает область дорожки сетки по одной ячейке, на которую может претендовать большой палец.
Большой палец
Стиль appearance: none
также удаляет визуальную галочку, предоставленную браузером. Этот компонент использует псевдоэлемент и псевдокласс :checked
на входе, чтобы заменить этот визуальный индикатор.
Большой палец — это дочерний псевдоэлемент, прикрепленный к input[type="checkbox"]
и располагающийся поверх дорожки, а не под ней, заявляя область сетки track
:
.gui-switch > input::before {
content: "";
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
}
Стили
Пользовательские свойства позволяют использовать универсальный компонент переключателя, который адаптируется к цветовым схемам, языкам с письмом справа налево и предпочтениям движения.
Стили сенсорного взаимодействия
На мобильных устройствах браузеры добавляют функцию выделения касаний и выделения текста к меткам и полям ввода. Это негативно повлияло на стиль и обратную связь по визуальному взаимодействию, необходимые этому переключателю. С помощью нескольких строк CSS я могу удалить эти эффекты и добавить свой собственный стиль cursor: pointer
:
.gui-switch {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
Не всегда целесообразно удалять эти стили, поскольку они могут быть ценной обратной связью при визуальном взаимодействии. Обязательно предоставьте собственные альтернативы, если вы их удалите.
Отслеживать
Стили этого элемента в основном связаны с его формой и цветом, к которым он обращается из родительского .gui-switch
через cascade .
.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);
}
Широкий выбор вариантов настройки трека переключения можно получить с помощью четырех пользовательских свойств. border: none
добавляется с момента appearance: none
не удаляет границы флажка во всех браузерах.
Большой палец
Элемент большого пальца уже находится на правильном track
, но ему нужны стили круга:
.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
ранее для этого элемента, эти изменения могут анимироваться при изменении:
/* positioned at the end of the track: track length - 100% (thumb width) */
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
}
/* positioned in the center of the track: half the track - half the thumb */
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
}
Я думаю, что эта разделенная оркестровка сработала хорошо. Элемент «thumb» связан только с одним стилем — позицией translateX
. Вход может управлять всеми сложностями и вычислениями.
Вертикальный
Поддержка осуществлялась с помощью класса модификатора -vertical
который добавляет вращение с помощью CSS-преобразований к input
элементу.
Однако 3D-повернутый элемент не меняет общую высоту компонента, что может нарушить компоновку блока. Учитывайте это, используя переменные --track-size
и --track-padding
. Рассчитайте минимальное количество места, необходимое для размещения вертикальной кнопки в макете, как ожидалось:
.gui-switch.-vertical {
min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));
& > input {
transform: rotate(-90deg);
}
}
(RTL) справа налево
Мой друг по CSS, Элад Шектер , и я вместе разработали прототип выдвижного бокового меню, используя преобразования CSS, которые обрабатывали языки с письмом справа налево путем переворачивания одной переменной. Мы сделали это потому, что в CSS нет логических преобразований свойств и, возможно, никогда не будет. У Элада возникла замечательная идея использовать значение пользовательского свойства для инвертирования процентов, чтобы обеспечить возможность управления одним расположением нашей собственной пользовательской логики для логических преобразований. Я использовал ту же самую технику в этом переключателе и думаю, что она сработала отлично:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Пользовательское свойство --isLTR
изначально имеет значение 1
, что означает, что оно true
поскольку по умолчанию наш макет расположен слева направо. Затем, используя псевдокласс CSS :dir()
, значение устанавливается равным -1
, когда компонент находится в макете справа налево.
Примените --isLTR
к действию, используя его в calc()
внутри преобразования:
.gui-switch.-vertical > input {
transform: rotate(-90deg);
transform: rotate(calc(90deg * var(--isLTR) * -1));
}
Теперь вращение вертикального переключателя учитывает положение противоположной стороны, необходимое для расположения справа налево.
Преобразования translateX
в псевдоэлементе большого пальца также необходимо обновить, чтобы учесть требования противоположной стороны:
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
--thumb-position: calc(
((var(--track-size) / 2) - (var(--thumb-size) / 2))
* var(--isLTR)
);
}
Хотя этот подход не сможет удовлетворить все потребности, связанные с такой концепцией, как логические преобразования CSS, он предлагает некоторые принципы DRY для многих случаев использования.
Штаты
Использование встроенного input[type="checkbox"]
не было бы полным без обработки различных состояний, в которых он может находиться: :checked
, :disabled
, :indeterminate
и :hover
. :focus
намеренно оставлен в покое, скорректировано только его смещение; кольцо фокусировки отлично смотрелось в Firefox и Safari:
Проверено
<label for="switch-checked" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>
Это состояние представляет собой on
состояние. В этом состоянии фон входного «дорожки» установлен на активный цвет, а положение ползунка установлено на «конец».
.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
, когда флажок не установлен и не снят. Это веселое состояние, оно привлекательное и скромное. Хорошее напоминание о том, что логические состояния могут иметь скрытое промежуточное положение между состояниями.
Установить неопределенный флажок сложно, его может установить только JavaScript:
<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
. При наведении неотключенного ввода увеличьте размер --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 сделала это правильно благодаря своему переключателю: вы можете перетаскивать их из стороны в сторону, и очень приятно иметь такую возможность. И наоборот, элемент пользовательского интерфейса может считаться неактивным, если при попытке перетаскивания ничего не происходит.
Перетаскиваемые большие пальцы
Псевдоэлемент большого пальца получает свою позицию из области .gui-switch > input
var(--thumb-position)
. JavaScript может предоставить значение встроенного стиля на входе, чтобы динамически обновлять положение большого пальца, создавая впечатление, что он следует за жестом указателя. . Когда указатель отпущен, удалите встроенные стили и определите, было ли перетаскивание ближе к выключению или включению, используя пользовательское свойство --thumb-position
. Это основа решения; события указателя, условно отслеживающие позиции указателя для изменения пользовательских свойств CSS.
Поскольку до появления этого сценария компонент уже был на 100% функциональным, для поддержания существующего поведения требуется немало усилий, например, нажатие на метку для переключения ввода. Наш 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 может считывать так много значений из элементов, даже из псевдоэлементов.
dragging
Это основной момент для логики перетаскивания, и есть несколько моментов, на которые следует обратить внимание в обработчике событий функции:
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
, маленький кружок, который этот скрипт позиционирует вместе с указателем. Объект switches
— это Map()
, где ключи — это .gui-switch
, а значения — это кэшированные границы и размеры, которые обеспечивают эффективность сценария. Направление справа налево обрабатывается с использованием того же пользовательского свойства, что и CSS — --isLTR
, и может использовать его для инвертирования логики и продолжения поддержки RTL. event.offsetX
также полезен, поскольку содержит значение дельты, полезное для позиционирования ползунка.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Эта последняя строка CSS устанавливает пользовательское свойство, используемое элементом большого пальца. В противном случае это присвоение значения будет меняться со временем, но предыдущее событие указателя временно установило --thumb-transition-duration
в 0s
, устраняя то, что было бы вялым взаимодействием.
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()
}
Взаимодействие с элементом завершено, пришло время установить свойство ввода проверено и удалить все события жестов. Флажок изменяется с помощью 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
}
Дополнительные мысли
Жест перетаскивания вызвал некоторую задолженность по коду из-за выбранной исходной структуры HTML, в основном заключающей в себя вводимые данные в метку. Метка, являющаяся родительским элементом, будет получать взаимодействия с щелчками после ввода. В конце события dragEnd
вы, возможно, заметили, что функция padRelease()
звучит странно.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
Это делается для того, чтобы метка получила этот последующий щелчок, поскольку при этом можно снять или проверить действие, выполненное пользователем.
Если бы мне пришлось сделать это снова, я мог бы рассмотреть возможность настройки DOM с помощью JavaScript во время обновления UX, чтобы создать элемент, который сам обрабатывает щелчки по меткам и не борется со встроенным поведением.
Этот вид JavaScript мне меньше всего нравится писать, я не хочу управлять условным всплыванием событий:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Заключение
Этот крохотный компонент-переключатель на данный момент оказался самой трудоемкой из всех задач с графическим интерфейсом! Теперь, когда вы знаете, как я это сделал, как бы вы‽ 🙂
Давайте разнообразим наши подходы и изучим все способы разработки в Интернете. Создайте демо, пришлите мне ссылку в Твиттере , и я добавлю ее в раздел ремиксов сообщества ниже!
Ремиксы сообщества
- @KonstantinRouda с пользовательским элементом: демо и кодом .
- @jhvanderschee с кнопкой: Codepen .
Ресурсы
Найдите исходный код .gui-switch
на GitHub .