Créer un composant switch

Découvrez les principes de base de la création d'un composant switch réactif et accessible.

Dans ce post, je vais vous expliquer comment créer des composants de contacteur. Tester la fonctionnalité

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> Démonstration

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

Présentation

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

Cette démonstration utilise <input type="checkbox" role="switch"> pour la majorité de ses qui présente l'avantage de ne pas avoir besoin de CSS ou de JavaScript entièrement fonctionnelle et accessible. Chargement de CSS compatible avec l'écriture de droite à gauche de langues, de verticalité, d'animation, etc. Passer au chargement en JavaScript déplaçables et tangibles.

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 les propriétés personnalisées utilisées. tout au long des éléments enfants des composants, et des points d'entrée pour une gestion la personnalisation.

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 surlignage pour les 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, un utilisateur de préférence de mouvement réduite la requête média peut être insérée dans une propriété personnalisée avec la fonction PostCSS du plug-in à partir de ce brouillon spécification dans les requêtes média 5:

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

Majoration

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

A
libellé et case à cocher naturels.

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

<input type="checkbox"> est fourni avec un API et state. La le navigateur gère checked une propriété et une entrée événements tels que oninput et onchanged.

Mises en page

Flexbox grid et custom propriétés sont essentielles en conservant les styles de ce composant. Ils centralisent les valeurs, donnent des noms des calculs ou des zones autrement ambigus, et permettre une petite propriété personnalisée pour personnaliser facilement les composants.

.gui-switch

La mise en page de premier niveau du 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 commutateur horizontal, montrant leur mise en page
de l&#39;espace.

.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 semblables aux modifications de 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é vertical et un commutateur.

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

Suivre

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

Outils pour les développeurs Grid superposés à la piste switch, montrant la piste de la grille nommée
zones 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 grille de une par une pour que le pouce la revendication.

Thumb

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

Le pouce est un pseudo-élément enfant associé à input[type="checkbox"]. s'empile en haut de la piste plutôt qu'en dessous en revendiquant la zone de la grille track:

Outils de développement affichant le curseur du pseudo-élément tel qu&#39;il est 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 offrent un composant switch polyvalent qui s'adapte à la couleur les langues qui se lisent de droite à gauche et les préférences de mouvement.

Comparatif des thèmes clair et sombre du bouton bascule et de ses
différents états.

Styles d'interaction tactile

Sur mobile, les navigateurs ajoutent des fonctionnalités de mise en surbrillance d'appui et de sélection de texte aux libellés et d'entrée. Ceux-ci ont eu un impact négatif sur le style et le retour d'interaction visuelle qui ce commutateur était nécessaire. Avec quelques lignes de CSS, je peux supprimer ces effets et en 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 utiles et d'interaction. Veillez à proposer des alternatives personnalisées si vous les supprimez.

Suivre

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

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

Un large éventail d'options de personnalisation du type de contacteur proviennent de quatre propriétés personnalisées. border: none a été ajouté, car appearance: none ne le fait pas supprimez les bordures de la case à cocher dans tous les navigateurs.

Thumb

L'élément "Miniature" se trouve déjà à droite de l'élément track, mais il a besoin d'un style de cercle:

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

Affichage des outils de développement avec le pseudo-élément représentant un pouce circulaire

Interaction

Utiliser des propriétés personnalisées pour préparer les interactions qui entraîneront un pointage et les changements de position du curseur. La préférence de l'utilisateur est également vérifiées avant de faire passer de mise en surbrillance par mouvement ou par 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 pouce dans la piste. Nous disposons de la taille des pistes et des pouces que nous utiliserons pour pour que le pouce soit bien décalé sur la piste et entre les différentes zones: 0% et 100%.

L'élément input possède la variable de position --thumb-position et le curseur. pseudo-élément l'utilise comme position translateX:

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

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

Nous sommes maintenant libres de modifier --thumb-position dans le CSS et les 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 découplée avait bien fonctionné. L'élément pouce est ne concerne qu'un seul style, la position translateX. L'entrée peut gérer toutes 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 CSS se transforme en élément input.

