Créer un composant switch

Présentation de base sur la création d'un composant de bouton d'activation/de désactivation responsif et accessible.

Dans cet article, je souhaite partager une réflexion sur la façon de créer des composants de commutateur. Tester la fonctionnalité

Démo

Si vous préférez regarder une vidéo, voici une version YouTube de cet article:

Présentation

Un boutons fonctionne comme une case à cocher, mais représente explicitement les états "Activé" et "Désactivé" booléens.

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 nécessiter de CSS ni de JavaScript pour être entièrement fonctionnel et accessible. Le chargement du CSS est compatible avec les langues de l'écriture de droite à gauche, la verticalité, l'animation et plus encore. Le chargement de JavaScript rend le bouton bascule déplaçable et tangible.

Propriétés personnalisées

Les variables suivantes représentent les différentes parties du commutateur et leurs options. En tant que classe de premier niveau, .gui-switch contient des propriétés personnalisées utilisées dans l'ensemble des enfants du composant, ainsi que des points d'entrée pour la personnalisation centralisée.

Suivre

La longueur (--track-size), la marge intérieure et 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

Taille, couleur d'arrière-plan et couleurs de surbrillance des interactions:

.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, une requête multimédia utilisateur de préférence de mouvement réduite peut être placée dans une propriété personnalisée avec le plug-in PostCSS, basé sur cette spécification préliminaire dans les requêtes multimédias 5:

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

Annoter

J'ai choisi d'encapsuler mon élément <input type="checkbox" role="switch"> avec un <label>, en regroupant leur relation pour éviter l'ambiguïté d'association entre la case à cocher et le libellé, 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édéfini avec une API et un état. Le navigateur gère la propriété checked et les événements d'entrée tels que oninput et onchanged.

Mises en page

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

.gui-switch

La mise en page de premier niveau du bouton bascule 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.

Les outils de développement Flexbox superposent un libellé horizontal et un bouton, montrant leur distribution de l&#39;espace de 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 sont similaires à la modification de n'importe quelle mise en page Flexbox. Par exemple, pour placer des libellés au-dessus ou en dessous d'un bouton, ou pour modifier la valeur flex-direction:

Les outils de développement Flexbox superposent un bouton et une étiquette verticale.

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

Suivre

La zone de saisie de la case à cocher est conçue comme un bouton d'activation/de désactivation en supprimant son appearance: checkbox normal et en fournissant sa propre taille à la place:

Les outils de développement de la grille superposés au rail de commutation, affichant les zones de rail de grille nommées avec le nom &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;
}

Le canal crée également une zone de canal de grille à une seule cellule pour qu'un pouce puisse la 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 pseudo-élément enfant associé à input[type="checkbox"] et s'empile au-dessus du rail au lieu en dessous en revendiquant la zone de grille track:

DevTools montrant le curseur du pseudo-élément positionné 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 de créer un composant de bouton polyvalent qui s'adapte aux schémas de couleurs, aux langues de l'arabe et aux préférences de mouvement.

Comparaison côte à côte du thème clair et sombre pour le bouton et ses états.

Styles d'interaction tactile

Sur mobile, les navigateurs ajoutent des surbrillances et des fonctionnalités de sélection de texte aux libellés et aux entrées. Cela a eu un impact négatif sur le style et les commentaires d'interaction visuelle dont ce bouton avait besoin. 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 fournir des commentaires visuels sur l'interaction. Veillez à fournir des alternatives personnalisées si vous les supprimez.

Suivre

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

Variantes du bouton avec des tailles et des couleurs de piste personnalisées.

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

Quatre propriétés personnalisées offrent un large éventail d'options de personnalisation pour le canal de commutation. border: none est ajouté, car appearance: none ne supprime pas les bordures de la case à cocher dans tous les navigateurs.

Thumb

L'élément de curseur se trouve déjà dans le track de droite, mais il a besoin de styles de cercle:

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

DevTools mis en surbrillance le pseudo-élément de curseur de cercle.

Interaction

Utilisez des propriétés personnalisées pour vous préparer aux interactions qui afficheront des surbrillances en survol et des modifications de la position du curseur. Les préférences de l'utilisateur sont également vérifiées avant de passer aux styles de mouvement ou de 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 dans la piste. Nous avons à notre disposition les tailles de la piste et du curseur que nous utiliserons dans les calculs pour que le curseur soit correctement décalé et situé entre la piste : 0% et 100%.

L'élément input est propriétaire de la variable de position --thumb-position, et le pseudo-élément de curseur l'utilise comme position translateX:

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

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

