Créer un composant switch

Présentation des principes de base de la création d'un composant switch réactif et accessible.

Dans cet article, je vais vous expliquer comment créer des composants de commutateur. Tester la fonctionnalité

Démonstration

Si vous préférez la vidéo, voici une version YouTube de ce post:

Présentation

Un commutateur fonctionne de la même manière qu'une case à cocher, mais représente explicitement les états d'activation et de désactivation des valeurs booléennes.

Cette démonstration utilise <input type="checkbox" role="switch"> pour la majorité de ses fonctionnalités, ce qui présente l'avantage de ne pas avoir besoin de CSS ou de JavaScript pour être entièrement fonctionnel et accessible. Le chargement CSS permet de prendre en charge les langues qui se lisent de droite à gauche, la verticalité, l'animation, etc. Le chargement de JavaScript rend le commutateur déplaçable et tangible.

Propriétés personnalisées

Les variables suivantes représentent les différentes parties du bouton bascule et leurs options. En tant que classe de niveau supérieur, .gui-switch contient les propriétés personnalisées utilisées dans les enfants des composants, ainsi que des points d'entrée pour une personnalisation centralisée.

Suivi

La longueur (--track-size), la marge intérieure et les deux couleurs:

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

Thumb

La taille, la couleur d'arrière-plan et les couleurs de mise en surbrillance de l'interaction:

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

Mouvements réduits

Pour ajouter un alias clair et réduire les répétitions, vous pouvez insérer une requête média utilisateur à préférence de mouvement réduite dans une propriété personnalisée avec le plug-in PostCSS, en fonction de cette spécification préliminaire dans les requêtes média 5:

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

Markup

J'ai choisi d'encapsuler mon élément <input type="checkbox" role="switch"> avec un <label>, en regroupant leur relation pour éviter toute ambiguïté d'association de cases à cocher et de libellés, tout en permettant à l'utilisateur d'interagir avec le libellé pour activer/désactiver l'entrée.

Étiquette et case à cocher naturelles sans style.

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

<input type="checkbox"> est précompilé avec une API et un state. Le navigateur gère la propriété checked et les événements d'entrée tels que oninput et onchanged.

Mises en page

Flexbox, grid et les propriétés personnalisées sont essentiels pour conserver les styles de ce composant. Elles centralisent les valeurs, donnent des noms à des calculs ou des zones autrement ambigus, et activent une petite API de propriété personnalisée pour personnaliser facilement les composants.

.gui-switch

La mise en page de premier niveau pour le commutateur est Flexbox. La classe .gui-switch contient les propriétés personnalisées privées et publiques que les enfants utilisent pour calculer leurs mises en page.

Outils de développement Flexbox superposés à une étiquette et un bouton bascule horizontal, montrant la répartition de l&#39;espace dans la mise en page

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

L'extension et la modification de la mise en page Flexbox revient à modifier n'importe quelle mise en page Flexbox. Par exemple, pour placer des libellés au-dessus ou en dessous d'un commutateur, ou pour modifier le flex-direction:

Outils de développement Flexbox superposés à un libellé et à un commutateur verticaux.

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

Suivi

L'entrée de la case à cocher est stylisée comme une piste de commutation en supprimant sa appearance: checkbox normale et en fournissant sa propre taille à la place:

Outils de développement en grille superposée sur la piste de commutation, montrant les zones de piste de grille nommées nommées &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 piste crée également une zone de piste de grille de cellule une par une que le curseur peut revendiquer.

Thumb

Le style appearance: none supprime également la coche visuelle fournie par le navigateur. Ce composant utilise un pseudo-élément et la pseudo-classe :checked sur l'entrée pour remplacer cet indicateur visuel.

Le curseur est un enfant du pseudo-élément associé à input[type="checkbox"]. Il s'empile sur la piste et non en dessous en revendiquant la zone de la grille track:

Outils de développement affichant le pouce du pseudo-élément placé dans une grille CSS

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

Styles

Les propriétés personnalisées permettent un composant switch polyvalent qui s'adapte aux jeux de couleurs, aux langues qui se lisent de droite à gauche et aux préférences de mouvement.

Comparaison côte à côte des thèmes clair et sombre pour le bouton bascule et ses états.

Styles d'interaction tactile

