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

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

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

Демо

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