Создание компонента коммутатора

Базовый обзор того, как создать гибкий и доступный компонент коммутатора.

В этом посте я хочу поделиться мыслями о том, как создавать компоненты коммутатора. Попробуйте демо .

Демо

Если вы предпочитаете видео, вот версия этого поста на 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 содержит частные и общедоступные пользовательские свойства, которые дети используют для расчета своих макетов.

Flexbox DevTools накладывает горизонтальную метку и переключатель, показывающий распределение пространства в их макете.

.gui-switch {
 
display: flex;
 
align-items: center;
 
gap: 2ch;
 
justify-content: space-between;
}

Расширение и изменение макета флексбокса похоже на изменение любого макета флексбокса. Например, чтобы разместить метки над или под переключателем или изменить направление flex-direction :

Flexbox DevTools накладывает вертикальную метку и переключатель.

<label for="light-switch" class="gui-switch" style="flex-direction: column">
  Default
 
<input type="checkbox" role="switch" id="light-switch">
</label>

Отслеживать

Ввод флажка стилизован под дорожку переключения путем удаления его обычного appearance: checkbox и указания вместо него собственного размера:

Grid DevTools накладывает дорожку переключения, показывая именованные области дорожки сетки с именем «дорожка».

.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 :

DevTools показывает большой палец псевдоэлемента, расположенный внутри сетки CSS.

.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%;
}

В DevTools выделен псевдоэлемент кругового большого пальца.

Взаимодействие

Используйте пользовательские свойства, чтобы подготовиться к взаимодействиям, которые будут отображать выделение при наведении и изменение положения большого пальца. Предпочтения пользователя также проверяются перед переходом к стилям выделения при движении или наведении.

.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:

Скриншот кольца фокусировки, ориентированного на переключатель в 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()
}

Заключение

Этот крохотный компонент-переключатель на данный момент оказался самой трудоемкой из всех задач с графическим интерфейсом! Теперь, когда вы знаете, как я это сделал, как бы вы‽ 🙂

Давайте разнообразим наши подходы и изучим все способы разработки в Интернете. Создайте демо, пришлите мне ссылку в Твиттере , и я добавлю ее в раздел ремиксов сообщества ниже!

Ремиксы сообщества

Ресурсы

Найдите исходный код .gui-switch на GitHub .