Перевод подготовлен в рамках онлайн-курса "HTML/CSS".

Приглашаем всех желающих на открытый урок «CSS Reset — ненужный артефакт или спасательный круг». На этом вебинаре рассмотрим, зачем нам CSS Reset, что такое рендеринг и как браузер рендерит страницу.


Toggles (или их еще называют "тумблеры"/"переключатели") широко используются в современных интерфейсах. Они, как правило, относительно просты, и их можно рассматривать как простые флажки (checkbox). Тем не менее, их часто делают недоступными тем или иным способом.

В этой статье я покажу небольшую имплементацию доступного toggle на HTML + CSS, которую вы можете применить в своих проектах и доработать по своему усмотрению.

  • Разметка

  • Стилизация

  • Контейнер

  • The toggle и дескриптор handle

  • Стили для фокуса

  • Проверенное состояние

  • Отключенное состояние

  • Поддержка право-лево

  • Иконки

  • Вариант кнопки

  • Завершение

Дисклеймер: Прежде чем использовать toggle, подумайте, насколько это наилучший вариант пользовательского интерфейса для данной ситуации. Toggles могут быть визуально непонятными, и в некоторых случаях кнопка может быть более подходящим решением для данной ситуации.

Разметка

Как всегда, давайте начнем с HTML. В данном случае мы начнем с самых основ, а именно с правильно обозначенного флажка. Это input с <label>, с правильными атрибутами и видимой меткой.

Если toggle вызывает немедленное действие (например, переключение темы) и поэтому зависит от JavaScript, то вместо него следует использовать <button>. Обратитесь к варианту кнопки для получения дополнительной информации о разметке - стили, по сути, одинаковы. Спасибо Adrian Roselli за то, что он обратил наше внимание на это!

<label class="Toggle" for="toggle">
  <input type="checkbox" name="toggle" id="toggle" class="Toggle__input" />
  This is the label
</label>

Стоит отметить, что это не единственный способ разметки такого компонента интерфейса. Например, вместо него можно использовать 2 radio inputs. Sara Soueidan более подробно рассказывает о проектировании и создании toggle.

Теперь нам понадобится немного больше. Чтобы не передавать статус флажка, полагаясь только на цвет (Критерий успеха WCAG 1.4.1 "Использование цвета"), мы собираемся использовать пару иконок.

Мы будем использовать небольшой контейнер между вводом данных и текстовой меткой, который будет содержать 2 иконки: галочку и крестик (взяты из иконок Material UI). Затем мы создадим toggle handle с псевдоэлементом, который будет охватывать одну из иконок за раз.

<label class="Toggle" for="toggle">
  <input type="checkbox" name="toggle" id="toggle" class="Toggle__input" />

  <span class="Toggle__display" hidden>
    <svg
      aria-hidden="true"
      focusable="false"
      class="Toggle__icon Toggle__icon--checkmark"
      width="18"
      height="14"
      viewBox="0 0 18 14"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
    >
      <path
        d="M6.08471 10.6237L2.29164 6.83059L1 8.11313L6.08471 13.1978L17 2.28255L15.7175 1L6.08471 10.6237Z"
        fill="currentcolor"
        stroke="currentcolor"
      />
    </svg>
    <svg
      aria-hidden="true"
      focusable="false"
      class="Toggle__icon Toggle__icon--cross"
      width="13"
      height="13"
      viewBox="0 0 13 13"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
    >
      <path
        d="M11.167 0L6.5 4.667L1.833 0L0 1.833L4.667 6.5L0 11.167L1.833 13L6.5 8.333L11.167 13L13 11.167L8.333 6.5L13 1.833L11.167 0Z"
        fill="currentcolor"
      />
    </svg>
  </span>

  This is the label
</label>

Следует отметить несколько моментов, связанных с нашей разметкой:

  • Мы используем aria-hidden="true"  для наших SVG, потому что они не должны обнаруживаться вспомогательными технологиями, так как являются сугубо декоративными.

  • Мы также используем focusable="false" для наших SVG, чтобы избежать проблем с Internet Explorer, где SVG по умолчанию фокусируются.

  • Мы используем hidden для контейнера .Toggle__display , чтобы скрыть его, когда CSS недоступен, поскольку он должен вернуться к базовому флажку. Его значение отображения будет переопределено в CSS.