En revanche, la rotation d'un élément en 3D ne modifie pas la hauteur globale du composant. ce qui peut perturber la mise en page en bloc. Prenez en compte ces informations à l'aide des --track-size et Variables --track-padding. Calculer l'espace minimal nécessaire pour un bouton vertical pour s'insérer comme prévu 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 (RTL)

Elad Schecter, ami CSS, avec qui j'ai prototypé et un menu latéral coulissant à l'aide de transformations CSS qui gèrent l'écriture de droite à gauche langues en inversant une seule . Nous avons procédé ainsi, car il n'y a pas de transformation de propriété logique en CSS, et il n'y en aura peut-être jamais. Elad a eu l'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 nos propres pour les transformations logiques. J'ai utilisé cette même technique pour ce commutateur et j'ai que cela s'est très bien déroulé:

.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 la mise en page s'affiche de gauche à droite par défaut. Ensuite, à l'aide du code CSS, pseudo-classe :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() à l'intérieur d'une transformation:

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

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

Les transformations translateX du pseudo-élément Thumb doivent également être mises à jour vers tenir compte de l'exigence de la partie 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 réponde pas à tous les besoins liés à un concept comme le CSS logique il offre une certaine principes DRY pour de nombreux différents cas d'utilisation.

États

L'utilisation de l'input[type="checkbox"] intégré ne serait pas complète sans en gérant les différents états possibles: :checked, :disabled, :indeterminate et :hover. :focus a été délibérément laissé seul, avec un l'ajustement effectué uniquement sur son décalage ; l'anneau de focus était parfait dans Firefox et Safari:

Capture d&#39;écran de l&#39;anneau de focus avec un bouton bascule 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'entrée "suivre" l'arrière-plan est défini sur la couleur active et la position du pouce est définie sur 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 a une apparence différente, mais doit également rendre L'immuabilité de l'interaction est exempte du navigateur, mais Les états visuels nécessitent des 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 &quot;Sombre&quot; désactivé, coché et décoché
différents états.

Cet état est délicat, car il nécessite des thèmes sombre et clair, avec des fonctionnalités cochés. J'ai choisi des styles minimalistes pour faciliter l'utilisation de ces états la charge de maintenance des combinaisons de styles.

Indéterminé

:indeterminate est un état souvent oublié, où une case à cocher n'est ni cochée ou décochée. Cet état est amusant, accueillant et sobre. Un bon rappelez-vous que les états booléens peuvent avoir un caractère trompeur entre les états.

Il est délicat de définir une case à cocher sur "indéterminé", seul JavaScript peut le 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 la piste
au milieu, pour indiquer 
un choix indécis.

Puisque l'État, selon moi, est simple et accueillant, il m'a semblé approprié de mettre position du curseur 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 passage de la souris doivent offrir une assistance visuelle pour l'interface utilisateur connectée, mais aussi pour orienter l'interface utilisateur interactive. Ce bouton bascule met en évidence le pouce avec un anneau semi-transparent lorsque l'utilisateur pointe sur le libellé ou l'entrée. Ce survol puis oriente le curseur vers l'élément interactif "pique".

Le "point fort" l'effet est effectué avec box-shadow. Lorsque l'utilisateur pointe sur une entrée non désactivée, augmentez la taille de --highlight-size. Si l'utilisateur est d'accord avec le mouvement, la box-shadow change d'apparence et s'affiche. S'il ne convient pas au mouvement, 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 un une interface utilisateur, en particulier celle-ci avec un cercle à l'intérieur d'une piste. iOS a trouvé la bonne réponse avec leur bouton bascule, vous pouvez les faire glisser d'un côté à l'autre, et c'est très satisfaisant de qui ont le choix. À l'inverse, un élément d'interface utilisateur peut sembler inactif si un geste de glissement est et que rien ne se passe.

Pouce déplaçable

Le pseudo-élément "Thumb" reçoit sa position à partir de .gui-switch > input définie sur var(--thumb-position), JavaScript peut fournir une valeur de style intégré sur l'entrée pour mettre à jour de façon dynamique la position du pouce, de sorte qu'elle semble suivre 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 de la position réelle ou de la désactivation à l'aide de la propriété personnalisée --thumb-position Il s’agit de l’épine dorsale de la solution ; événements de pointeur suivre la position du pointeur de manière conditionnelle afin de modifier les propriétés personnalisées du CSS.

