Découvrez les principes de base de la création d'un composant switch réactif 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é
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 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, 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, en fonction de 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 ou désactiver la saisie.
<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.
.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
:
<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 :
.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
:
.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.
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 le retour d'interaction visuelle 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 fournir des commentaires visuels sur l'interaction. Veillez à proposer 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.
.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%;
}
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
possède la variable de position --thumb-position
, et le pseudo-élément de pouce 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
à 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 "Vignette" ne concerne qu'un seul style, à savoir la position translateX
. L'entrée peut gérer toute la
complexité et les calculs.
Vertical
Cette opération 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 l'espace minimal requis pour qu'un bouton vertical s'insère 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);
}
}
(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 l'avons fait, car il n'y a pas de transformation de propriété logique 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()
à l'intérieur d'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
du pseudo-élément de pouce doivent également être mises à jour pour tenir compte de l'exigence du côté opposé:
.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é volontairement laissé tel quel, avec un ajustement uniquement apporté à son décalage. L'anneau de focus était particulièrement élégant sur 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
est différent visuellement, mais doit également rendre l'élément immuable.L'immuabilité de l'interaction est sans frais 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%);
}}
}
}
Cet état est délicat, car il nécessite des thèmes sombre et clair, avec des états désactivés et cochés. J'ai choisi des styles minimaux pour ces états afin de faciliter la maintenance des combinaisons de styles.
Indéterminé
:indeterminate
est un état souvent oublié, où une case à cocher n'est ni cochée, ni décochée. 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>
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 bascule met en surbrillance le curseur avec un anneau semi-transparent lorsque l'utilisateur pointe sur le libellé ou l'entrée. Cette animation de survol indique ensuite l'élément de curseur interactif.
L'effet de mise en surbrillance est effectué 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 "Thumb" reçoit sa position à partir du var(--thumb-position)
limité à .gui-switch > input
. JavaScript peut fournir une valeur de style intégré à l'entrée pour mettre à jour de manière dynamique la position du pouce, de sorte qu'il semble suivre 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 la position du pointeur afin de 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 déplacement est un geste personnalisé, ce qui en fait un excellent candidat pour bénéficier des avantages de 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 code CSS suivant indique au navigateur que lorsqu'un geste du pointeur commence à partir de cette piste de commutateur, 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 pixels calculées en fonction 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. Sinon, cette attribution de valeur serait transmise au fil du temps, mais un événement de pointeur précédent a temporairement défini --thumb-transition-duration
sur 0s
, supprimant 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 librement des éléments 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é 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 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
}
Pensées supplémentaires
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électionnerait ou sélectionnerait l'interaction effectuée par l'utilisateur.
Si je devais répéter cette étape, 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 n'entre pas en conflit avec le 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 feriez-vous ? 😃
Diversifiez nos approches et découvrons toutes les manières de créer des applications sur le Web. Créez une démo, envoyez-moi des tweets via des liens et je l'ajouterai à la section "Remix de la communauté" ci-dessous.
Remix de la communauté
- @KonstantinRouda avec un élément personnalisé : démo et code.
- @jhvanderschee avec un bouton: Codepen.
Ressources
Vous trouverez le code source .gui-switch
sur GitHub.