Cómo compilar un componente de interruptor

Una descripción general fundamental sobre cómo compilar un componente de interruptor responsivo y accesible.

En esta publicación, quiero compartir mi forma de pensar sobre cómo compilar componentes de interruptores. Prueba la demostración.

Demostración

Si prefieres ver un video, aquí tienes una versión de YouTube de esta publicación:

Descripción general

Un interruptor funciona de manera similar a una casilla de verificación, pero representa de forma explícita los estados de encendido y apagado booleanos.

En esta demostración, se usa <input type="checkbox" role="switch"> para la mayor parte de su funcionalidad, lo que tiene la ventaja de no necesitar CSS o JavaScript para que sean totalmente funcionales y accesibles. La carga de CSS admite idiomas de derecha a izquierda, verticalidad, animación y mucho más. Cargar JavaScript hace que el interruptor sea tangible y se pueda arrastrar.

Propiedades personalizadas

Las siguientes variables representan las distintas partes del interruptor y sus opciones. Como clase de nivel superior, .gui-switch contiene propiedades personalizadas que se usan en todos los componentes secundarios y puntos de entrada para la personalización centralizada.

Pista

La longitud (--track-size), el padding y los dos colores:

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

Miniatura

El tamaño, el color de fondo y los colores de los elementos destacados de la interacción:

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

Movimiento reducido

Para agregar un alias claro y reducir la repetición, se puede colocar una consulta de medios de usuario de preferencia de movimiento reducida en una propiedad personalizada con el complemento PostCSS según esta especificación de borrador en Media Queries 5:

@custom-media --motionOK (prefers-reduced-motion: no-preference);

Marca

Elegí unir mi elemento <input type="checkbox" role="switch"> con un <label> y agrupar su relación para evitar la ambigüedad de la asociación de la casilla de verificación y la etiqueta, a la vez que le doy al usuario la capacidad de interactuar con la etiqueta para activar o desactivar la entrada.

Una etiqueta y una casilla de verificación naturales sin diseño.

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

<input type="checkbox"> viene precompilado con una API y un estado. El navegador administra la propiedad checked y los eventos de entrada, como oninput y onchanged.

Diseños

Flexbox, la cuadrícula y las propiedades personalizadas son fundamentales para mantener los estilos de este componente. Centralizan los valores, asignan nombres a cálculos o áreas que de otro modo serían ambiguos y habilitan una pequeña API de propiedad personalizada para personalizar componentes de forma sencilla.

.gui-switch

El diseño de nivel superior del interruptor es flexbox. La clase .gui-switch contiene las propiedades personalizadas privadas y públicas que los elementos secundarios usan para calcular sus diseños.

Las Herramientas para desarrolladores de Flexbox se superponen con una etiqueta horizontal y un interruptor, que muestra su distribución de diseño del espacio.

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

Extender y modificar el diseño de flexbox es como cambiar cualquier diseño de flexbox. Por ejemplo, para colocar etiquetas sobre o debajo de un interruptor, o para cambiar el flex-direction, haz lo siguiente:

Herramientas para desarrolladores de Flexbox superpuestas a una etiqueta vertical y un interruptor.

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

Pista

La entrada de la casilla de verificación tiene el estilo de una pista de interruptor quitando su appearance: checkbox normal y proporcionando su propio tamaño:

DevTools de cuadrícula superpuestas en el riel de interruptores, que muestran las áreas del riel de cuadrícula con el nombre &quot;track&quot;.

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

La pista también crea un área de pista de cuadrícula de una sola celda para que un pulgar la reclame.

Miniatura

El estilo appearance: none también quita la marca de verificación visual que proporciona el navegador. Este componente usa un pseudoelemento y la pseudoclase :checked en la entrada para reemplazar este indicador visual.

El elemento de vista previa es un pseudoelemento secundario conectado a input[type="checkbox"] y se apila sobre la pista en lugar de debajo de ella reclamando el área de la cuadrícula track:

DevTools muestra el pulgar del pseudoelemento como posicionado dentro de una cuadrícula de CSS.

.gui-switch > input::before {
  content: "";
  grid-area: track;
  inline-size: var(--thumb-size);
  block-size: var(--thumb-size);
}

Estilos

Las propiedades personalizadas permiten un componente de interruptor versátil que se adapta a los esquemas de colores, los idiomas de derecha a izquierda y las preferencias de movimiento.

Una comparación en paralelo del tema claro y oscuro del interruptor y sus estados.

Estilos de interacción táctil

