Creazione di un componente Switch

Una panoramica di base su come creare un componente Switch reattivo e accessibile.

In questo post voglio mostrarti come creare componenti per i sensori. Prova la demo.

Demo

Se preferisci i video, ecco una versione di YouTube di questo post:

Panoramica

Un interruttore funziona in modo simile a una casella di controllo, ma rappresenta in modo esplicito gli stati di attivazione e disattivazione booleani.

Questa demo utilizza <input type="checkbox" role="switch"> per la maggior parte delle funzionalità, il che ha il vantaggio di non aver bisogno di CSS o JavaScript per essere completamente funzionali e accessibili. Il caricamento di CSS supporta lingue da destra a sinistra, verticalità, animazione e altro ancora. Il caricamento di JavaScript rende il passaggio trascinabile e tangibile.

Proprietà personalizzate

Le seguenti variabili rappresentano le varie parti dello switch e le relative opzioni. Essendo la classe di primo livello, .gui-switch contiene proprietà personalizzate utilizzate nei componenti secondari, nonché punti di ingresso per la personalizzazione centralizzata.

Monitoraggio

Lunghezza (--track-size), spaziatura interna e due colori:

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

Pizzico

Dimensioni, colore dello sfondo e colori di evidenziazione dell'interazione:

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

Movimento ridotto

Per aggiungere un alias chiaro e ridurre la ripetizione, è possibile inserire una query multimediale utente con preferenza di movimento ridotta in una proprietà personalizzata con il plug-in PostCSS in base a questa bozza di specifica in Media Queries 5:

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

Markup

Ho scelto di aggregare il mio elemento <input type="checkbox" role="switch"> con un <label>, raggruppando la relazione per evitare ambiguità di associazione di caselle di controllo ed etichette, dando all'utente la possibilità di interagire con l'etichetta per attivare/disattivare l'input.

Un&#39;etichetta naturale e senza stile con una casella di controllo.

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

<input type="checkbox"> include un'API e uno state predefiniti. Il browser gestisce la proprietà checked e gli eventi di input come oninpute onchanged.

Layout

Le proprietà Flexbox, grid e personalizzate sono fondamentali per mantenere gli stili di questo componente. Centralizzano i valori, assegnano nomi a calcoli o aree ambigui e consentono una piccola API di proprietà personalizzata per semplificare la personalizzazione dei componenti.

.gui-switch

Il layout di primo livello per l'interruttore è Flexbox. La classe .gui-switch contiene le proprietà personalizzate pubbliche e private che i bambini usano per calcolare i loro layout.

Flexbox DevTools con un&#39;etichetta orizzontale e un sensore che mostra la distribuzione dello spazio del layout.

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

L'estensione e la modifica del layout Flexbox è come modificare qualsiasi layout Flexbox. Ad esempio, per applicare le etichette sopra o sotto un sensore o per modificare flex-direction:

Flexbox DevTools con un&#39;etichetta verticale e un&#39;opzione.

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

Monitoraggio

All'input della casella di controllo viene assegnato lo stile di una traccia switch rimuovendo il suo valore appearance: checkbox normale e fornendo le proprie dimensioni:

Griglia DevTools che si sovrappone alla traccia di passaggio e mostra le aree della traccia della griglia denominata
&quot;traccia&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 traccia crea anche un'area della traccia della griglia a cella singola e utilizzabile con un pollice.

Pizzico

Lo stile appearance: none rimuove anche il segno di spunta visivo fornito dal browser. Questo componente utilizza uno pseudo-elemento e la pseudo-classe :checked nell'input per sostituire l'indicatore visivo.

Il pollice è uno pseudo-elemento secondario collegato a input[type="checkbox"] e si sovrappone alla traccia anziché sotto rivendicando l'area della griglia track:

DevTools che mostra la miniatura dello pseudo elemento posizionato all&#39;interno di una griglia CSS.

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

Stili

Le proprietà personalizzate consentono un componente per il passaggio versatile, che si adatta a schemi cromatici, lingue da destra a sinistra e preferenze di movimento.

Un confronto affiancato del tema chiaro e scuro dell&#39;interruttore e dei suoi stati.

Stili di interazione touch