Nous pouvons désormais modifier --thumb-position à partir du CSS et des pseudo-classes fournies sur les éléments de case à cocher. Étant donné que nous avons défini transition: transform var(--thumb-transition-duration) ease de manière conditionnelle sur cet élément précédemment, 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 pense que cette orchestration déconnectée a bien fonctionné. 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 à l'aide d'une classe de modificateur -vertical qui ajoute une rotation avec des transformations CSS à l'élément input.

Cependant, un élément en rotation 3D ne modifie pas la hauteur globale du composant, ce qui peut fausser la mise en page des blocs. Tenez compte de cela à l'aide des variables --track-size et --track-padding. Calculez la quantité d'espace minimale requise pour qu'un bouton vertical s'affiche dans la mise en page comme prévu:

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

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

(RTL) de droite à gauche

Avec un ami CSS, Elad Schecter, nous avons prototypé un menu latéral coulissant à l'aide de transformations CSS qui géraient les langues de droite à gauche en inversant une seule variable. Nous avons fait cela, car il n'y a pas de transformations de propriétés logiques en CSS, et il est possible qu'il n'y en ait jamais. Elad a eu la bonne idée d'utiliser une valeur de propriété personnalisée pour inverser les pourcentages, afin de permettre la gestion d'un seul emplacement de notre propre logique personnalisée pour les transformations logiques. J'ai utilisé cette même technique dans ce commutateur et je pense que cela 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.

Utilisez --isLTR dans une calc() dans une transformation:

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

La rotation du bouton bascule vertical tient désormais compte de la position opposée requise par la mise en page de droite à gauche.

Les transformations translateX sur le pseudo-élément de curseur doivent également être mises à jour pour tenir compte de l'exigence de l'autre côté:

.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 résolve pas tous les besoins concernant un concept tel que les transformations CSS logiques, elle offre certains principes DRY pour de nombreux cas d'utilisation.

États

L'utilisation de input[type="checkbox"] intégré ne serait pas complète sans gérer les différents états dans lesquels il peut se trouver: :checked, :disabled, :indeterminate et :hover. :focus a été laissé intentionnellement tel quel, avec un ajustement uniquement effectué sur son décalage. L'anneau de mise au point était superbe sur Firefox et Safari:

Capture d&#39;écran de l&#39;anneau de sélection sur un bouton 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 de la "piste" de saisie 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 ne se distingue pas seulement visuellement, mais doit également rendre l'élément immuable.L'immuabilité des interactions est indépendante du navigateur, mais 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%);
    }}
  }
}

Bouton de style sombre dans les états &quot;Désactivé&quot;, &quot;Coché&quot; et &quot;Décoché&quot;.

Cet état est délicat, car il nécessite des thèmes clairs et sombres avec des états désactivés et cochés. J'ai choisi des styles minimalistes pour ces états afin de réduire la charge de maintenance des combinaisons de styles.

Indéterminé

L'état :indeterminate, où une case n'est ni cochée ni décochée, est souvent oublié. Il s'agit d'un état amusant, invitant et modeste. Bon rappel que les états booléens peuvent avoir des états intermédiaires.

Il est difficile de définir une case à cocher sur "indéterminé". Seul JavaScript peut le faire:

<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é dans lequel le curseur de la piste se trouve au milieu, pour indiquer une indécision.

Comme l'état, à mon avis, est discret et invitant, il m'a semblé approprié de placer la position du bouton du contacteur au milieu:

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

Survol

Les interactions avec le pointeur doivent fournir une assistance visuelle pour l'UI connectée et également indiquer l'UI interactive. Ce bouton met en surbrillance le curseur avec un anneau semi-transparent lorsque vous pointez sur le libellé ou la saisie. Cette animation de survol indique ensuite l'élément de curseur interactif.

L'effet "mettre en surbrillance" est obtenu avec box-shadow. Lorsque vous pointez sur une entrée non désactivée, augmentez la taille de --highlight-size. Si l'utilisateur accepte le mouvement, nous effectuons une transition de l'box-shadow et le voyons grandir. S'il ne l'accepte pas, le surlignage s'affiche 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 bouton peut sembler étrange dans sa tentative d'émuler une interface physique, en particulier ce type avec un cercle dans un rail. iOS a bien compris cela avec son bouton, vous pouvez le faire glisser d'un côté à l'autre, et c'est très satisfaisant d'avoir cette option. À l'inverse, un élément d'interface utilisateur peut sembler inactif si vous essayez de le faire glisser 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) de portée .gui-switch > input. JavaScript peut fournir une valeur de style intégrée à l'entrée pour mettre à jour dynamiquement la position du curseur, ce qui donne l'impression qu'il suit le geste du pointeur. Lorsque le pointeur est relâché, supprimez les styles intégrés et déterminez si le glissement était plus proche de la désactivation ou de l'activation à 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 de manière conditionnelle les positions du pointeur pour modifier les propriétés personnalisées CSS.

