Creazione di un componente Switch

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

In questo post voglio condividere le idee su un modo per creare componenti di switch. 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 di attivazione e disattivazione booleani.

Questa demo utilizza <input type="checkbox" role="switch"> per la maggior parte dei di Google Cloud, che ha il vantaggio di non dover utilizzare CSS o JavaScript completamente funzionali e accessibili. Il caricamento di CSS introduce il supporto della scrittura da destra a sinistra lingue, verticalità, animazione e altro. Il caricamento di JavaScript esegue il passaggio trascinabili e tangibili.

Proprietà personalizzate

Le seguenti variabili rappresentano le varie parti dello switch e le relative le opzioni di CPU e memoria disponibili. Essendo la classe di primo livello, .gui-switch contiene proprietà personalizzate utilizzate a tutti gli elementi secondari dei componenti e punti di accesso per personalizzazione.

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 dello 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 le ripetizioni, un utente con preferenze di movimento ridotto la query supporti può essere inserita in una proprietà personalizzata con il plug-in basato su questa bozza nella sezione Query multimediali 5

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

Aumento

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

R
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"> è predefinito API e state. La del browser gestisce checked proprietà e input eventi ad esempio oninpute onchanged.

Layout

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

.gui-switch

Il layout di primo livello per il passaggio è flexbox. Il corso .gui-switch contiene le proprietà personalizzate pubbliche e private usate dai publisher secondari per calcolare layout.

Flexbox DevTools che si sovrappone a un&#39;etichetta orizzontale e a un&#39;opzione, mostrando il relativo layout
distribuzione dello spazio.

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

L'estensione e la modifica del layout flexbox sono analoghi a modificare il layout di un flexbox. Ad esempio, per inserire etichette sopra o sotto un sensore o per modificare flex-direction:

Flexbox DevTools sovrapposto a un&#39;etichetta verticale e a un cambio.

<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 un cambio di traccia rimuovendo la sua normale appearance: checkbox e fornisce una dimensione specifica:

DevTools a griglia che si sovrappone alla traccia di cambio, mostrando la traccia della griglia denominata
aree 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 monitoraggio con griglia a singola cella una per una per un pollice reclamo.

Miniature

Lo stile appearance: none rimuove anche il segno di spunta visivo fornito dallo del browser. Questo componente utilizza un parametro pseudo-elemento e :checked pseudo-class sull'input in sostituire questo indicatore visivo.

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

DevTools che mostra il pollice dello pseudo-elemento come 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 al colore schemi, lingue da destra a sinistra e preferenze di movimento.

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

Stili di interazione tocco

Sui dispositivi mobili, i browser aggiungono le evidenziazioni del tocco e le funzionalità di selezione del testo a etichette e di input. Ciò ha influito negativamente sullo stile e sul feedback visivo che per usare questo interruttore. 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, che possono avere valore visivo feedback sull'interazione. Se le rimuovi, assicurati di fornire alternative personalizzate.

Traccia

Gli stili di questo elemento riguardano principalmente la forma e il colore a cui accede dal publisher principale .gui-switch tramite cascade.

Cambia le varianti con colori e dimensioni delle tracce 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 varietà di opzioni di personalizzazione per il cambio di traccia è disponibile in quattro proprietà personalizzate. border: none è stato aggiunto perché appearance: none non lo fa rimuovi i bordi dalla casella di controllo in tutti i browser.

Miniature

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

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

DevTools mostrato che evidenzia lo pseudo-elemento con il pollice circolare.

Interazione

Utilizza le proprietà personalizzate per prepararti alle interazioni che mostreranno il passaggio del mouse le evidenziazioni e la posizione del pollice. Anche la preferenza dell'utente è selezionata prima di eseguire la transizione stili di evidenziazione movimento o al 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 unico meccanismo di origine per posizionare il pollice all'interno la traccia. A nostra disposizione sono disponibili le dimensioni delle tracce e dei pollici che utilizzeremo per mantenere lo spostamento corretto del pollice all'interno della traccia: 0% e 100%.

L'elemento input è proprietario della variabile di posizione --thumb-position e del pollice lo pseudoelemento lo usa come posizione translateX:

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

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

Ora siamo liberi di modificare --thumb-position da CSS e dalle pseudo-classi forniti agli elementi delle caselle di controllo. Poiché in precedenza abbiamo impostato transition: transform var(--thumb-transition-duration) ease in modo condizionale su questo elemento, queste modifiche può animarsi quando viene modificato:

