Creazione di un componente Switch

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

In questo post voglio condividere il mio pensiero su un modo per creare componenti di commutazione. Prova la demo.

Demo

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

Panoramica

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

Questa demo utilizza <input type="checkbox" role="switch"> per la maggior parte delle sue funzionalità, il che ha il vantaggio di non richiedere CSS o JavaScript per essere completamente funzionale e accessibile. Il caricamento di CSS offre il supporto per le lingue da destra a sinistra, la verticalità, l'animazione e altro ancora. Il caricamento di JavaScript rende l'interruttore trascinabile e tangibile.

Proprietà personalizzate

Le seguenti variabili rappresentano le varie parti dell'interruttore e le relative opzioni. In qualità di classe di primo livello, .gui-switch contiene proprietà personalizzate utilizzate in tutti gli elementi secondari del componente e punti di ingresso per la personalizzazione centralizzata.

Traccia

La lunghezza (--track-size), il padding 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%);
  }
}

Miniature

Le dimensioni, il colore di sfondo e i 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 media query per gli utenti con preferenza per il movimento ridotto 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);

Segni e linee

Ho scelto di racchiudere l'elemento <input type="checkbox" role="switch"> in un <label>, raggruppando la loro relazione per evitare ambiguità nell'associazione tra casella di controllo ed etichetta, dando all'utente la possibilità di interagire con l'etichetta per attivare/disattivare l'input.

Un&#39;etichetta e una casella di controllo
naturali e senza stili.

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

<input type="checkbox"> viene fornito precompilato con un'API e uno stato. Il browser gestisce la proprietà checked e gli eventi di input come oninput e onchanged.

Layout

Flexbox, griglia e proprietà personalizzate sono fondamentali per mantenere gli stili di questo componente. Centralizzano i valori, assegnano nomi a calcoli o aree altrimenti ambigui e consentono una piccola API delle proprietà personalizzate per personalizzare facilmente i componenti.

.gui-switch

Il layout di primo livello per l'interruttore è flexbox. La classe .gui-switch contiene le proprietà personalizzate private e pubbliche utilizzate dai figli per calcolare i layout.

Overlay di Flexbox DevTools su un&#39;etichetta orizzontale e un interruttore, che mostra la distribuzione dello spazio del layout.

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

Estendere e modificare il layout Flexbox è come modificare qualsiasi layout Flexbox. Ad esempio, per posizionare le etichette sopra o sotto un interruttore o per modificare flex-direction:

Overlay di DevTools Flexbox che sovrappone un&#39;etichetta verticale e un interruttore.

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

Traccia

L'input della casella di controllo viene stilizzato come una traccia di interruttore rimuovendo il suo appearance: checkbox normale e fornendo invece le proprie dimensioni:

Overlay di Grid DevTools che si sovrappone alla traccia di commutazione, mostrando le aree denominate della traccia della griglia
con il nome &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 traccia crea anche unƏarea di traccia della griglia a una cella per una miniatura da rivendicare.

Miniature

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

Il pollice è un elemento secondario pseudo-elemento collegato a input[type="checkbox"] e si impila sopra la traccia anziché sotto, rivendicando l'area della griglia track:

DevTools che mostra la miniatura dello pseudo-elemento posizionata 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 di commutazione versatile che si adatta a schemi di colori, lingue da destra a sinistra e preferenze di movimento.

Un confronto fianco a fianco del tema chiaro e scuro per l&#39;interruttore e i relativi
stati.

Stili di interazione touch

Sui dispositivi mobili, i browser aggiungono funzionalità di evidenziazione al tocco e di selezione del testo alle etichette e ai campi di input. Questi elementi hanno influito negativamente sullo stile e sul feedback visivo di cui questo interruttore aveva bisogno. 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 fornire un feedback visivo prezioso. Assicurati di fornire alternative personalizzate se le rimuovi.

Traccia

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

Le varianti degli interruttori con dimensioni e colori personalizzati della traccia.

.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'ampia gamma di opzioni di personalizzazione per la traccia dell'interruttore deriva da quattro proprietà personalizzate. border: none viene aggiunto perché appearance: none non rimuove i bordi dalla casella di controllo su tutti i browser.

Miniature

L'elemento pollice si trova già a destra track, ma necessita di stili circolari:

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

DevTools che mostra lo pseudo-elemento del cursore circolare evidenziato.

Interazione

Utilizza le proprietà personalizzate per prepararti alle interazioni che mostreranno i punti salienti al passaggio del mouse e le modifiche alla posizione del pollice. Prima di eseguire la transizione degli stili di evidenziazione del movimento o al passaggio del mouse, viene controllata anche la preferenza dell'utente.

.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 del pollice

Le proprietà personalizzate forniscono un meccanismo di origine singola per posizionare il cursore nella traccia. Abbiamo a disposizione le dimensioni della traccia e del pollice, che utilizzeremo nei calcoli per mantenere il pollice correttamente sfalsato e all'interno della traccia: 0% e 100%.

