Creazione di un componente Switch

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

In questo post voglio condividere alcune idee su come creare componenti di switch. Prova la demo.

Demo

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

Panoramica

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

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

Proprietà personalizzate

Le seguenti variabili rappresentano le varie parti dello switch e le relative opzioni. In quanto classe di primo livello, .gui-switch contiene proprietà personalizzate utilizzate in tutti i componenti secondari e punti di accesso per una personalizzazione centralizzata.

Traccia

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

Miniature

Le dimensioni, il colore di sfondo e i colori di evidenziazione delle interazioni:

.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 le ripetizioni, una query media per gli utenti con preferenze di movimento ridotte può essere inserita in una proprietà personalizzata con il plug-in PostCSS in base a questa specifica preliminare nelle query media 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>, raggruppandone la relazione per evitare ambiguità nell'associazione di caselle di controllo e etichette, offrendo al contempo 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 stile.

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

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

Layout

Flexbox, grid e proprietà personalizzate sono fondamentali per mantenere gli stili di questo componente. Centralizzano i valori, assegnino nomi a calcoli o aree altrimenti ambigui e attivano una piccola API di proprietà personalizzate per semplici personalizzazioni dei componenti.

.gui-switch

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

Strumenti per gli sviluppatori Flexbox che sovrappongono un&#39;etichetta e un&#39;opzione orizzontali, mostrando la distribuzione dello spazio nel 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 inserire etichette sopra o sotto un'opzione o per modificare il pulsanteflex-direction:

Flexbox DevTools sovrappone un&#39;etichetta e un&#39;opzione verticali.

<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 definito come una traccia Switch rimuovendo il suo normale appearance: checkbox e fornendo invece una dimensione separata:

Grid DevTools sovrapposto alla traccia di commutazione, che mostra le aree della traccia della griglia denominate 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;
}

Il canale crea anche un'area della traccia con una griglia di singole celle per la rivendicazione di una miniatura.

Miniature

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

Il miniplayer è un elemento secondario pseudo collegato a input[type="checkbox"] e si sovrappone alla traccia anziché sotto di essa, occupando l'area della grigliatrack:

DevTools che mostra l&#39;anteprima dell&#39;elemento pseudo 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 di utilizzare un componente di sensore versatile che si adatta a schemi di colori, lingue da destra a sinistra e preferenze di movimento.

Un confronto affiancato del tema chiaro e scuro per l&#39;opzione e i relativi stati.

Stili di interazione tocco

Sui dispositivi mobili, i browser aggiungono evidenziazioni al tocco e funzionalità di selezione del testo alle etichette e ai campi di immissione. Ciò ha influito negativamente sullo stile e sul feedback visivo necessario per 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 essere un feedback importante sull'interazione visiva. 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 .gui-switch principale tramite cascade.

Le varianti di cambio con colori e dimensioni delle tracce personalizzate.

.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 il canale di cambio proviene da quattro proprietà personalizzate. border: none viene aggiunto perché appearance: none nonrimuove i bordi dalla casella di controllo su tutti i browser.

Miniature

L'elemento miniatura è già a destra track, ma ha bisogno di stili di cerchio:

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

DevTools mostra l&#39;evidenziazione dell&#39;elemento pseudo-thumb a forma di cerchio.

Interazione

Utilizza le proprietà personalizzate per prepararti alle interazioni che mostreranno gli evidenziatori al passaggio del mouse e le modifiche alla posizione del cursore. Prima di applicare la transizione degli stili di animazione o di evidenziazione al passaggio del mouse, viene anche controllata 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 pollice

Le proprietà personalizzate forniscono un unico meccanismo di origine per il posizionamento dell'anteprima nel brano. A nostra disposizione sono disponibili le dimensioni della traccia e del pollice che utilizzeremo nei calcoli per mantenere lo spostamento del pollice all'interno della traccia: 0% e 100%.