/* 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 funzionasse bene. L'elemento pollice è riguardare un solo stile, una posizione translateX. L'input può gestire tutte la complessità e i calcoli.

Verticale

Il supporto è stato eseguito con una classe di modificatore -vertical che aggiunge una rotazione con Il CSS viene trasformato nell'elemento input.

Tuttavia, un elemento ruotato in 3D non modifica l'altezza complessiva del componente. il che può compromettere il layout a blocchi. Tieni conto di questo problema utilizzando --track-size e --track-padding variabili. Calcolare la quantità minima di spazio richiesta un pulsante verticale che scorra 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 un'amica CSS, Elad Schecter, abbiamo creato un prototipo un menu laterale a scorrimento, utilizzando le trasformazioni CSS che gestivano la scrittura da destra a sinistra lingue diverse capovolgendo . L'abbiamo fatto perché non ci sono trasformazioni logiche in CSS e potrebbero non esserci mai. Elad ha avuto l'idea di utilizzare un valore di proprietà personalizzato di invertire le percentuali, per consentire la gestione di un'unica sede logica per le trasformazioni logiche. Ho usato la stessa tecnica in questo passaggio e pensi che sia andata alla grande:

.gui-switch {
  --isLTR: 1;

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

Una proprietà personalizzata denominata --isLTR inizialmente contiene il valore 1, il che significa che true poiché il nostro layout va da sinistra a destra per impostazione predefinita. Quindi, utilizzando il linguaggio CSS pseudoclasse :dir(), il valore è impostato su -1 quando il componente è in un layout da destra a sinistra.

Metti in azione --isLTR utilizzandolo in 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 richiesti dal layout da destra a sinistra.

Anche le trasformazioni translateX sullo pseudo-elemento pollice devono essere aggiornate in 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)
  );
}

Anche se questo approccio non è efficace per risolvere tutte le esigenze relative a un concetto come il CSS logico trasformazioni, offre I principi DRY per molti e casi d'uso specifici.

Stati

L'utilizzo dell'asset integrato input[type="checkbox"] non sarebbe completo senza per gestire i vari stati in cui può trovarsi: :checked, :disabled :indeterminate e :hover. :focus è stata lasciata intenzionalmente da sola, con un l'aggiustamento apportato solo al suo offset; l'anello di messa a fuoco sembrava ottimo su Firefox 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, l'input "track" lo sfondo sia impostato sul colore attivo e la posizione del pollice è 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 dovrebbe anche rendere il immutabile.L'immutabilità dell'interazione è senza costi dal 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 non selezionata
stati.

Questo stato è complicato perché richiede temi chiari e scuri sia disattivati che selezionati. Ho scelto stilisticamente stili minimalisti per rendere questi stati più nitidi la manutenzione delle combinazioni di stili.

Indeterminato

Uno stato spesso dimenticato è :indeterminate, dove una casella di controllo non è né selezionata o deselezionata. È uno stile divertente, invitante e senza pretese. Un buon ricorda che gli stati booleani possono presentarsi occulti tra uno stato e l'altro.

È difficile impostare una casella di controllo 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 di indeterminato con la miniatura della traccia
centrale, per indicare un&#39;incertezza.

Poiché per me lo stato è semplice e invitante, è stato appropriato metterlo il pulsante di attivazione/disattivazione 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 dovrebbero fornire un supporto visivo per l'UI connessa e anche forniscono indicazioni verso una UI interattiva. Questa opzione evidenzia il pollice con un anello semitrasparente quando passi il mouse sopra l'etichetta o l'input. Questo passaggio l'animazione fornisce quindi la direzione verso l'elemento pollice interattivo.

L'elemento "in evidenza" è stato eseguito con box-shadow. Al passaggio del mouse, aumenta le dimensioni di --highlight-size di un input non disattivato. Se l'utente approva il movimento, eseguiamo la transizione del box-shadow e lo vediamo crescere. Se non va bene per il movimento, l'evidenziazione viene visualizzata all'istante:

.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

Secondo me, l'interfaccia di uno switch può sembrare inquietante nel tentativo di emulare un modello fisico dell'interfaccia utente, in particolare questo tipo con un cerchio all'interno di una traccia. iOS ha capito bene con il loro interruttore, puoi trascinarle da un lato all'altro ed è molto soddisfacente l'opzione. Al contrario, un elemento UI può sembrare inattivo se viene eseguito un gesto di trascinamento e non succede nulla.

Pollice trascinabili

Lo pseudo-elemento pollice riceve la sua posizione dallo .gui-switch > input con ambito var(--thumb-position), JavaScript può fornire un valore di stile incorporato l'input per aggiornare dinamicamente la posizione del pollice facendolo sembrare seguire il gesto del puntatore. Quando il puntatore viene rilasciato, rimuovi gli stili incorporati e determinare se il trascinamento era più vicino o attivo utilizzando la proprietà personalizzata --thumb-position. Questa è la colonna portante della soluzione: eventi puntatore monitorare in modo condizionale le posizioni del puntatore per modificare le proprietà personalizzate CSS.

Poiché il componente era già funzionante al 100% prima che questo script venga visualizzato occorre un bel 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à in a spese delle funzionalità esistenti.

touch-action

Il trascinamento è un gesto, personalizzato, il che lo rende ideale per Vantaggi di touch-action. Nel caso di questo cambio, un gesto orizzontale dovrebbe essere gestito dallo script o un gesto verticale registrato per il passaggio e la variante corrispondente. Con touch-action possiamo indicare al browser quali gesti gestire 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 viene avviato in questa traccia di cambio, gestire i gesti verticali, non fare nulla con la posizione quelli:

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

Il risultato desiderato è un gesto orizzontale che non esegue anche la panoramica o lo scorrimento . Un puntatore può scorrere verticalmente iniziando dall'input e far scorrere mentre quelle orizzontali sono gestite in modo personalizzato.

Utilità dello stile dei valori pixel

Durante la configurazione e durante il trascinamento, sarà necessario acquisire vari valori di numeri calcolati dagli elementi. Le seguenti funzioni JavaScript restituiscono valori di pixel calcolati 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. È abbastanza chiaro che JavaScript possa leggere così tanti valori dagli elementi, anche dagli pseudo-elementi.

dragging

Questo è un momento chiave per la logica di trascinamento e ci sono alcuni aspetti da considerare dal gestore di eventi di 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'hero dello script è state.activethumb, il piccolo cerchio rappresentato dallo script insieme a un puntatore. L'oggetto switches è un Map() in cui 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 che il CSS è --isLTR e che può utilizzarlo per invertire la logica e continuare che supporta RTL. Anche il parametro event.offsetX è importante, in quanto contiene un delta utile per posizionare il pollice.

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

Questa riga finale di CSS imposta la proprietà personalizzata utilizzata dall'elemento thumb. Questo un'assegnazione di valore verrebbe altrimenti trasferita nel tempo, ma un puntatore precedente l'evento ha impostato temporaneamente --thumb-transition-duration su 0s e ciò che verrà rimosso ci sarebbe stata un'interazione lenta.

dragEnd

Per consentire all'utente di trascinare all'esterno dell'interruttore e rilasciare, viene visualizzata una evento finestra globale necessario registrato:

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

  dragEnd(event)
})

Penso che sia molto importante che un utente abbia la libertà di trascinare abbastanza intelligente da tenerne conto. Non ci è voluto molto per gestirlo con questo passaggio, ma è stata necessaria un'attenta valutazione durante lo sviluppo e 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, è il momento di impostare l'input selezionato e rimuovere tutti gli eventi relativi ai gesti. La casella di controllo viene modificata con state.activethumb.checked = determineChecked().

determineChecked()

Questa funzione, chiamata da dragEnd, determina la posizione attuale del pollice entro i limiti della sua traccia e restituisce true se è uguale o superiore a a metà strada:

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 subito un debito di codice a causa della struttura HTML iniziale dei dati, soprattutto se aggrega l'input in un'etichetta. L'etichetta, in quanto padre , riceverebbe le interazioni di clic dopo l'input. Alla fine Evento dragEnd, potresti aver notato padRelease() come suono strano personalizzata.

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

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

per tenere conto dell'etichetta su cui verrà fatto clic in un secondo momento, come verrebbe deselezionato, o verificarla, l'interazione eseguita da un utente.

Se dovessi farlo di nuovo, potrei prendere in considerazione la modifica del DOM con JavaScript durante l'upgrade dell'esperienza utente, in modo da creare un elemento che gestisca i clic sulle etichette e non si oppone al comportamento integrato.

Questo tipo di JavaScript è quello che preferisco scrivere, non voglio gestire bubbling evento condizionale:

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

Conclusione

Questo componente di passaggio per adolescenti si è rivelato la parte più impegnativa di tutte le sfide legate alla GUI. finora! Ora che sai come ci ho fatto, come faresti‽ 🙂

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

Remix della community

Risorse

Trova il codice sorgente .gui-switch su GitHub.