L'elemento input possiede la variabile di posizione --thumb-position e 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 liberamente --thumb-position da CSS e dalle pseudo-classi fornite sugli elementi della casella di controllo. Poiché in precedenza abbiamo impostato transition: transform var(--thumb-transition-duration) ease in modo condizionale su questo elemento, queste modifiche potrebbero essere animate 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)
  );
}

Ho pensato che questa orchestrazione disaccoppiata funzionasse bene. L'elemento pollice riguarda solo uno stile, una posizione translateX. L'input può gestire tutta la complessità e i calcoli.

Verticale

Il supporto è stato eseguito con una classe modificatore -vertical che aggiunge una rotazione con le trasformazioni CSS all'elemento input.

Un elemento ruotato in 3D non modifica l'altezza complessiva del componente, il che può compromettere il layout dei blocchi. Tieni conto di questo utilizzando le variabili --track-size e --track-padding. Calcola lo spazio minimo richiesto per un pulsante verticale per il flusso 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

Un amico CSS, Elad Schecter, e io abbiamo creato insieme un prototipo di menu laterale a scorrimento utilizzando le trasformazioni CSS che gestiscono le lingue da destra a sinistra invertendo una singola variabile. Abbiamo fatto questo perché non esistono trasformazioni di proprietà logiche in CSS e probabilmente non esisteranno mai. Elad ha avuto la brillante idea di utilizzare un valore della proprietà personalizzata per invertire le percentuali, per consentire la gestione di una singola posizione della nostra logica personalizzata per le trasformazioni logiche. Ho utilizzato la stessa tecnica in questo cambio e credo che abbia funzionato alla grande:

.gui-switch {
  --isLTR: 1;

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

Una proprietà personalizzata chiamata --isLTR inizialmente contiene il valore 1, il che significa che è true, dato che il nostro layout è da sinistra a destra per impostazione predefinita. Poi, 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 in pratica --isLTR utilizzandolo all'interno di un calc() in una trasformazione:

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

Ora la rotazione dell'interruttore verticale tiene conto della posizione del lato opposto richiesta dal layout da destra a sinistra.

Anche le trasformazioni translateX sullo pseudo-elemento 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 adatto a risolvere tutte le esigenze relative a un concetto come le trasformazioni CSS logiche, offre alcuni principi DRY per molti casi d'uso.

Stati

L'utilizzo di 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 invariato, con una modifica apportata solo al suo offset; l'anello di messa a fuoco aveva un ottimo aspetto su Firefox e Safari:

Uno screenshot dell&#39;anello di messa a fuoco focalizzato su un interruttore 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 cursore è impostata su "la 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 visivo diverso, ma deve anche rendere l'elemento immutabile.L'immutabilità dell'interazione è senza costi per il browser, ma gli stati visivi richiedono stili a causa dell'utilizzo 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;interruttore con stile scuro negli stati disattivato, selezionato e deselezionato.

Questo stato è complesso perché richiede temi scuri e chiari con stati disattivati e selezionati. Ho scelto stili minimali per questi stati per semplificare la manutenzione delle combinazioni di stili.

Indeterminato

Uno stato spesso dimenticato è :indeterminate, in cui una casella di controllo non è selezionata né deselezionata. Questo è uno stato divertente, invitante e modesto. Un buon promemoria che gli stati booleani possono avere stati intermedi subdoli.

È difficile impostare una casella di controllo su Indeterminato, solo JavaScript può farlo:

<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, in cui il cursore della traccia si trova
al centro, per indicare che non è stata presa una decisione.

Poiché lo stato, per me, è modesto e invitante, mi è sembrato opportuno posizionare il cursore dell'interruttore al centro:

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

Passaci il mouse sopra

Le interazioni al passaggio del mouse devono fornire supporto visivo per l'interfaccia utente connessa e anche indicazioni per l'interfaccia utente interattiva. Questo interruttore evidenzia il pollice con un anello semitrasparente quando il cursore passa sopra l'etichetta o l'input. Questa animazione al passaggio del mouse fornisce poi indicazioni sull'elemento interattivo del pollice.

L'effetto "Evidenzia" viene realizzato con box-shadow. Al passaggio del mouse su un input non disattivato, aumenta le dimensioni di --highlight-size. Se l'utente non ha problemi con il movimento, la box-shadow viene visualizzata e cresce. Se invece non vuole che ci siano movimenti, l'evidenziazione viene visualizzata 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, un'interfaccia di commutazione può sembrare inquietante nel suo tentativo di emulare un'interfaccia fisica, soprattutto questo tipo con un cerchio all'interno di una traccia. iOS ha fatto bene con il suo interruttore, puoi trascinarlo da un lato all'altro ed è molto soddisfacente avere questa opzione. Al contrario, un elemento dell'interfaccia utente può sembrare inattivo se viene tentato un gesto di trascinamento e non succede nulla.

Miniature trascinabili

Lo pseudo-elemento pollice riceve la sua posizione dall'elemento .gui-switch > input con ambito var(--thumb-position). JavaScript può fornire un valore di stile in linea all'input per aggiornare dinamicamente la posizione del pollice, in modo che sembri seguire il gesto del puntatore. Quando il puntatore viene rilasciato, rimuovi gli stili in linea e determina se il trascinamento è più vicino a off o on utilizzando la proprietà personalizzata --thumb-position. Si tratta della spina dorsale della soluzione: gli eventi puntatore tracciano in modo condizionale le posizioni del puntatore per modificare le proprietà personalizzate CSS.

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

touch-action

Il trascinamento è un gesto personalizzato, il che lo rende un ottimo candidato per i vantaggi di touch-action. Nel caso di questo interruttore, un gesto orizzontale deve essere gestito dal nostro script, mentre un gesto verticale deve essere acquisito per la variante dell'interruttore 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 seguente CSS indica al browser che quando un gesto del puntatore inizia da all'interno di questa traccia di commutazione, gestisce i gesti verticali e non fa nulla con quelli orizzontali:

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

Il risultato desiderato è un gesto orizzontale che non sposta o scorre la pagina. Un puntatore può scorrere verticalmente a partire dall'interno dell'input e scorrere la pagina, ma quelli orizzontali vengono gestiti in modo personalizzato.

Utilità di stile del valore pixel

Durante la configurazione e il trascinamento, è necessario recuperare vari valori numerici calcolati dagli elementi. Le seguenti funzioni JavaScript restituiscono valori in pixel calcolati data una proprietà CSS. Viene utilizzato nello script di configurazione nel seguente modo: 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,
}

