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

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

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

Демо

Если вы предпочитаете видео, вот версия этого поста на 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 .