Cómo compilar un componente de interruptor

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

En esta publicación, quiero compartir las ideas sobre una forma de crear 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 switch es similar a una casilla de verificación, pero representa explícitamente los estados booleanos de activación y desactivación.

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 ser completamente funcional y accesible. La carga de CSS brinda compatibilidad con idiomas que se escriben de derecha a izquierda, verticalidad, animación y mucho más. La carga de JavaScript hace que el interruptor sea arrastrable y tangible.

Propiedades personalizadas

Las siguientes variables representan las distintas partes del interruptor y sus opciones. Como la 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.

Seguimiento

La longitud (--track-size), el padding y 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 resaltado 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 del usuario con preferencia de movimiento reducida en una propiedad personalizada con el complemento PostCSS según esta especificación en borrador en las consultas de contenido multimedia 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 ambigüedades en la asociación de etiquetas y casillas de verificación. Al mismo tiempo, le otorgamos al usuario la capacidad de interactuar con la etiqueta para activar o desactivar la entrada.

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

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

<input type="checkbox"> viene previamente compilado con una API y un state. 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 valores, asignan nombres a cálculos o áreas que podrían ser ambiguos y habilitan una API de propiedad personalizada pequeña para personalizar los componentes con facilidad.

.gui-switch

El diseño de nivel superior para el 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.

Herramientas para desarrolladores de Flexbox sobre una etiqueta horizontal y un interruptor, lo que muestra la distribución del espacio de su diseño.

.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 encima o debajo de un interruptor, o bien para cambiar el flex-direction, haz lo siguiente:

Herramientas para desarrolladores de Flexbox superponer 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>

Seguimiento

La entrada de la casilla de verificación tiene el estilo de una pista de cambio, ya que quita su appearance: checkbox normal y proporciona su propio tamaño:

Las Herramientas para desarrolladores de cuadrícula se superponen con el segmento de cambio y muestran las áreas del recorrido de la cuadrícula con nombre &#39;track&#39;.

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

El recorrido también crea un área de seguimiento de cuadrícula de una por una celda para que la miniatura pueda reclamarlo.

Miniatura

El diseño 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 círculo es un seudoelemento secundario adjunto a input[type="checkbox"] y se apila en la parte superior de la pista, en lugar de debajo de ella, cuando se reclama el área de cuadrícula track:

Herramientas para desarrolladores que muestran el pulgar del seudoelemento 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 habilitan un componente de interruptor versátil que se adapta a esquemas de colores, idiomas de derecha a izquierda y preferencias de movimiento.

Una comparación en paralelo de los temas claro y oscuro del interruptor y sus estados

Estilos de interacción táctil

En dispositivos móviles, los navegadores agregan elementos destacados cuando se presiona y funciones de selección de texto a las etiquetas y a las entradas. Esto afectaron negativamente el estilo y los comentarios de interacción visual que necesitaba el 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 se recomienda quitar esos diseños, ya que pueden ser valiosos comentarios de interacción visual. Asegúrate de proporcionar alternativas personalizadas si las quitas.

Seguimiento

Los diseños de este elemento se centran principalmente en su forma y color, a los que se accede desde el .gui-switch superior a través de la cascada.

Las variantes del interruptor con tamaños y colores de pistas personalizados.

.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 interruptores proviene de cuatro propiedades personalizadas. Se agregó border: none, ya que appearance: none no quita los bordes de la casilla de verificación en todos los navegadores.

Miniatura

El elemento Thumb ya se encuentra en el track de la derecha, pero necesita estilos de círculo:

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

Se muestra Herramientas para desarrolladores destacando el seudoelemento circular.

Interacción

Usa propiedades personalizadas a fin de prepararte para las interacciones, en las que se mostrarán elementos destacados cuando se coloca el cursor sobre un elemento y los cambios de posición de los pulgares. La preferencia del usuario también se verifica antes de hacer la transición de los estilos de movimiento o resaltar.

.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 único para posicionar el círculo en la pista. A nuestra disposición, están los tamaños del segmento y del pulgar que usaremos en los cálculos para mantener la barra vertical y entre las que se ubican correctamente: 0% y 100%.

El elemento input posee la variable de posición --thumb-position, y el pseudoelemento miniatura 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 seudoclases que se proporcionan en los elementos de la casilla de verificación. Dado que antes configuramos transition: transform var(--thumb-transition-duration) ease de forma condicional en este elemento, es posible que se animen estos cambios 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)
  );
}

Pensé que esta orquestación separada funcionó bien. El elemento pulgar se relaciona con un solo estilo, una posición translateX. La entrada puede gestionar 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 de CSS al elemento input.

Sin embargo, un elemento rotado en 3D no cambia la altura general del componente, lo que puede afectar el diseño del bloque. Ten esto en cuenta con las variables --track-size y --track-padding. Calcula la cantidad mínima de espacio requerido 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)

Un amigo de CSS, Elad Schecter, y yo creamos un prototipo juntos de un menú lateral de deslizamiento con transformaciones de CSS que manejaban los lenguajes de derecha a izquierda con solo girar una sola variable. Lo hicimos porque no hay transformaciones de propiedad lógicas en CSS y es posible que nunca las haya. Elad tuvo la gran idea de usar un valor de propiedad personalizada para invertir los porcentajes y permitir la administración de una sola ubicación de nuestra lógica personalizada para transformaciones lógicas. Usé esta misma técnica en este cambio y creo que funcionó muy bien:

.gui-switch {
  --isLTR: 1;

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

Inicialmente, una propiedad personalizada llamada --isLTR tiene 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 seudoclase de CSS :dir(), el valor se establece en -1 cuando el componente está dentro de un diseño de derecha a izquierda.

Usa --isLTR en acción con 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 seudoelemento pulgar también deben actualizarse para tener en cuenta el requisito lateral 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á a fin de resolver todas las necesidades de un concepto como las transformaciones de CSS lógicas, ofrece algunos principios de DRY para muchos casos de uso.

Estados

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

Captura de pantalla del anillo de enfoque enfocada 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 pulgar 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 interacción está libre del 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 de estilo oscuro está inhabilitado, marcado y desmarcado.

Este estado es complicado, ya que necesita temas oscuros y claros con los estados inhabilitados y verificados. Elegí estilísticamente diseños mínimos para estos estados para aliviar la carga de mantenimiento de las combinaciones de diseños.

No concluyente

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

Es difícil establecer 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>

Es el estado indeterminado que tiene el círculo en el medio, para indicar &quot;indeciso&quot;.

Dado que el estado, para mí, es discreto y atractivo, me pareció adecuado colocar el interruptor del pulgar 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 colocar el cursor sobre un elemento deben proporcionar compatibilidad visual para la IU conectada y también proporcionar instrucciones hacia la IU interactiva. Este interruptor destaca el círculo con un anillo semitransparente cuando se coloca el cursor sobre la etiqueta o la entrada. Luego, esta animación de desplazamiento proporciona una dirección hacia el elemento miniatura interactivo.

El efecto "destacar" se realiza con box-shadow. Cuando colocas 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 que crece. Si no está de acuerdo con el movimiento, el resaltado aparece instantáneamente:

.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 switch 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 acertó con su interruptor, puedes arrastrarlos 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 realizar un gesto de arrastre y no sucede nada.

Pulgares arrastrables

El seudoelemento miniatura recibe su posición del alcance de .gui-switch > input var(--thumb-position). JavaScript puede proporcionar un valor de estilo intercalado en la entrada para actualizar de forma dinámica la posición del pulgar, lo que hace que parezca seguir el gesto del puntero. Cuando se suelta el puntero, quita los diseños intercalados y determina si el arrastre estaba más cerca de desactivado o activado con la propiedad personalizada --thumb-position. Esta es la base de la solución: los eventos de punteros realizan un seguimiento condicional de las posiciones del puntero para modificar las propiedades personalizadas de CSS.

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

touch-action

Arrastrar es un gesto personalizado, lo que lo convierte en un excelente candidato para obtener beneficios de touch-action. En el caso de este interruptor, nuestra secuencia de comandos debería controlar un gesto horizontal, o bien un gesto vertical capturado para la variante del interruptor vertical. Con touch-action, podemos decirle 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 un gesto de puntero comienza desde este segmento del interruptor, no controla los gestos verticales, pero no hace nada con los horizontales:

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

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

Utilidades de estilo de valor de píxel

Durante la configuración y durante el arrastre, se deberán tomar varios valores numéricos calculados de los elementos. Las siguientes funciones de JavaScript muestran valores de píxeles calculados según una propiedad de CSS. Se usa en la secuencia de comandos de configuración de la siguiente manera: 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 seudoelemento de destino. Es genial que JavaScript pueda leer tantos valores de los elementos, incluso de pseudoelementos.

dragging

Este es un momento fundamental para la lógica de arrastre, y hay algunos aspectos que se deben tener en cuenta desde el 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`)
}

La secuencia de comandos hero es state.activethumb, el círculo pequeño que posiciona esta secuencia de comandos junto con un puntero. El objeto switches es un Map() en el que las claves son de .gui-switch y los valores son límites y tamaños almacenados en caché que mantienen la eficiencia de la secuencia de comandos. La opción de derecha a izquierda se controla con la misma propiedad personalizada que CSS --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 pulgar. De lo contrario, esta asignación de valor cambiaría con el tiempo, pero un evento de puntero anterior estableció --thumb-transition-duration temporalmente en 0s, lo que quitó lo que habría sido una interacción lenta.

dragEnd

Para que el usuario pueda arrastrar fuera del interruptor y soltarlo, era necesario registrar un evento de ventana global:

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

  dragEnd(event)
})

Creo que es muy importante que el usuario tenga la libertad de arrastrar y soltar elementos, y que la interfaz sea lo suficientemente inteligente como para dar cuenta de ello. No tardó mucho en manejarlo con este cambio, pero requirió una consideración cuidadosa 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 configurar la propiedad de entrada marcada y quitar todos los eventos de gestos. La casilla de verificación se cambia por state.activethumb.checked = determineChecked().

determineChecked()

Esta función, a la que llama dragEnd, determina dónde se encuentra la corriente circular dentro de los límites de su seguimiento y muestra el valor true si es igual a la mitad del recorrido o sobre la mitad de este:

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
}

Pensamientos adicionales

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

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

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

Esto sirve para tener en cuenta que la etiqueta recibe este clic posterior, ya que desmarcaría o verificaría la interacción que realizó un usuario.

Si volviera a hacerlo, podría considerar la posibilidad de ajustar el DOM con JavaScript durante la actualización de la UX para crear un elemento que controle los clics en las etiquetas y no luche contra el comportamiento integrado.

Este tipo de JavaScript es el que menos me gusta escribir; no quiero administrar el burbujas de eventos condicionales:

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

Conclusión

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

Diversifiquemos nuestros enfoques y aprendamos todas las formas de desarrollar en la Web. Crea una demostración, twittea vínculos y la agregaré a la sección de remixes de la comunidad a continuación.

Remixes de la comunidad

Recursos

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