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

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

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

Демо

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

A Естественная, нестилизованная метка и флажок.

<label for="switch" class="gui-switch">
  Label text
  <input type="checkbox" role="switch" id="switch">
</label>

<input type="checkbox"> поставляется с готовым API и состоянием . Браузер управляет checked свойством и событиями ввода, такими как oninput и onchanged .

Макеты

Flexbox , grid и настраиваемые свойства критически важны для поддержания стилей этого компонента. Они централизуют значения, присваивают имена вычислениям или областям, которые иначе были бы неоднозначными, и предоставляют небольшой API для настраиваемых свойств для лёгкой настройки компонента.

.gui-switch

Макет верхнего уровня для переключателя — Flexbox. Класс .gui-switch содержит приватные и публичные пользовательские свойства, которые дочерние элементы используют для вычисления своих макетов.

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

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

Расширение и изменение макета Flexbox аналогично изменению любого макета Flexbox. Например, можно разместить метки над или под переключателем или изменить 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, а не под ним, заявляя права 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 через каскад .

Варианты переключателей с индивидуальными размерами и цветами дорожек.

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

.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

На мой взгляд, интерфейс Switch может показаться странным в попытке эмулировать физический интерфейс, особенно с кругом внутри трека. В iOS это реализовано правильно: можно перетаскивать его из стороны в сторону, и эта возможность очень удобна. И наоборот, элемент пользовательского интерфейса может казаться неактивным, если при попытке перетаскивания ничего не происходит.

Перетаскиваемые большие пальцы

Псевдоэлемент thumb получает своё положение из .gui-switch > input в области видимости var(--thumb-position) . JavaScript может предоставить значение встроенного стиля для ввода, чтобы динамически обновлять положение thumb, создавая видимость его соответствия жесту указателя. При отпускании указателя удалите встроенные стили и определите, было ли перетаскивание ближе к состоянию «включено» или «выключено», с помощью пользовательского свойства --thumb-position . Это основа решения: события указателя условно отслеживают положение указателя для изменения пользовательских свойств CSS.

Поскольку компонент уже был полностью функционален до появления этого скрипта, требуется немало усилий для поддержания текущего поведения, например, для переключения ввода по нажатию на метку. Наш 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 , а значения — кэшированные границы и размеры, что обеспечивает эффективность скрипта. Направление справа налево обрабатывается тем же пользовательским свойством --isLTR , что и в CSS, и может использоваться для инвертирования логики и продолжения поддержки RTL. event.offsetX также полезен, поскольку содержит значение delta, полезное для позиционирования ползунка.

state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)

Эта последняя строка CSS-кода задаёт пользовательское свойство, используемое элементом thumb. В противном случае это присвоенное значение изменялось бы со временем, но предыдущее событие указателя временно установило --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()
}

Взаимодействие с элементом завершено, пора установить свойство 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
}

Дополнительные мысли

Жест перетаскивания потребовал немного кода из-за выбранной изначально 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-челленджей на данный момент! Теперь, когда вы знаете, как я это сделал, как бы вы… 🙂

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

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

Ресурсы

Исходный код .gui-switch можно найти на GitHub .