L'elemento input possiede la variabile di posizione --thumb-position e lo pseudoelemento 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 dal CSS e le pseudoclassi fornite per gli elementi di casella di controllo. Poiché abbiamo impostato transition: transform var(--thumb-transition-duration) ease in modo condizionale in precedenza in 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 abbia funzionato bene. L'elemento miniatura riguarda solo uno stile, una posizione translateX. L'input può gestire tutta la complessità e i calcoli.

Verticale

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

Tuttavia, un elemento ruotato in 3D non modifica l'altezza complessiva del componente, il che può alterare il layout del blocco. Tieni conto di questo aspetto utilizzando le variabili --track-size e --track-padding. Calcola lo spazio minimo necessario per far sì che un pulsante verticale si adatti al 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 un mio amico CSS, Elad Schecter, abbiamo creato insieme un prototipo di menu laterale scorrevole utilizzando le trasformazioni CSS che gestivano le lingue da destra a sinistra capovolgendo una singola variabile. L'abbiamo fatto perché non ci sono trasformazioni delle proprietà logiche in CSS e potrebbero non esserci mai. Elad ha avuto la grande idea di utilizzare un valore di proprietà personalizzato per invertire le percentuali, al fine di consentire la gestione di un'unica posizione della nostra logica personalizzata per le trasformazioni logiche. Ho utilizzato la stessa tecnica in questo passaggio e ritengo che il risultato sia stato ottimo:

.gui-switch {
  --isLTR: 1;

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

Una proprietà personalizzata denominata --isLTR ha inizialmente un valore 1, ovvero true, poiché il 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.

Utilizza --isLTR 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 pulsante di attivazione/disattivazione verticale tiene conto della posizione opposta del lato obbligatoria per il layout da destra a sinistra.

Anche le trasformazioni translateX sull'elemento pseudo-anteprima 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 funzioni per risolvere tutte le esigenze relative a un concetto come le trasformazioni logiche di CSS, 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 intenzionalmente lasciato invariato, con un aggiustamento solo dell'offset; l'anello di messa a fuoco era perfetto su Firefox e Safari:

Uno screenshot dell&#39;anello di messa a fuoco attivo 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 dell'input "track" è impostato sul colore attivo e la posizione del cursore è impostata 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 deve anche rendere immutabile l'elemento. L'immutabilità dell'interazione è indipendente dal 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;opzione con stile scuro negli stati disattivato,
selezionato e non selezionato.

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

Indeterminato

Uno stato spesso dimenticato è :indeterminate, in cui una casella di controllo non è selezionata o deselezionata. È uno stile divertente, invitante e senza pretese. Un buon promemoria che gli stati booleani possono avere stati intermedi nascosti.

È complicato impostare una casella di controllo su indeterminata, 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 con l&#39;anteprima della traccia al centro, per indicare che non è stata presa una decisione.

Poiché lo stato, per me, è modesto e invitante, ho ritenuto opportuno mettere la posizione del pollice dell'opzione 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 con il passaggio del mouse devono fornire supporto visivo per l'interfaccia utente collegata e anche indicare la direzione verso l'interfaccia utente interattiva. Questo pulsante evidenzia il cursore con un anello semitrasparente quando passi il mouse sopra l'etichetta o il campo di immissione. Questa animazione passaggio del mouse fornisce quindi la direzione verso l'elemento interattivo del pollice.

L'effetto "evidenziazione" viene creato con box-shadow. Al passaggio del mouse, aumenta le dimensioni di --highlight-size di un input non disattivato. Se l'utente accetta il movimento, viene visualizzata la transizione del box-shadow e lo vediamo crescere. Se non accetta il movimento, 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 interruttore può sembrare strana nel suo tentativo di emulare un'interfaccia fisica, in particolare questo tipo con un cerchio all'interno di un canale. 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 UI può sembrare inattivo se si tenta un gesto di trascinamento e non succede nulla.

Icone di scorrimento trascinabili

L'elemento pseudo-thumb riceve la sua posizione da .gui-switch > input var(--thumb-position) con ambito, JavaScript può fornire un valore di stile in linea sull'input per aggiornare dinamicamente la posizione del cursore in modo che sembri seguire il gesto del cursore. Quando rilasci il cursore, rimuovi gli stili in linea e determina se il trascinamento era più vicino a off o on utilizzando la proprietà personalizzata --thumb-position. Questa è la spina dorsale della soluzione: gli eventi del cursore monitorano condizionatamente le posizioni del cursore per modificare le proprietà CSS personalizzate.

Poiché il componente era già completamente funzionale prima che questo script fosse visualizzato, è necessario un po' di lavoro per mantenere il comportamento esistente, ad esempio fare clic su un'etichetta per attivare/disattivare l'input. Il nostro codice JavaScript non dovrebbe aggiungere caratteristiche a scapito di quelle esistenti.

touch-action

Il trascinamento è un gesto personalizzato, che lo rende un'ottima candidata per i vantaggi di touch-action. In questo caso, il nostro script deve gestire un gesto orizzontale o acquisire un gesto verticale per la variante di 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 cursore inizia da questo canale di attivazione/disattivazione, deve gestire i gesti verticali e non fare nulla con quelli horizontali:

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

Il risultato desiderato è un gesto orizzontale che non esegue anche la panoramica o lo scorrimento della pagina. Un cursore può scorrere verticalmente dall'interno dell'input e scorrere la pagina, ma quelli orizzontali vengono gestiti in modo personalizzato.

Utilità dello stile dei valori pixel

Durante la configurazione e il trascinamento, sarà necessario recuperare diversi valori numerici calcolati dagli elementi. Le seguenti funzioni JavaScript restituiscono valori di 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,
}