Nota come window.getComputedStyle() accetta un secondo argomento, uno pseudo elemento di destinazione. È piuttosto interessante che JavaScript possa leggere così tanti valori dagli elementi, anche dagli pseudo-elementi.

dragging

Questo è un momento fondamentale per la logica di trascinamento e ci sono alcune cose da notare dal gestore 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'eroe dello script è state.activethumb, il piccolo cerchio che questo script posiziona insieme a un puntatore. L'oggetto switches è un Map() in cui le chiavi sono .gui-switch e i valori sono limiti e dimensioni memorizzati nella cache che mantengono lo script efficiente. La direzione da destra a sinistra viene gestita utilizzando la stessa proprietà personalizzata che CSS è --isLTR e può utilizzarla per invertire la logica e continuare a supportare la direzione da destra a sinistra. Anche event.offsetX è importante, in quanto contiene un valore delta utile per posizionare il pollice.

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

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

dragEnd

Affinché l'utente possa trascinare il cursore molto lontano dall'interruttore e rilasciarlo, è necessario registrare un evento della finestra globale:

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

  dragEnd(event)
})

Penso che sia molto importante che un utente abbia la libertà di trascinare in modo approssimativo e che l'interfaccia sia abbastanza intelligente da tenerne conto. Non è stato difficile gestirlo con questo interruttore, ma è stato necessario prestare molta attenzione durante lo 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. È il momento di impostare la proprietà di input selezionata e rimuovere tutti gli eventi di movimento. La casella di controllo viene sostituita con state.activethumb.checked = determineChecked().

determineChecked()

Questa funzione, chiamata da dragEnd, determina la posizione attuale del cursore all'interno dei 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
}

Ulteriori riflessioni

Il gesto di trascinamento ha comportato un po' di debito tecnico a causa della struttura HTML iniziale scelta, in particolare il wrapping dell'input in un'etichetta. L'etichetta, essendo un elemento principale, riceverebbe le interazioni di 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 per tenere conto del fatto che l'etichetta riceve questo clic successivo, in quanto deseleziona o seleziona l'interazione eseguita da un utente.

Se dovessi farlo di nuovo, potrei prendere in considerazione la possibilità di modificare il DOM con JavaScript durante l'upgrade dell'esperienza utente, in modo da creare un elemento che gestisca autonomamente i clic sulle etichette e non entri in conflitto con il comportamento integrato.

Questo tipo di JavaScript è quello che preferisco scrivere di meno, non voglio gestire il bubbling condizionale degli eventi:

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

Conclusione

Questo minuscolo componente di commutazione si è rivelato il più difficile di tutte le sfide della GUI finora. Ora che sai come ho fatto, come faresti tu?‽ 🙂

Diversifichiamo i nostri approcci e impariamo tutti i modi per creare sul web. Crea una demo, inviami un tweet con i link e la aggiungerò alla sezione dei remix della community qui sotto.

Remix della community

Risorse

Trova il .gui-switch codice sorgente su GitHub.