Creazione di un componente Switch

Una panoramica di base su come creare un componente di opzione adattabile 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à, il 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 dell'opzione e le relative opzioni. In qualità di classe di primo livello, .gui-switch contiene proprietà personalizzate utilizzate in tutti i componenti secondari e punti di contatto 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 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.

Flexbox DevTools sovrappone 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 impostato come traccia di opzione rimuovendo il valore normaleappearance: checkbox e specificando le sue dimensioni:

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.

L'anteprima è 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 un componente di opzione versatile che si adatta a temi 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 sul feedback relativo allo stile e all'interazione visiva 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 principale .gui-switch tramite la cascata.

Le varianti dell&#39;opzione 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);
}

Un'ampia gamma di opzioni di personalizzazione per il canale di scambio 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 del pollice

Le proprietà personalizzate forniscono un unico meccanismo di origine per il posizionamento dell'anteprima nel brano. A nostra disposizione abbiamo le dimensioni della traccia e del cursore che utilizzeremo nei calcoli per mantenere il cursore correttamente offset e 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 modifica -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 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 il prototipo di un menu laterale scorrevole che utilizza le trasformazioni CSS per gestire le lingue da destra a sinistra capovolgendo una singola variabile. Lo abbiamo fatto perché in CSS non esistono trasformazioni delle proprietà logiche e potrebbe non essercene mai. Elad ha avuto la brillante idea di utilizzare un valore della proprietà personalizzata per invertire le percentuali, in modo da consentire la gestione di una singola località 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 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 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 incentrato 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 deselezionato.

Questo stato è complicato perché richiede temi scuri e chiari con stati disattivati e selezionati. Ho scelto stili minimal per questi stati per allentare 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 stato 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, mi è sembrato appropriato 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 con il passaggio del mouse fornisce indicazioni sull'elemento di miniatura interattivo.

L'effetto "evidenziazione" viene creato con box-shadow. Quando passi il mouse sopra un input non disattivato, aumenta le dimensioni di --highlight-size. 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 di 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 eseguito 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 deve aggiungere funzionalità a spese 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, lo 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à per lo stile dei valori dei pixel

Durante la configurazione e il trascinamento, sarà necessario recuperare diversi valori numerici calcolati dagli elementi. Le seguenti funzioni JavaScript restituiscono valori in 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 pseudo elemento target. È fantastico che JavaScript possa leggere così tanti valori dagli elementi, persino 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 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 modalità da destra a sinistra viene gestita utilizzando la stessa proprietà personalizzata del CSS --isLTR ed è in grado di utilizzarla per invertire la logica e continuare a supportare la modalità 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 ultima riga di CSS imposta la proprietà personalizzata utilizzata dall'elemento miniatura. 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

Affinché l'utente possa trascinare molto al di fuori dell'opzione e rilasciare, è stato necessario registrare un evento 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 liberamente e che l'interfaccia sia abbastanza intelligente da tenerne conto. 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 viene modificata con state.activethumb.checked = determineChecked().

determineChecked()

Questa funzione, chiamata da dragEnd, determina dove si trova il cursore corrente all'interno dei limiti della traccia e restituisce true se è uguale o superiore alla 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. L'etichetta, in quanto elemento principale, riceverà le interazioni con i 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, poiché 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 entri in conflitto 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.