Étant donné que le composant était déjà fonctionnel à 100% avant l'affichage de ce script il faut beaucoup de travail pour maintenir le comportement existant, en cliquant sur une étiquette pour activer/désactiver l'entrée. Notre JavaScript ne doit pas ajouter de fonctionnalités au niveau 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 Avantages touch-action. Dans le cas de ce commutateur, un geste horizontal doit être gérée par notre script, ou un geste vertical capturé pour le bouton bascule vertical variante d'origine. Avec touch-action, nous pouvons indiquer au navigateur les gestes à gérer afin qu'un script puisse gérer un geste sans concurrence.

Le code CSS suivant indique au navigateur que lorsqu'un geste du pointeur commence dans ce circuit de commutation, gérer les gestes verticaux, ne rien faire avec uns:

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

Le résultat souhaité est un geste horizontal qui ne fait pas défiler . Un pointeur peut faire défiler verticalement l'élément à partir de l'entrée et faire défiler les mais les horizontaux sont traités de manière personnalisée.

Utilitaires du style de valeur Pixel

Lors de la configuration et du déplacement, diverses valeurs numériques calculées devront être récupérées. des éléments. Les fonctions JavaScript suivantes renvoient des valeurs en pixels calculées en fonction d'une propriété CSS. Il est utilisé dans le script de configuration comme ceci : 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 intéressant, JavaScript peut lire autant de valeurs à partir d'éléments, même de pseudo-éléments.

dragging

C'est un moment central de la logique du drag, et il y a quelques points à noter à partir du 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 dont est issu ce script avec un pointeur. L'objet switches est un Map() où le les clés sont des .gui-switch, et les valeurs sont des limites et des tailles mises en cache qui conservent l'efficacité du script. L'orientation de droite à gauche est gérée à l'aide de la même propriété personnalisée. que le CSS est --isLTR, et qu'il peut l'utiliser pour inverser la logique et continuer compatible avec les langues RTL. event.offsetX est également intéressant, car il contient un delta. utile pour positionner le pouce.

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

Cette dernière ligne du CSS définit la propriété personnalisée utilisée par l'élément Thumb. Ce l'attribution de valeur changerait au fil du temps, mais un pointeur précédent l'événement a défini temporairement --thumb-transition-duration sur 0s, ce qui a entraîné la suppression de l'élément aurait été une interaction faible.

dragEnd

Pour que l'utilisateur puisse faire glisser un événement de la fenêtre globale nécessaire 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 glisser librement et d'avoir être suffisamment intelligente pour en tenir compte. Il n'a pas fallu beaucoup de temps pour gérer cette situation avec ce changement, mais il a dû être mûrement réfléchi pendant le développement processus.

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 l'entrée coché et 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 pouce. dans les limites de sa piste et renvoie "true" si la valeur est égale ou supérieure à à mi-chemin:

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 entraînait une certaine quantité de code en raison de la structure HTML initiale. en particulier l'encapsulation de l'entrée dans une étiquette. Le libellé, en tant que parent , recevrait des interactions de clic après l'entrée. À la fin de dragEnd, vous avez peut-être remarqué que le son de padRelease() était bizarre .

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

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

Cela permet de tenir compte du libellé qui entraîne le clic ultérieur, comme il le ferait décocher, ou vérifier, l'interaction qu'un utilisateur a effectuée.

Si je devais recommencer, j'envisage peut-être d'ajuster DOM avec JavaScript pendant la mise à niveau de l'expérience utilisateur, par exemple pour créer un élément qui gère lui-même les clics sur les libellés et ne lutte pas contre le comportement intégré.

C'est ce type de code JavaScript que j'aime le moins, ébullition d'événements conditionnels:

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

Conclusion

Ce petit bouton bascule a fini par être le plus difficile de tous les défis de l'IUG pour l'instant ! Maintenant que vous savez comment j'ai fait, comment feriez-vous ? 😃

Diversifiez nos approches et découvrons toutes les manières de créer des applications sur le Web. Créer une démonstration, me envoyer des tweets et je l'ajouterai à la section des remix de la communauté ci-dessous.

Remix de la communauté

Ressources

Recherchez le code source .gui-switch sur GitHub.