Sui dispositivi mobili, i browser aggiungono evidenziazioni al tocco e funzionalità di selezione del testo a etichette e input. Ciò ha influito negativamente sullo stile e sulle interazioni visive necessarie a questo passaggio. Con poche righe di CSS posso rimuovere questi effetti e aggiungere il mio stile cursor: pointer:

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

Non è sempre consigliabile rimuovere questi stili, in quanto possono rappresentare un prezioso feedback sulle interazioni visive. Assicurati di fornire alternative personalizzate se le rimuovi.

Monitoraggio

Gli stili di questo elemento riguardano principalmente la forma e il colore, a cui si accede dall'elemento .gui-switch principale tramite la cascade.

Cambia le varianti con dimensioni e colori dei canali personalizzati.

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

Le quattro proprietà personalizzate disponibili sono numerose. border: none è stato aggiunto perché appearance: none non rimuove i bordi dalla casella di controllo in tutti i browser.

Pizzico

L'elemento pollice si trova già sul track a destra, ma richiede stili del cerchio:

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

DevTools visualizzato che evidenzia lo pseudo elemento con il pollice a cerchio.

Interazione

Utilizza le proprietà personalizzate per prepararti alle interazioni, che mostreranno aree evidenziate al passaggio del mouse e modifiche alla posizione del pollice. Viene selezionata anche la preferenza dell'utente prima di eseguire la transizione degli stili di animazione o di evidenziazione del passaggio del mouse.

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

Posizione pollice

Le proprietà personalizzate forniscono un singolo meccanismo di origine per il posizionamento del pollice nella traccia. Abbiamo a disposizione le dimensioni della traccia e del pollice che utilizzeremo nei calcoli per mantenere il corretto offset del pollice e all'interno della traccia: 0% e 100%.

L'elemento input possiede la variabile di posizione --thumb-position, mentre lo pseudo elemento thumb la utilizza come posizione translateX:

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

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

Ora possiamo modificare --thumb-position da CSS e dalle pseudo-classi fornite negli elementi delle caselle di controllo. Poiché abbiamo impostato in precedenza transition: transform var(--thumb-transition-duration) ease in modo condizionale su questo elemento, queste modifiche potrebbero animarsi quando vengono modificate:

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

Pensavo che questa orchestrazione disaccoppiata andasse bene. L'elemento "thumb" riguarda un solo stile, ovvero una posizione translateX. L'input può gestire tutta la complessità e i calcoli.

Verticale

È stata supportata una classe di modifica -vertical che aggiunge una rotazione con trasformazioni CSS all'elemento input.

Un elemento ruotato 3D non modifica però l'altezza complessiva del componente, il che può compromettere il layout a blocchi. Tieni conto di ciò utilizzando le variabili --track-size e --track-padding. Calcola la quantità minima di spazio necessaria affinché un pulsante verticale sia presente nel layout come previsto:

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

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

(RTL) da destra a sinistra

Io e Elad Schecter abbiamo prototipato insieme un menu laterale a scorrimento utilizzando le trasformazioni CSS che gestivano le lingue da destra a sinistra capovolgendo una singola variabile. L'abbiamo fatto perché non ci sono trasformazioni logiche delle proprietà in CSS e potrebbe non esserlo mai. Elad aveva l'idea di utilizzare un valore di proprietà personalizzato per invertire le percentuali, per consentire la gestione di singole località della nostra logica personalizzata per le trasformazioni logiche. Ho usato la stessa tecnica per il passaggio e penso che abbia funzionato alla grande:

.gui-switch {
  --isLTR: 1;

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

Una proprietà personalizzata denominata --isLTR inizialmente contiene il valore 1, ovvero true poiché il nostro layout è da sinistra a destra per impostazione predefinita. Quindi, utilizzando la pseudo classe CSS :dir(), il valore viene impostato su -1 quando il componente si trova in un layout da destra a sinistra.

Metti --isLTR in azione utilizzandolo all'interno di un calc() all'interno di una trasformazione:

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

Ora la rotazione del sensore verticale tiene conto della posizione del lato opposto richiesto dal layout da destra a sinistra.

Anche le trasformazioni translateX nello pseudoelemento thumb devono essere aggiornate per tenere conto del requisito del lato opposto:

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

Sebbene questo approccio non sia in grado di soddisfare tutte le esigenze relative a un concetto come la trasformazione logica del codice CSS, offre alcuni principi DRY per molti casi d'uso.

Stati

L'utilizzo dell'elemento input[type="checkbox"] integrato non sarebbe completo senza gestire i vari stati in cui può trovarsi: :checked, :disabled, :indeterminate e :hover. :focus è stato lasciato intenzionalmente lasciato a se stesso, modificando solo l'offset; l'anello di messa a fuoco era perfetto su Firefox e Safari:

Uno screenshot dell&#39;anello di messa a fuoco su un&#39;opzione in Firefox e Safari.

Selezionato

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

Questo stato rappresenta lo stato on. In questo stato, lo sfondo della "traccia" dell'input è impostato sul colore attivo e la posizione del pollice su "fine".

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

Disabilitato

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

Un pulsante :disabled non solo ha un aspetto diverso, ma dovrebbe anche rendere immutabile l'elemento.L'immutabilità dell'interazione è priva del browser, ma gli stati visivi richiedono stili a causa dell'uso di 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%);
    }}
  }
}

L&#39;opzione con stile scuro è disattivata, selezionata e deselezionata.

Questo stato è complicato perché richiede temi chiari e scuri con entrambi gli stati disattivati e selezionati. Ho scelto stilisticamente stili minimi per questi stati, così da alleggerire il carico di manutenzione delle combinazioni di stili.

Indeterminato

Uno stato spesso dimenticato è :indeterminate, in cui una casella di controllo non è né selezionata né deselezionata. Questo è uno stato divertente, piacevole e sobrio. Un buon promemoria per ricordare che gli stati booleani possono essere poco visibili da uno stato all'altro.

È difficile impostare una casella di controllo su indeterminata, solo JavaScript può impostarla:

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

Lo stato indeterminato con il pollice della traccia al centro, a indicare un&#39;indecisione.

Poiché lo stato per me è sobrio e invitante, mi sembrava opportuno posizionare l'interruttore con la posizione centrale al centro:

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

Passa il mouse sopra

Le interazioni con il passaggio del mouse devono fornire un supporto visivo per l'UI connessa e anche fornire indicazioni verso l'UI interattiva. Questo pulsante evidenzia il pollice con un anello semitrasparente quando passi il mouse sull'etichetta o sull'input. Questa animazione al passaggio del mouse fornisce la direzione verso l'elemento interattivo del pollice.

L'effetto "Evidenzia" viene eseguito con box-shadow. Al passaggio del mouse su un input non disattivato, aumenta la dimensione di --highlight-size. Se l'utente è d'accordo con il movimento, effettuiamo la transizione di box-shadow e lo vediamo crescere; se non è d'accordo con il movimento, l'evidenziazione appare immediatamente:

.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

Per me, il tentativo di emulare un'interfaccia fisica è inconsueto, soprattutto se si tratta di un cerchio all'interno di una traccia. iOS ha funzionato correttamente con il suo switch, puoi trascinarli da un lato all'altro ed è molto gratificante avere questa opzione. Al contrario, un elemento UI può risultare inattivo se viene tentato un gesto di trascinamento e non succede nulla.

Mi piace trascinabili

Lo pseudo-elemento "thumb" riceve la sua posizione dall'elemento var(--thumb-position) con ambito .gui-switch > input. JavaScript può fornire un valore di stile incorporato nell'input per aggiornare in modo dinamico la posizione del pollice, facendo sembrare che segua il gesto del puntatore. Quando il puntatore viene rilasciato, rimuovi gli stili incorporati e determina se il trascinamento era più vicino a quello disattivato o attivo utilizzando la proprietà personalizzata --thumb-position. Questa è la struttura portante della soluzione: gli eventi puntatore monitorano in modo condizionale le posizioni dei puntatore per modificare le proprietà personalizzate del CSS.

Poiché il componente era già funzionante al 100% prima della visualizzazione di questo script, è necessario un po' di lavoro per mantenere il comportamento esistente, ad esempio fare clic su un'etichetta per attivare/disattivare l'input. Il nostro JavaScript non dovrebbe aggiungere funzionalità a scapito di quelle esistenti.

touch-action

Trascinare è un gesto personalizzato, quindi è perfetto per i vantaggi di touch-action. In questo caso, il nostro script deve gestire un gesto orizzontale o un gesto verticale per la variante del sensore verticale. Con touch-action possiamo indicare al browser quali gesti gestire su questo elemento, in modo che uno script possa gestire un gesto senza concorrenza.