Стили 

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

  • Контейнер - это обертка <label>, которая содержит как toggle, так и текстовую метку (.Toggle).

  • Toggle - это визуальный тумблер, зеленый или красный в зависимости от статуса, с 2 иконками (.Toggle__display).

  • Handle - это круглый диск, охватывающий одну из иконок и перемещающийся влево и вправо при взаимодействии с toggle  (.Toggle__display::before).

  • Input - Вход - это HTML <input>, который визуально скрыт, но остается доступным и фокусируемым (.Toggle__input).

Контейнер

Давайте начнем с базовых стилей для нашего контейнера.

/**
 * 1. Vertically center the toggle and the label. `flex` could be used if a 
 *    block-level display is preferred.
 * 2. Make sure the toggle remains clean and functional even if the label is
 *    too wide to fit on one line. Thanks @jouni_kantola for the heads up!
 * 3. Grant a position context for the visually hidden and absolutely
 *    positioned input.
 * 4. Provide spacing between the toggle and the text regardless of layout
 *    direction. If browser support is considered insufficient, use
 *    a right margin on `.Toggle__display` in LTR, and left margin in RTL.
 *    See: https://caniuse.com/flexbox-gap
 */
.Toggle {
  display: inline-flex; /* 1 */
  align-items: center; /* 1 */
  flex-wrap: wrap; /* 2 */
  position: relative; /* 3 */
  gap: 1ch; /* 4 */
}

Toggle и handle

Затем - наш toggle. Чтобы облегчить настройку его стилей, мы используем некоторые пользовательские свойства CSS для перемещения вокруг handle и его диаметра.

/**
 * 1. Vertically center the icons and space them evenly in the available 
 *    horizontal space, essentially giving something like: [  ? ]
 * 2. Size the display according to the size of the handle. `box-sizing`
 *    could use `border-box` but the border would have to be considered
 *    in the `width` computation. Either way works.
 * 3. For the toggle to be visible in Windows High-Contrast Mode, we apply a
 *    thin semi-transparent (or fully transparent) border.
 *    Kind thanks to Adrian Roselli for the tip:
 *    https://twitter.com/aardrian/status/1379786724222631938?s=20
 * 4. Grant a position context for the pseudo-element making the handle.
 * 5. Give a pill-like shape with rounded corners, regardless of the size.
 * 6. The default state is considered unchecked, hence why this pale red is
 *    used as a background color.
 */
.Toggle__display {
  --offset: 0.25em;
  --diameter: 1.8em;

  display: inline-flex; /* 1 */
  align-items: center; /* 1 */
  justify-content: space-around; /* 1 */

  width: calc(var(--diameter) * 2 + var(--offset) * 2); /* 2 */
  height: calc(var(--diameter) + var(--offset) * 2); /* 2 */
  box-sizing: content-box; /* 2 */

  border: 0.1em solid rgb(0 0 0 / 0.2); /* 3 */

  position: relative; /* 4 */
  border-radius: 100vw; /* 5 */
  background-color: #fbe4e2; /* 6 */

  transition: 250ms;
  cursor: pointer;
}

/**
 * 1. Size the round handle according to the diameter custom property.
 * 2. For the handle to be visible in Windows High-Contrast Mode, we apply a
 *    thin semi-transparent (or fully transparent) border.
 *    Kind thanks to Adrian Roselli for the tip:
 *    https://twitter.com/aardrian/status/1379786724222631938?s=20
 * 3. Absolutely position the handle on top of the icons, vertically centered
 *    within the container and offset by the spacing amount on the left.
 * 4. Give the handle a solid background to hide the icon underneath. This
 *    could be dark in a dark mode theme, as long as it’s solid.
 */
.Toggle__display::before {
  content: '';

  width: var(--diameter); /* 1 */
  height: var(--diameter); /* 1 */
  border-radius: 50%; /* 1 */

  box-sizing: border-box; /* 2 */
  border: 0.1 solid rgb(0 0 0 / 0.2); /* 2 */

  position: absolute; /* 3 */
  z-index: 2; /* 3 */
  top: 50%; /* 3 */
  left: var(--offset); /* 3 */
  transform: translate(0, -50%); /* 3 */

  background-color: #fff; /* 4 */
  transition: inherit;
}