Étant donné que le composant était déjà 100% fonctionnel avant l'affichage de ce script, il faut beaucoup de travail pour conserver le comportement existant, comme cliquer sur un libellé pour activer ou désactiver la saisie. Notre code JavaScript ne doit pas ajouter de fonctionnalités au détriment des fonctionnalités existantes.

touch-action

Le glissement est un geste, un geste personnalisé, ce qui en fait un excellent candidat pour les avantages touch-action. Dans le cas de ce bouton, un geste horizontal doit être géré par notre script ou un geste vertical capturé pour la variante de bouton vertical. Avec touch-action, nous pouvons 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 que lorsqu'un geste de pointeur commence à partir de ce canal de contacteur, il doit gérer les gestes verticaux et ne rien faire avec les gestes horizontaux:

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

Le résultat souhaité est un geste horizontal qui ne fait pas non plus un panoramique ni un défilement de la page. Un pointeur peut commencer à défiler verticalement à partir de la saisie et faire défiler la page, mais les pointeurs horizontaux sont gérés de manière personnalisée.

Utilitaires de style de valeur de pixel

Lors de la configuration et du glissement, diverses valeurs numériques calculées doivent être extraites des éléments. Les fonctions JavaScript suivantes renvoient des valeurs de pixel calculées à partir d'une propriété CSS. Il est utilisé dans le script de configuration comme suit : 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. Il est plutôt pratique que JavaScript puisse lire autant de valeurs à partir d'éléments, même de pseudo-éléments.

dragging

Il s'agit d'un moment clé pour la logique de glisser-déposer. Voici quelques points à noter concernant le gestionnaire d'événements de la fonction:

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, le petit cercle que ce script positionne avec un pointeur. L'objet switches est un Map() dont les clés sont des .gui-switch et les valeurs sont des limites et des tailles mises en cache qui maintiennent l'efficacité du script. Le mode de lecture de droite à gauche est géré à l'aide de la même propriété personnalisée que le CSS, à savoir --isLTR. Il peut l'utiliser pour inverser la logique et continuer à prendre en charge le mode de lecture de droite à gauche. 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 de curseur. Cette attribution de valeur passerait à l'heure au fil du temps, mais un événement de pointeur précédent a temporairement défini --thumb-transition-duration sur 0s, ce qui a supprimé ce qui aurait été une interaction lente.

dragEnd

Pour que l'utilisateur soit autorisé à faire glisser le bouton loin du bouton bascule et à le relâcher, un événement de fenêtre global doit être enregistré:

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

  dragEnd(event)
})

Je pense qu'il est très important qu'un utilisateur puisse faire glisser des éléments librement et que l'interface soit suffisamment intelligente pour en tenir compte. Il n'a pas fallu beaucoup de temps pour gérer ce changement, mais il a nécessité une réflexion approfondie pendant le 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é checked de l'entrée et de supprimer tous les événements de geste. La case à cocher est modifiée avec state.activethumb.checked = determineChecked().

determineChecked()

Cette fonction, appelée par dragEnd, détermine où se trouve le curseur dans les limites de son rail et renvoie la valeur "true" s'il est égal ou supérieur à la moitié du rail:

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
}

Autres réflexions

Le geste de glisser a entraîné une dette de code en raison de la structure HTML initiale choisie, en particulier en encapsulant la saisie dans un libellé. Étant un élément parent, le libellé recevra les interactions de clic après la saisie. À 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é qui reçoit ce clic ultérieur, car il désélectionnera ou sélectionnera l'interaction effectuée par l'utilisateur.

Si je devais recommencer, j'envisagerais peut-être 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 qui ne s'oppose pas au comportement intégré.

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

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

Conclusion

Ce petit composant de bouton a nécessité le plus de travail de tous les défis d'interface utilisateur à ce jour. Maintenant que vous savez comment j'ai fait, comment procéderiez-vous ? 🙂

Diversifions nos approches et découvrons toutes les façons de créer sur le Web. Créez une démo, tweetez-moi des liens et je les ajouterai à la section "Remix de la communauté" ci-dessous.

Remix de la communauté

Ressources

Vous trouverez le code source .gui-switch sur GitHub.