En dispositivos móviles, los navegadores agregan elementos destacados de presión y funciones de selección de texto a las etiquetas y entradas. Esto afectó negativamente los comentarios de estilo y de interacción visual que necesitaba este cambio. Con algunas líneas de CSS, puedo quitar esos efectos y agregar mi propio estilo cursor: pointer:

.gui-switch {
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

No siempre es recomendable quitar esos estilos, ya que pueden ser comentarios valiosos sobre la interacción visual. Asegúrate de proporcionar alternativas personalizadas si los quitas.

Pista

Los estilos de este elemento se relacionan principalmente con su forma y color, a los que accede desde el .gui-switch superior a través de la cascada.

Se cambian las variantes con tamaños y colores personalizados del segmento.

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

Una amplia variedad de opciones de personalización para el segmento de cambio proviene de cuatro propiedades personalizadas. Se agrega border: none, ya que appearance: none no quita los bordes de la casilla de verificación en todos los navegadores.

Miniatura

El elemento de miniatura ya está en el track de la derecha, pero necesita estilos de círculo:

.gui-switch > input::before {
  background: var(--thumb-color);
  border-radius: 50%;
}

Se muestran DevTools destacando el pseudoelemento de miniatura de círculo.

Interacción

Usa las propiedades personalizadas para prepararte para las interacciones que mostrarán elementos destacados cuando se coloca el cursor sobre ellos y los cambios de posición de los dedos. También se verifica la preferencia del usuario antes de migrar los estilos de movimiento o de desplazamiento del cursor.

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

Posición del pulgar

Las propiedades personalizadas proporcionan un mecanismo de fuente única para posicionar el pulgar en la pista. Tenemos a nuestra disposición los tamaños de la pista y la miniatura que usaremos en los cálculos para mantener la miniatura compensada correctamente y dentro de la pista: 0% y 100%.

El elemento input es propietario de la variable de posición --thumb-position, y el pseudoelemento de control deslizante la usa como una posición translateX:

.gui-switch > input {
  --thumb-position: 0%;
}

.gui-switch > input::before {
  transform: translateX(var(--thumb-position));
}

Ahora podemos cambiar --thumb-position de CSS y las pseudoclases proporcionadas en los elementos de las casillas de verificación. Como configuramos transition: transform var(--thumb-transition-duration) ease de forma condicional antes en este elemento, es posible que estos cambios se animen cuando se modifiquen:

/* 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)
  );
}

Creo que esta orquestación desacoplada funcionó bien. El elemento Thumb solo se relaciona con un estilo, una posición translateX. La entrada puede administrar toda la complejidad y los cálculos.

Vertical

La compatibilidad se realizó con una clase de modificador -vertical que agrega una rotación con transformaciones CSS al elemento input.

Sin embargo, un elemento rotado en 3D no cambia la altura general del componente, lo que puede alterar el diseño del bloque. Ten en cuenta esto con las variables --track-size y --track-padding. Calcula la cantidad mínima de espacio necesaria para que un botón vertical fluya en el diseño como se espera:

.gui-switch.-vertical {
  min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));

  & > input {
    transform: rotate(-90deg);
  }
}

De derecha a izquierda (RTL)

Mi amigo de CSS, Elad Schecter, y yo creamos un prototipo de un menú lateral deslizante con transformaciones de CSS que controlaban los idiomas de derecha a izquierda cambiando una sola variable. Lo hicimos porque no hay transformaciones de propiedades lógicas en CSS, y es posible que nunca las haya. Elad tuvo la gran idea de usar un valor de propiedad personalizado para invertir los porcentajes, lo que permite la administración de una sola ubicación de nuestra propia lógica personalizada para las transformaciones lógicas. Usé la misma técnica en este cambio y creo que funcionó muy bien:

.gui-switch {
  --isLTR: 1;

  &:dir(rtl) {
    --isLTR: -1;
  }
}

Una propiedad personalizada llamada --isLTR tiene inicialmente un valor de 1, lo que significa que es true, ya que nuestro diseño es de izquierda a derecha de forma predeterminada. Luego, con la pseudoclase CSS :dir(), el valor se establece en -1 cuando el componente está dentro de un diseño de derecha a izquierda.

Para usar --isLTR, utilízalo en una calc() dentro de una transformación:

.gui-switch.-vertical > input {
  transform: rotate(-90deg);
  transform: rotate(calc(90deg * var(--isLTR) * -1));
}

Ahora, la rotación del interruptor vertical tiene en cuenta la posición del lado opuesto que requiere el diseño de derecha a izquierda.

Las transformaciones translateX en el pseudoelemento de miniatura también deben actualizarse para tener en cuenta el requisito del lado opuesto:

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

Si bien este enfoque no funcionará para resolver todas las necesidades relacionadas con un concepto como las transformaciones lógicas de CSS, ofrece algunos principios de DRY para muchos casos prácticos.

Estados

El uso de input[type="checkbox"] integrado no estaría completo sin controlar los diversos estados en los que puede estar: :checked, :disabled, :indeterminate y :hover. :focus se dejó de lado de forma intencional, y solo se ajustó su desplazamiento. El anillo de enfoque se veía muy bien en Firefox y Safari:

Captura de pantalla de un anillo de enfoque enfocado en un interruptor en Firefox y Safari.

Marcado

<label for="switch-checked" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>

Este estado representa el estado on. En este estado, el fondo de la “pista” de entrada se establece en el color activo y la posición del control deslizante se establece en “el final”.

.gui-switch > input:checked {
  background: var(--track-color-active);
  --thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}

Inhabilitado

<label for="switch-disabled" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>

Un botón :disabled no solo se ve diferente visualmente, sino que también debe hacer que el elemento sea inmutable.La inmutabilidad de las interacciones no se incluye en el navegador, pero los estados visuales necesitan estilos debido al uso de 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%);
    }}
  }
}

El interruptor con diseño oscuro en los estados inhabilitado, marcado y desmarcado

Este estado es complicado, ya que necesita temas oscuros y claros con estados inhabilitados y marcados. En términos de estilo, elegí estilos minimalistas para estos estados para facilitar la carga de mantenimiento de las combinaciones de estilos.

Indeterminado

Un estado que a menudo se olvida es :indeterminate, en el que una casilla de verificación no está marcada ni desmarcada. Es un estado divertido, atractivo y sencillo. Es un buen recordatorio de que los estados booleanos pueden tener estados intermedios.

Es difícil configurar una casilla de verificación como "indeterminada", solo JavaScript puede configurarla:

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

El estado indeterminado que tiene la miniatura de la pista en el medio para indicar que no se decidió

Como, para mí, el estado es sencillo y atractivo, me pareció apropiado colocar la posición del pulgar del interruptor en el medio:

.gui-switch > input:indeterminate {
  --thumb-position: calc(
    calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
    * var(--isLTR)
  );
}

Colocar el cursor sobre un elemento

Las interacciones de desplazamiento del mouse deben proporcionar compatibilidad visual con la IU conectada y también dirigir a la IU interactiva. Este interruptor destaca el pulgar con un anillo semitransparente cuando se coloca el cursor sobre la etiqueta o la entrada. Luego, esta animación de desplazamiento proporciona dirección hacia el elemento de migas de pan interactivo.

El efecto "destacar" se realiza con box-shadow. Cuando se coloca el cursor sobre una entrada no inhabilitada, aumenta el tamaño de --highlight-size. Si el usuario está de acuerdo con el movimiento, realizamos la transición de box-shadow y vemos cómo crece. Si no está de acuerdo, el elemento destacado aparece de inmediato:

.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

Para mí, una interfaz de interruptor puede resultar extraña en su intento de emular una interfaz física, en especial este tipo con un círculo dentro de una pista. iOS lo hizo bien con su interruptor, puedes arrastrarlo de un lado a otro y es muy satisfactorio tener la opción. Por el contrario, un elemento de la IU puede parecer inactivo si se intenta un gesto de arrastre y no sucede nada.

Dedos arrastrables

El pseudoelemento de control deslizante recibe su posición del var(--thumb-position) centrado en .gui-switch > input. JavaScript puede proporcionar un valor de estilo intercalado en la entrada para actualizar de forma dinámica la posición del control deslizante, lo que hace que parezca que sigue el gesto del puntero. Cuando se suelta el puntero, quita los estilos intercalados y determina si el arrastre estaba más cerca de la posición de apagado o de encendido con la propiedad personalizada --thumb-position. Esta es la columna vertebral de la solución: los eventos del puntero hacen un seguimiento condicional de las posiciones del puntero para modificar las propiedades personalizadas de CSS.

Dado que el componente ya funcionaba al 100% antes de que apareciera esta secuencia de comandos, requiere bastante trabajo mantener el comportamiento existente, como hacer clic en una etiqueta para activar o desactivar la entrada. Nuestro JavaScript no debería agregar funciones a expensas de las funciones existentes.

touch-action

Arrastrar es un gesto personalizado, lo que lo convierte en un gran candidato para obtener beneficios de touch-action. En el caso de este interruptor, nuestra secuencia de comandos debe controlar un gesto horizontal o capturar un gesto vertical para la variante del interruptor vertical. Con touch-action, podemos indicarle al navegador qué gestos controlar en este elemento, de modo que una secuencia de comandos pueda controlar un gesto sin competencia.

El siguiente CSS le indica al navegador que, cuando se inicia un gesto del puntero desde este segmento de cambio, controle los gestos verticales y no haga nada con los horizontales:

.gui-switch > input {
  touch-action: pan-y;
}

El resultado deseado es un gesto horizontal que tampoco desplaza ni desplaza la página. Un puntero puede comenzar a desplazarse verticalmente desde dentro de la entrada y desplazar la página, pero los horizontales se controlan de forma personalizada.

Utilidades de estilo de valor de píxeles

Durante la configuración y el arrastre, se deberán extraer varios valores numéricos calculados de los elementos. Las siguientes funciones de JavaScript muestran valores de píxeles calculados según una propiedad CSS. Se usa en la secuencia de comandos de configuración como esta 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,
}

Observa cómo window.getComputedStyle() acepta un segundo argumento, un pseudoelemento objetivo. Es muy útil que JavaScript pueda leer tantos valores de los elementos, incluso de los pseudoelementos.

dragging

Este es un momento central para la lógica de arrastre y hay algunos aspectos con anotaciones del controlador de eventos de la función:

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`)
}

El elemento principal de la secuencia de comandos es state.activethumb, el pequeño círculo que esta secuencia de comandos posiciona junto con un puntero. El objeto switches es un Map() en el que las claves son .gui-switch y los valores son límites y tamaños almacenados en caché que mantienen la secuencia de comandos eficiente. La orientación de derecha a izquierda se controla usando la misma propiedad personalizada de que CSS es --isLTR, y puede usarla para invertir la lógica y continuar admitiendo RTL. El event.offsetX también es valioso, ya que contiene un valor delta útil para posicionar el pulgar.

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

Esta última línea de CSS establece la propiedad personalizada que usa el elemento de miniatura. De lo contrario, esta asignación de valor realizaría la transición con el tiempo, pero un evento de puntero anterior configuró temporalmente --thumb-transition-duration en 0s, lo que quita lo que habría sido una interacción lenta.

dragEnd

Para que el usuario pueda arrastrar el interruptor fuera de su posición y soltarlo, se debe registrar un evento de ventana global:

window.addEventListener('pointerup', event => {
  if (!state.activethumb) return

  dragEnd(event)
})

Creo que es muy importante que un usuario tenga la libertad de arrastrar de forma flexible y que la interfaz sea lo suficientemente inteligente como para tener en cuenta esto. No fue necesario hacer mucho para controlarlo con este cambio, pero se tuvo que tener en cuenta cuidadosamente durante el proceso de desarrollo.

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()
}

Se completó la interacción con el elemento. Es hora de establecer la propiedad checked de entrada y quitar todos los eventos de gesto. La casilla de verificación se cambia con state.activethumb.checked = determineChecked().

determineChecked()

Esta función, a la que llama dragEnd, determina dónde se encuentra el valor actual del control deslizante dentro de los límites de su pista y muestra verdadero si es igual o superior a la mitad de la pista:

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
}

Reflexiones adicionales

El gesto de arrastre generó un poco de deuda de código debido a la estructura HTML inicial que se eligió, en particular, unir la entrada en una etiqueta. La etiqueta, que es un elemento superior, recibiría interacciones de clic después de la entrada. Al final del evento dragEnd, es posible que hayas notado padRelease() como una función de sonido extraño.

const padRelease = () => {
  state.recentlyDragged = true

  setTimeout(_ => {
    state.recentlyDragged = false
  }, 300)
}

Esto se hace para que la etiqueta reciba este clic más adelante, ya que desmarcaría o marcaría la interacción que realizó un usuario.

Si tuviera que hacerlo de nuevo, podría considerar ajustar el DOM con JavaScript durante la actualización de la UX para crear un elemento que controle los clics de la etiqueta y no entre en conflicto con el comportamiento integrado.

Este tipo de JavaScript es el que menos me gusta escribir, no quiero administrar la propagación de eventos condicionales:

const preventBubbles = event => {
  if (state.recentlyDragged)
    event.preventDefault() && event.stopPropagation()
}

Conclusión

Este pequeño componente del interruptor terminó siendo el más trabajo de todos los desafíos de la GUI hasta ahora. Ahora que sabes cómo lo hice, ¿cómo lo harías tú? 🙂

Diversifiquemos nuestros enfoques y aprendamos todas las formas de compilar en la Web. Crea una demostración, twittea vínculos y la agregaré a la sección de remixes de la comunidad que aparece más abajo.

Remixes de la comunidad

Recursos

Encuentra el código fuente de .gui-switch en GitHub.