Переход здесь осуществляется таким образом, что handle плавно скользит из одной стороны в другую. Это может немного отвлекать или настораживать некоторых людей, поэтому рекомендуется отключить этот переход, когда включена функция reduced-motion (уменьшение движения). Это можно сделать с помощью следующего фрагмента:

@media (prefers-reduced-motion: reduce) {
  .Toggle__display {
    transition-duration: 0ms;
  }
}

Стили для фокуса

Мы вставили наш контейнер toggle после самого ввода, чтобы использовать комбинатор смежных сиблингов ( + )для стилизации toggle в зависимости от состояния ввода (проверен, сфокусирован, отключен...).

Сначала давайте разберемся со стилями для фокуса. До тех пор, пока они заметны, они могут быть настолько индивидуальными, насколько мы захотим. Для того чтобы быть достаточно нейтральным, я решил отобразить родной контур фокуса вокруг toggle, когда вход сфокусирован.

/**
 * 1. When the input is focused, provide the display the default outline
 *    styles from the browser to mimic a native control. This can be
 *    customised to have a custom focus outline.
 */
.Toggle__input:focus + .Toggle__display {
  outline: 1px dotted #212121; /* 1 */
  outline: 1px auto -webkit-focus-ring-color; /* 1 */
}

Я заметил одну интересную вещь: при нажатии на родной флажок или его метку контур фокуса не появляется. Это происходит только при фокусировке флажка с помощью клавиатуры. Мы можем имитировать это поведение, удалив стили, которые мы только что применили, когда селектор :focus-visible не подходит.

/**
 * 1. When the toggle is interacted with with a mouse click (and therefore
 *    the focus does not have to be ‘visible’ as per browsers heuristics),
 *    remove the focus outline. This is the native checkbox’s behaviour where
 *    the focus is not visible when clicking it.
 */
.Toggle__input:focus:not(:focus-visible) + .Toggle__display {
  outline: 0; /* 1 */
}

Проверенное состояние

Затем нам нужно разобраться с проверенным состоянием. В этом случае мы хотим сделать две вещи: обновить цвет фона toggle с красного на зеленый и сдвинуть handle вправо, чтобы он закрыл крестик и показал галочку (100% собственной ширины).

/**
 * 1. When the input is checked, change the display background color to a
 *    pale green instead. 
 */
.Toggle__input:checked + .Toggle__display {
  background-color: #e3f5eb; /* 1 */
}

/**
 * 1. When the input is checked, slide the handle to the right so it covers
 *    the cross icon instead of the checkmark one.
 */
.Toggle__input:checked + .Toggle__display::before {
  transform: translate(100%, -50%); /* 1 */
}

Adrian Roselli справедливо заметил, что эта схема не учитывает возможное "смешанное" (или "неопределенное" состояние). Это справедливо для простоты, поскольку большинство флажков/тумблеров не нуждаются в таком состоянии, но его следует учитывать, когда это необходимо.

Отключенное состояние

Наконец, мы можем добавить несколько пользовательских стилей, чтобы сделать отключенный toggle более явным.

/**
 * 1. When the input is disabled, tweak the toggle styles so it looks dimmed 
 *    with less sharp colors, softer opacity and a relevant cursor.
 */
.Toggle__input:disabled + .Toggle__display {
  opacity: 0.6; /* 1 */
  filter: grayscale(40%); /* 1 */
  cursor: not-allowed; /* 1 */
}

Поддержка право-лево

Изначально я забыл о поддержке право-лево, и Adrian Roselli был достаточно любезен, чтобы указать мне на это, поэтому я обновил код. В идеале мы должны использовать псевдо-класс :dir() , но, к сожалению, на данный момент браузеры поддерживают его довольно плохо, поэтому нам приходится полагаться на селектор атрибута [dir].

Нам нужно настроить все, что в настоящее время направлено так, чтобы было исходное и проверенное положение handle.

/**
 * 1. Flip the original position of the unchecked toggle in RTL.
 */
[dir='rtl'] .Toggle__display::before {
  left: auto; /* 1 */
  right: var(--offset); /* 1 */
}

/**
 * 1. Move the handle in the correct direction in RTL.
 */