Sur mobile, les navigateurs ajoutent des fonctionnalités de mise en surbrillance et de sélection de texte aux libellés et aux entrées. Cela a eu un impact négatif sur le style et le retour d'interaction visuel nécessaires à ce changement. Avec quelques lignes de code CSS, je peux supprimer ces effets et ajouter mon propre style cursor: pointer:

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

Il n'est pas toujours conseillé de supprimer ces styles, car ils peuvent être précieux pour les interactions visuelles. Si vous les supprimez, assurez-vous de fournir des alternatives personnalisées.

Suivi

Les styles de cet élément concernent principalement sa forme et sa couleur, auxquelles il accède à partir de l'élément .gui-switch parent via la cascade.

Variantes de commutateurs avec des tailles et des couleurs de circuit personnalisés.

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

Un large éventail d'options de personnalisation pour le canal de changement provient de quatre propriétés personnalisées. border: none a été ajouté, car appearance: none ne supprime pas les bordures de la case à cocher dans tous les navigateurs.

Thumb

L'élément "pouce" se trouve déjà sur l'élément track de droite, mais doit avoir des styles de cercle:

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

Outils de développement affiché, mettant en évidence le pseudo-élément représentant un pouce en forme de cercle.

Interaction

Utilisez des propriétés personnalisées pour vous préparer aux interactions qui afficheront des mises en surbrillance lors du survol et des changements de position du curseur. Les préférences de l'utilisateur sont également cochées avant la transition des styles de mise en surbrillance du mouvement ou du survol.

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

Position du pouce

Les propriétés personnalisées fournissent un mécanisme source unique pour positionner le curseur sur la piste. Nous disposons des tailles des rails et des pouces, que nous utiliserons pour les calculs afin de maintenir le décalage correct du pouce et de l'espace entre les deux dans la piste : 0% et 100%.

L'élément input possède la variable de position --thumb-position, et le pseudo-élément du curseur l'utilise en tant que position translateX:

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

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

Nous pouvons maintenant modifier --thumb-position à partir de CSS et des pseudo-classes fournies sur les éléments de case à cocher. Étant donné que nous avons précédemment défini transition: transform var(--thumb-transition-duration) ease de manière conditionnelle sur cet élément, ces modifications peuvent s'animer lorsqu'elles sont modifiées:

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

Je pensais que cette orchestration dissociée fonctionnait bien. L'élément de curseur ne concerne qu'un seul style, une position translateX. L'entrée peut gérer toute la complexité et les calculs.

Vertical

La prise en charge a été effectuée avec une classe de modificateur -vertical qui ajoute une rotation avec des transformations CSS à l'élément input.

Toutefois, un élément ayant fait l'objet d'une rotation en 3D ne modifie pas la hauteur globale du composant, ce qui peut perturber la mise en page en blocs. Tenez-en compte à l'aide des variables --track-size et --track-padding. Calculez l'espace minimal requis pour qu'un bouton vertical s'affiche correctement dans la mise en page:

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

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

De droite à gauche

Avec Elad Schecter, un ami CSS, nous avons prototypé ensemble un menu latéral coulissant à l'aide de transformations CSS qui géraient les langues qui se lisent de droite à gauche en inversant une seule variable. Nous l'avons fait, car il n'existe pas de transformation de propriété logique en CSS, et il se peut qu'il n'y en ait jamais. Elad a eu la formidable idée d'utiliser une valeur de propriété personnalisée pour inverser les pourcentages, afin de permettre la gestion d'un emplacement unique de notre logique personnalisée pour les transformations logiques. J'ai utilisé cette même technique dans ce changement et je pense qu'elle a très bien fonctionné:

.gui-switch {
  --isLTR: 1;

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

Une propriété personnalisée appelée --isLTR contient initialement la valeur 1, ce qui signifie qu'elle est true, car notre mise en page est de gauche à droite par défaut. Ensuite, à l'aide de la pseudo-classe CSS :dir(), la valeur est définie sur -1 lorsque le composant se trouve dans une mise en page de droite à gauche.

Mettez --isLTR en action en l'utilisant dans un calc() dans une transformation:

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

Désormais, la rotation du commutateur vertical prend en compte la position du côté opposé requise par la mise en page de droite à gauche.

Les transformations translateX du pseudo-élément "pouce" doivent également être mises à jour pour tenir compte de l'exigence opposée:

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

Bien que cette approche ne puisse pas répondre à tous les besoins liés à un concept tel que les transformations CSS logiques, elle offre certains principes DRY pour de nombreux cas d'utilisation.

États

L'utilisation de la input[type="checkbox"] intégrée ne serait pas complète sans la gestion des différents états dans lesquels elle peut se trouver: :checked, :disabled, :indeterminate et :hover. :focus a été volontairement laissé seul, avec seulement un ajustement effectué sur son décalage. L'anneau de mise au point était particulièrement bien adapté à Firefox et Safari:

Capture d&#39;écran de l&#39;anneau de focus sur un commutateur dans Firefox et Safari.

Coché

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

Cet état représente l'état on. Dans cet état, l'arrière-plan d'entrée "track" est défini sur la couleur active et la position du curseur est définie sur "la fin".

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

Désactivé

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

Un bouton :disabled est visuellement différent, mais il doit également rendre l'élément immuable.L'immuabilité de l'interaction est libre du navigateur, et les états visuels ont besoin de styles en raison de l'utilisation 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%);
    }}
  }
}

Le bouton sombre est désactivé, coché et décoché.

Cet état est délicat, car il a besoin de thèmes sombres et clairs, désactivés et cochés. J'ai choisi stylistiquement des styles minimaux pour ces états afin de faciliter la maintenance des combinaisons de styles.

Indéterminée

Un état souvent oublié est :indeterminate, où une case n'est ni cochée, ni décochée. C'est amusant, accueillant et simple. Pour rappel, les états booléens peuvent être soudains entre les états.

Il est difficile de définir une case à cocher indéterminée. Seul JavaScript peut la définir:

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

État indéterminé avec le curseur de piste au milieu, pour indiquer que la question est indéterminée.

Étant donné que l'état est simple et engageant, il m'a semblé approprié de placer le curseur de l'interrupteur au milieu:

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

Pointer le curseur

Les interactions avec la souris doivent fournir un support visuel pour l'UI connectée et fournir une direction vers l'UI interactive. Ce bouton bascule surligne le curseur avec un anneau semi-transparent lorsque l'utilisateur pointe sur l'étiquette ou l'entrée. Cette animation de survol indique ensuite la direction vers l'élément interactif du pouce.

L'effet de mise en surbrillance est effectué avec box-shadow. En pointant sur une entrée non désactivée, augmentez la taille de --highlight-size. Si l'utilisateur accepte les mouvements, la box-shadow est mise en surbrillance et s'agrandit. Dans le cas contraire, la mise en surbrillance apparaît instantanément:

.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

Pour moi, une interface de commutateur peut sembler étrange dans sa tentative d'émuler une interface physique, en particulier celle-ci avec un cercle à l'intérieur d'un circuit. iOS a tout compris avec son commutateur, vous pouvez les faire glisser d'un côté à l'autre, et c'est très satisfaisant de cette option. À l'inverse, un élément d'interface utilisateur peut sembler inactif si un geste de déplacement est effectué et que rien ne se passe.

"Pouces" déplaçables

Le pseudo-élément de curseur reçoit sa position à partir du var(--thumb-position) limité par .gui-switch > input. JavaScript peut fournir une valeur de style intégrée à l'entrée pour mettre à jour de manière dynamique la position du curseur et donner l'impression qu'elle suit le geste du pointeur. Lorsque le pointeur est relâché, supprimez les styles intégrés et déterminez si le déplacement était plus proche ou activé à l'aide de la propriété personnalisée --thumb-position. Il s'agit de l'épine dorsale de la solution. Les événements de pointeur suivent les positions du pointeur de manière conditionnelle afin de modifier les propriétés personnalisées CSS.

Étant donné que le composant fonctionnait déjà à 100% avant l'affichage de ce script, conserver le comportement existant nécessite beaucoup de travail, comme cliquer sur un libellé pour activer/désactiver l'entrée. Notre code JavaScript ne doit pas ajouter de fonctionnalités au détriment des caractéristiques existantes.

touch-action

Le déplacement est un geste personnalisé, ce qui en fait un excellent candidat pour les avantages de touch-action. Dans le cas de ce commutateur, un geste horizontal doit être géré par notre script ou un geste vertical capturé pour la variante de commutateur vertical. touch-action permet d'indiquer au navigateur les gestes à gérer sur cet élément, afin qu'un script puisse gérer un geste sans concurrence.