Il codice CSS seguente indica al browser che quando un gesto del puntatore parte dall'interno di questa traccia di cambio, vengono gestiti i gesti verticali e non gli eventuali gesti orizzontali.

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

Il risultato desiderato è un gesto orizzontale che non consente anche di eseguire la panoramica o lo scorrimento della pagina. Un puntatore può far scorrere l'inizio e la pagina verticalmente dall'input, mentre quelli orizzontali sono gestiti in modo personalizzato.

Utilità stile Pixel

Durante la configurazione e il trascinamento, sarà necessario estrarre vari valori numerici calcolati dagli elementi. Le seguenti funzioni JavaScript restituiscono valori dei pixel calcolati in base a una proprietà CSS. Viene utilizzato nello script di configurazione come questo: 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,
}

Osserva come window.getComputedStyle() accetta un secondo argomento, uno pseudo elemento di destinazione. È pratico perché JavaScript possa leggere tanti valori dagli elementi, anche da pseudo elementi.

dragging

Questo è un momento fondamentale per la logica di trascinamento e occorre notare alcuni elementi dal gestore di eventi della funzione:

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

L'elemento hero dello script è state.activethumb, il cerchio che è posizionato insieme a un puntatore. L'oggetto switches è un oggetto Map() in cui le chiavi sono di .gui-switch e i valori sono limiti e dimensioni memorizzati nella cache che mantengono efficiente lo script. La scrittura da destra a sinistra viene gestita utilizzando la stessa proprietà personalizzata del CSS --isLTR e può utilizzarla per invertire la logica e continuare a supportare RTL. Anche event.offsetX è importante, poiché contiene un valore delta utile per il posizionamento del pollice.

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

Questa ultima riga di CSS imposta la proprietà personalizzata utilizzata dall'elemento thumb. Questa assegnazione del valore subirebbe una transizione nel tempo, ma un evento puntatore precedente ha impostato temporaneamente --thumb-transition-duration su 0s, rimuovendo quella che sarebbe stata un'interazione lenta.

dragEnd

Per consentire all'utente di trascinare all'esterno dell'opzione e rilasciare, è stato necessario registrare un evento di finestra globale:

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

  dragEnd(event)
})

Penso che sia molto importante che un utente abbia la libertà di trascinarsi senza stringere e che l'interfaccia sia abbastanza intelligente da tenerne conto. Non ci è voluto molto per gestirlo con questo passaggio, ma è stato necessario un'attenta considerazione durante il processo di sviluppo.

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'interazione con l'elemento è stata completata. È ora di impostare la proprietà di input verificata e rimuovere tutti gli eventi dei gesti. La casella di controllo è stata modificata con state.activethumb.checked = determineChecked().

determineChecked()

Questa funzione, chiamata da dragEnd, determina dove la corrente di apertura si trova entro i limiti della traccia e restituisce true se è uguale o superiore a metà della traccia:

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
}

Riflessioni extra

Il gesto di trascinamento ha generato un debito di codice a causa della struttura HTML iniziale scelta, in particolare il wrapping dell'input in un'etichetta. Essendo un elemento padre, l'etichetta riceve interazioni con clic dopo l'input. Alla fine dell'evento dragEnd, potresti aver notato padRelease() come una funzione dal suono strano.

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

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

Questo tiene conto dell'etichetta che riceve questo clic successivo, in quanto deseleziona o verifica l'interazione eseguita da un utente.

Se dovessi farlo di nuovo, potrei prendere in considerazione di modificare il DOM con JavaScript durante l'upgrade dell'UX, in modo da creare un elemento che gestisca i clic sulle etichette e non contrasti con il comportamento integrato.

Questo tipo di JavaScript è l'approccio che preferisco scrivere. Non voglio gestire il bubbling degli eventi condizionale:

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

Conclusione

Finora, questo piccolo componente di switch è stato il lavoro più impegnativo di tutte le sfide GUI. Ora che sai come ci sono riuscito, come faresti? 🙂

Diversifica i nostri approcci e scopriamo tutti i modi per creare sul web. Crea una demo, inviami un tweet con i link e lo aggiungerò alla sezione Remix della community di seguito.

Remix della community

Risorse

Trova il codice sorgente di .gui-switch su GitHub.