[dir='rtl'] .Toggle__input:checked + .Toggle__display::before {
  transform: translate(-100%, -50%); /* 1 */
}

Иконки

Наконец, мы применим некоторые стили к нашим иконкам, как рекомендует Florens Verschelde в своем фантастическом руководстве по SVG-иконкам:

.Toggle__icon {
  display: inline-block;
  width: 1em;
  height: 1em;
  color: inherit;
  fill: currentcolor;
  vertical-align: middle;
}

/**
 * 1. The cross looks visually bigger than the checkmark so we adjust its
 *    size. This might not be needed depending on the icons.
 */
.Toggle__icon--cross {
  color: #e74c3c;
  font-size: 85%; /* 1 */
}

.Toggle__icon--checkmark {
  color: #1fb978;
}

Вариант кнопки

Как упоминалось ранее, использование флажка не обязательно является наиболее подходящей разметкой. Если toggle имеет немедленный эффект (и поэтому полагается на JavaScript), и если он не может иметь неопределенное состояние, то вместо него следует использовать элемент <button> с атрибутом aria-pressed.

Adrian Roselli в своем материале о toggles предлагает дерево решений для выбора между флажком и кнопкой.

К счастью, наш код легко адаптировать, чтобы он работал так же, как и кнопка. Во-первых, мы изменим HTML таким образом, что <label> станет <button>, а <input> будет удален.

<button class="Toggle" type="button" aria-pressed="false">
  <span class="Toggle__display" hidden>
    <!-- The toggle does not change at all -->
  </span>
  This is the label
</button>

Затем нам нужно убедиться, что <button>  не похожа на саму кнопку. Для этого мы сбросим стили кнопки по умолчанию, включая контур фокуса, поскольку он применяется при toggle (переключении).

/**
 * 1. Reset default <button> styles.
 */
button.Toggle {
  border: 0; /* 1 */
  padding: 0; /* 1 */
  background: transparent; /* 1 */
  font: inherit; /* 1 */
}

/**
 * 1. The focus styles are applied on the toggle instead of the container, so
 *    the default focus outline can be safely removed.
 */
.Toggle:focus {
  outline: 0; /* 1 */
}

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

+ .Toggle:focus .Toggle__display,
.Toggle__input:focus + .Toggle__display {
  /* … */
}

+ .Toggle:focus:not(:focus-visible) .Toggle__display,
.Toggle__input:focus:not(:focus-visible) + .Toggle__display {
  /* … */
}

+ .Toggle[aria-pressed="true"] .Toggle__display::before,
.Toggle__input:checked + .Toggle__display::before {
  /* … */
}

+ .Toggle[disabled] .Toggle__display,
.Toggle__input:disabled + .Toggle__display {
  /* … */
}

+ [dir="rtl"] .Toggle[aria-pressed="true"] + .Toggle__display::before,
[dir="rtl"] .Toggle__input:checked + .Toggle__display::before {
  /* … */
}

Вот и все! Таким образом, мы можем использовать либо разметку флажка, либо разметку кнопки, в зависимости от того, что больше подходит для данной ситуации, и иметь одинаковые стили в обоих случаях. Очень удобно!

Заключение 

Как видите, ничего особо сложного в этом нет, но все же есть над чем подумать. Вот чего мы добились:

  • Мы используем реальный элемент формы флажка, который мы стилизуем под toggle.

  • Он передает свой статус с помощью иконографии и цвета.

  • Он не оставляет артефактов, когда CSS недоступен.

  • Он имеет собственные стили по фокусу и может быть настроен.

  • У него есть отключенное состояние.

  • При необходимости он имеет поддержку право-лево.

  • Он должен быть относительно легко адаптирован к темному режиму при наличии некоторых глобальных пользовательских свойств.

Здорово! Не стесняйтесь играть с кодом на CodePen, и я надеюсь, что это поможет вам сделать ваши toggles доступными. А также, я рекомендую прочитать эти статьи, чтобы продвинуться дальше:

Примечание

Dion упоминает, что toggle может выглядеть наоборот, и это мнение поддерживает Rawrmonstar, а Mikael Kundert упоминает, что использование флажков обычно проще.


Узнать подробнее о курсе "HTML/CSS"

Смотреть открытый урок «CSS Reset — ненужный артефакт или спасательный круг»