Le CSS suivant indique au navigateur qu'il doit gérer les gestes verticaux lorsqu'un geste du pointeur commence à partir de cette piste de commutateur et qu'il ne doit rien faire avec les gestes horizontaux:

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

Le résultat souhaité est un geste horizontal qui ne permet pas non plus de faire un panoramique ou de faire défiler la page. Un pointeur peut faire défiler verticalement le début de l'entrée et la page, mais les affichages horizontaux sont gérés de façon personnalisée.

Utilitaires de style "Valeur de pixel"

Lors de la configuration et du déplacement, différentes valeurs numériques calculées devront être extraites des éléments. Les fonctions JavaScript suivantes renvoient des valeurs de pixels calculées en fonction d'une propriété CSS. Il est utilisé dans le script de configuration de la manière suivante : 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,
}

Notez que window.getComputedStyle() accepte un deuxième argument, un pseudo-élément cible. C'est très bien que JavaScript puisse lire autant de valeurs à partir d'éléments, même à partir de pseudo-éléments.

dragging

Il s'agit d'un moment clé pour la logique de déplacement, et le gestionnaire d'événements de fonction doit noter quelques points:

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

Le héros du script est state.activethumb. Il s'agit du petit cercle que ce script place avec un pointeur. L'objet switches est un Map() où les clés sont de type .gui-switch et les valeurs sont des limites et des tailles mises en cache qui permettent au script d'être efficace. La lecture de droite à gauche est gérée à l'aide de la même propriété personnalisée que le CSS --isLTR. Elle peut l'utiliser pour inverser la logique et continuer à accepter le texte de droite à gauche. Le event.offsetX est également utile, car il contient une valeur delta utile pour positionner le curseur.

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

Cette dernière ligne de CSS définit la propriété personnalisée utilisée par l'élément "pouce". Cette attribution de valeur aurait autrement une transition au fil du temps, mais un événement de pointeur précédent a temporairement défini --thumb-transition-duration sur 0s, ce qui supprime ce qui aurait été une interaction lente.

dragEnd

Pour que l'utilisateur soit autorisé à sortir du commutateur et à le relâcher, un événement de fenêtre globale doit être enregistré:

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

  dragEnd(event)
})

Je pense qu'il est très important qu'un utilisateur ait la liberté de faire glisser lâchement et que l'interface soit suffisamment intelligente pour en tenir compte. Cette transition n'a pas nécessité grand-chose, mais nécessitait une attention particulière au cours du processus de développement.

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

L'interaction avec l'élément est terminée, il est temps de définir la propriété d'entrée vérifiée et de supprimer tous les événements gestuels. La case à cocher est remplacée par state.activethumb.checked = determineChecked().

determineChecked()

Cette fonction, appelée par dragEnd, détermine l'emplacement du courant du curseur dans les limites de sa piste et renvoie la valeur "true" si elle est égale ou supérieure à la moitié de la piste:

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
}

Pensées supplémentaires

Le geste de déplacement a entraîné un manque de code en raison de la structure HTML initiale choisie, notamment l'encapsulation de l'entrée dans un libellé. L'étiquette, en tant qu'élément parent, recevrait des interactions de clic après l'entrée. À la fin de l'événement dragEnd, vous avez peut-être remarqué que padRelease() était une fonction étrange.

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

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

Cela permet de tenir compte du libellé obtenant ce clic ultérieur, car il décocherait ou vérifierait l'interaction effectuée par un utilisateur.

Si je recommence, je pourrais envisager d'ajuster le DOM avec JavaScript lors de la mise à niveau de l'expérience utilisateur, afin de créer un élément qui gère lui-même les clics sur les libellés et ne lutte pas avec le comportement intégré.

Ce type de code JavaScript est le moins que j'aime écrire. Je ne veux pas gérer l'ébullition d'événements conditionnels:

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

Conclusion

Ce composant switch adolescent a fini par être le plus gros de tous les défis IUG jusqu'à présent ! Maintenant que vous savez comment je l'ai fait, comment le feriez-vous‽ 😃 ?

Diversissons nos approches et apprenons toutes les façons de créer sur le Web. Créez une démo, cliquez sur les liens tweet me, et je l'ajouterai à la section "Remix" de la communauté ci-dessous.

Remix de la communauté

Ressources

Recherchez le .gui-switch code source sur GitHub.