Nota come window.getComputedStyle() accetta un secondo argomento, uno pseudoelemento target. È fantastico 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 tenere presente 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 lo script sta posizionando insieme a un cursore. 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 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 è utile, in quanto contiene un valore delta utile per il posizionamento del pollice.

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

Questa riga finale di CSS imposta la proprietà personalizzata utilizzata dall'elemento thumb. In caso contrario, questa assegnazione del valore passerebbe nel tempo, ma un evento del cursore 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, è necessario registrare un evento finestra globale:

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

  dragEnd(event)
})

Penso che sia molto importante che l'utente abbia la libertà di trascinare l'utente in modo libero e che l'interfaccia sia abbastanza intelligente da tenere conto di ciò. Non è stato necessario molto per gestire 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à checked dell'input e rimuovere tutti gli eventi di gesto. La casella di controllo è stata modificata con state.activethumb.checked = determineChecked().

determineChecked()

Questa funzione, chiamata da dragEnd, determina dove si trova il cursore corrente nei 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
}

Considerazioni aggiuntive

Il gesto di trascinamento ha comportato un po' di debito di codice a causa della struttura HTML iniziale scelta, in particolare per l'inserimento di un a capo nell'input in un'etichetta. Essendo un elemento principale, l'etichetta riceve interazioni di clic dopo l'input. Alla fine dell'eventodragEnd, potresti aver notato che padRelease() è una funzione dal suono strano.

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

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

Questo serve a tenere conto del fatto che l'etichetta riceve questo clic successivo, in quanto deseleziona o seleziona l'interazione eseguita da un utente.

Se dovessi rifarlo, 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 i clic sulle etichette e non interferisca con il comportamento integrato.

Questo tipo di codice JavaScript è il mio preferito da scrivere, non voglio gestire la propagazione degli eventi condizionali:

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

Conclusione

Questo minuscolo componente di switch è stato il più impegnativo di tutte le sfide GUI finora. Ora che sai come ho fatto, come faresti? 🙂

Diversifichiamo i nostri approcci e impariamo tutti i modi per creare sul web. Crea una demo, twittami i link e io la aggiungerò alla sezione dei remix della community di seguito.

Remix della community

Risorse

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