Una panoramica di base su come creare un componente Switch reattivo e accessibile.
In questo post voglio mostrarti come creare componenti per i sensori. Prova la demo.
Se preferisci i video, ecco una versione di YouTube di questo post:
Panoramica
Un interruttore funziona in modo simile a una casella di controllo, ma rappresenta in modo esplicito gli stati di attivazione e disattivazione booleani.
Questa demo utilizza <input type="checkbox" role="switch">
per la maggior parte delle
funzionalità, il che ha il vantaggio di non aver bisogno di CSS o JavaScript per essere
completamente funzionali e accessibili. Il caricamento di CSS supporta lingue
da destra a sinistra, verticalità, animazione e altro ancora. Il caricamento di JavaScript
rende il passaggio trascinabile e tangibile.
Proprietà personalizzate
Le seguenti variabili rappresentano le varie parti dello switch e le relative opzioni. Essendo la classe di primo livello, .gui-switch
contiene proprietà personalizzate utilizzate
nei componenti secondari, nonché punti di ingresso per la personalizzazione
centralizzata.
Monitoraggio
Lunghezza (--track-size
), spaziatura interna e due colori:
.gui-switch {
--track-size: calc(var(--thumb-size) * 2);
--track-padding: 2px;
--track-inactive: hsl(80 0% 80%);
--track-active: hsl(80 60% 45%);
--track-color-inactive: var(--track-inactive);
--track-color-active: var(--track-active);
@media (prefers-color-scheme: dark) {
--track-inactive: hsl(80 0% 35%);
--track-active: hsl(80 60% 60%);
}
}
Pizzico
Dimensioni, colore dello sfondo e colori di evidenziazione dell'interazione:
.gui-switch {
--thumb-size: 2rem;
--thumb: hsl(0 0% 100%);
--thumb-highlight: hsl(0 0% 0% / 25%);
--thumb-color: var(--thumb);
--thumb-color-highlight: var(--thumb-highlight);
@media (prefers-color-scheme: dark) {
--thumb: hsl(0 0% 5%);
--thumb-highlight: hsl(0 0% 100% / 25%);
}
}
Movimento ridotto
Per aggiungere un alias chiaro e ridurre la ripetizione, è possibile inserire una query multimediale utente con preferenza di movimento ridotta in una proprietà personalizzata con il plug-in PostCSS in base a questa bozza di specifica in Media Queries 5:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Markup
Ho scelto di aggregare il mio elemento <input type="checkbox" role="switch">
con un <label>
, raggruppando la relazione per evitare ambiguità di associazione di caselle di controllo ed etichette, dando all'utente la possibilità di interagire con l'etichetta per attivare/disattivare l'input.
<label for="switch" class="gui-switch">
Label text
<input type="checkbox" role="switch" id="switch">
</label>
<input type="checkbox">
include un'API
e uno state predefiniti. Il browser gestisce la proprietà checked
e gli eventi di input come oninput
e onchanged
.
Layout
Le proprietà Flexbox, grid e personalizzate sono fondamentali per mantenere gli stili di questo componente. Centralizzano i valori, assegnano nomi a calcoli o aree ambigui e consentono una piccola API di proprietà personalizzata per semplificare la personalizzazione dei componenti.
.gui-switch
Il layout di primo livello per l'interruttore è Flexbox. La classe .gui-switch
contiene le proprietà personalizzate pubbliche e private che i bambini usano per calcolare i loro layout.
.gui-switch {
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
}
L'estensione e la modifica del layout Flexbox è come modificare qualsiasi layout Flexbox.
Ad esempio, per applicare le etichette sopra o sotto un sensore o per modificare flex-direction
:
<label for="light-switch" class="gui-switch" style="flex-direction: column">
Default
<input type="checkbox" role="switch" id="light-switch">
</label>
Monitoraggio
All'input della casella di controllo viene assegnato lo stile di una traccia switch rimuovendo il suo valore appearance: checkbox
normale e fornendo le proprie dimensioni:
.gui-switch > input {
appearance: none;
inline-size: var(--track-size);
block-size: var(--thumb-size);
padding: var(--track-padding);
flex-shrink: 0;
display: grid;
align-items: center;
grid: [track] 1fr / [track] 1fr;
}
La traccia crea anche un'area della traccia della griglia a cella singola e utilizzabile con un pollice.
Pizzico
Lo stile appearance: none
rimuove anche il segno di spunta visivo fornito dal
browser. Questo componente utilizza uno pseudo-elemento e la pseudo-classe :checked
nell'input per sostituire l'indicatore visivo.
Il pollice è uno pseudo-elemento secondario collegato a input[type="checkbox"]
e
si sovrappone alla traccia anziché sotto rivendicando l'area della griglia
track
:
.gui-switch > input::before {
content: "";
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
}
Stili
Le proprietà personalizzate consentono un componente per il passaggio versatile, che si adatta a schemi cromatici, lingue da destra a sinistra e preferenze di movimento.
Stili di interazione touch
Sui dispositivi mobili, i browser aggiungono evidenziazioni al tocco e funzionalità di selezione del testo a etichette e input. Ciò ha influito negativamente sullo stile e sulle interazioni visive
necessarie a questo passaggio. Con poche righe di CSS posso rimuovere questi effetti e aggiungere
il mio stile cursor: pointer
:
.gui-switch {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
Non è sempre consigliabile rimuovere questi stili, in quanto possono rappresentare un prezioso feedback sulle interazioni visive. Assicurati di fornire alternative personalizzate se le rimuovi.
Monitoraggio
Gli stili di questo elemento riguardano principalmente la forma e il colore, a cui si accede dall'elemento .gui-switch
principale tramite la cascade.
.gui-switch > input {
appearance: none;
border: none;
outline-offset: 5px;
box-sizing: content-box;
padding: var(--track-padding);
background: var(--track-color-inactive);
inline-size: var(--track-size);
block-size: var(--thumb-size);
border-radius: var(--track-size);
}
Le quattro proprietà personalizzate disponibili sono numerose. border: none
è stato aggiunto perché appearance: none
non
rimuove i bordi dalla casella di controllo in tutti i browser.
Pizzico
L'elemento pollice si trova già sul track
a destra, ma richiede stili del cerchio:
.gui-switch > input::before {
background: var(--thumb-color);
border-radius: 50%;
}
Interazione
Utilizza le proprietà personalizzate per prepararti alle interazioni, che mostreranno aree evidenziate al passaggio del mouse e modifiche alla posizione del pollice. Viene selezionata anche la preferenza dell'utente prima di eseguire la transizione degli stili di animazione o di evidenziazione del passaggio del mouse.
.gui-switch > input::before {
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
@media (--motionOK) { & {
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}}
}
Posizione pollice
Le proprietà personalizzate forniscono un singolo meccanismo di origine per il posizionamento del pollice nella traccia. Abbiamo a disposizione le dimensioni della traccia e del pollice che utilizzeremo nei
calcoli per mantenere il corretto offset del pollice e all'interno della traccia:
0%
e 100%
.
L'elemento input
possiede la variabile di posizione --thumb-position
, mentre lo pseudo elemento thumb la utilizza come posizione translateX
:
.gui-switch > input {
--thumb-position: 0%;
}
.gui-switch > input::before {
transform: translateX(var(--thumb-position));
}
Ora possiamo modificare --thumb-position
da CSS e dalle pseudo-classi
fornite negli elementi delle caselle di controllo. Poiché abbiamo impostato in precedenza transition: transform
var(--thumb-transition-duration) ease
in modo condizionale su questo elemento, queste modifiche potrebbero animarsi quando vengono modificate:
/* positioned at the end of the track: track length - 100% (thumb width) */
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
}
/* positioned in the center of the track: half the track - half the thumb */
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
}
Pensavo che questa orchestrazione disaccoppiata andasse bene. L'elemento "thumb" riguarda
un solo stile, ovvero una posizione translateX
. L'input può gestire tutta
la complessità e i calcoli.
Verticale
È stata supportata una classe di modifica -vertical
che aggiunge una rotazione con
trasformazioni CSS all'elemento input
.
Un elemento ruotato 3D non modifica però l'altezza complessiva del componente, il che può compromettere il layout a blocchi. Tieni conto di ciò utilizzando le variabili --track-size
e --track-padding
. Calcola la quantità minima di spazio necessaria affinché un pulsante verticale
sia presente nel layout come previsto:
.gui-switch.-vertical {
min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));
& > input {
transform: rotate(-90deg);
}
}
(RTL) da destra a sinistra
Io e Elad Schecter abbiamo prototipato insieme un menu laterale a scorrimento utilizzando le trasformazioni CSS che gestivano le lingue da destra a sinistra capovolgendo una singola variabile. L'abbiamo fatto perché non ci sono trasformazioni logiche delle proprietà in CSS e potrebbe non esserlo mai. Elad aveva l'idea di utilizzare un valore di proprietà personalizzato per invertire le percentuali, per consentire la gestione di singole località della nostra logica personalizzata per le trasformazioni logiche. Ho usato la stessa tecnica per il passaggio e penso che abbia funzionato alla grande:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Una proprietà personalizzata denominata --isLTR
inizialmente contiene il valore 1
, ovvero
true
poiché il nostro layout è da sinistra a destra per impostazione predefinita. Quindi, utilizzando la pseudo classe CSS :dir()
, il valore viene impostato su -1
quando il componente si trova in un layout da destra a sinistra.
Metti --isLTR
in azione utilizzandolo all'interno di un calc()
all'interno di una trasformazione:
.gui-switch.-vertical > input {
transform: rotate(-90deg);
transform: rotate(calc(90deg * var(--isLTR) * -1));
}
Ora la rotazione del sensore verticale tiene conto della posizione del lato opposto richiesto dal layout da destra a sinistra.
Anche le trasformazioni translateX
nello pseudoelemento thumb devono essere aggiornate per
tenere conto del requisito del lato opposto:
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
--thumb-position: calc(
((var(--track-size) / 2) - (var(--thumb-size) / 2))
* var(--isLTR)
);
}
Sebbene questo approccio non sia in grado di soddisfare tutte le esigenze relative a un concetto come la trasformazione logica del codice CSS, offre alcuni principi DRY per molti casi d'uso.
Stati
L'utilizzo dell'elemento input[type="checkbox"]
integrato non sarebbe completo senza gestire i vari stati in cui può trovarsi: :checked
, :disabled
, :indeterminate
e :hover
. :focus
è stato lasciato intenzionalmente lasciato a se stesso, modificando solo l'offset; l'anello di messa a fuoco era perfetto su Firefox e Safari:
Selezionato
<label for="switch-checked" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>
Questo stato rappresenta lo stato on
. In questo stato, lo sfondo della "traccia" dell'input è impostato sul colore attivo e la posizione del pollice su "fine".
.gui-switch > input:checked {
background: var(--track-color-active);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
Disabilitato
<label for="switch-disabled" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>
Un pulsante :disabled
non solo ha un aspetto diverso, ma dovrebbe anche rendere immutabile l'elemento.L'immutabilità dell'interazione è priva del browser, ma gli stati visivi richiedono stili a causa dell'uso di appearance: none
.
.gui-switch > input:disabled {
cursor: not-allowed;
--thumb-color: transparent;
&::before {
cursor: not-allowed;
box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 50%);
@media (prefers-color-scheme: dark) { & {
box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 50%);
}}
}
}
Questo stato è complicato perché richiede temi chiari e scuri con entrambi gli stati disattivati e selezionati. Ho scelto stilisticamente stili minimi per questi stati, così da alleggerire il carico di manutenzione delle combinazioni di stili.
Indeterminato
Uno stato spesso dimenticato è :indeterminate
, in cui una casella di controllo non è né selezionata né deselezionata. Questo è uno stato divertente, piacevole e sobrio. Un buon promemoria per ricordare che gli stati booleani possono essere poco visibili da uno stato all'altro.
È difficile impostare una casella di controllo su indeterminata, solo JavaScript può impostarla:
<label for="switch-indeterminate" class="gui-switch">
Indeterminate
<input type="checkbox" role="switch" id="switch-indeterminate">
<script>document.getElementById('switch-indeterminate').indeterminate = true</script>
</label>
Poiché lo stato per me è sobrio e invitante, mi sembrava opportuno posizionare l'interruttore con la posizione centrale al centro:
.gui-switch > input:indeterminate {
--thumb-position: calc(
calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
* var(--isLTR)
);
}
Passa il mouse sopra
Le interazioni con il passaggio del mouse devono fornire un supporto visivo per l'UI connessa e anche fornire indicazioni verso l'UI interattiva. Questo pulsante evidenzia il pollice con un anello semitrasparente quando passi il mouse sull'etichetta o sull'input. Questa animazione al passaggio del mouse fornisce la direzione verso l'elemento interattivo del pollice.
L'effetto "Evidenzia" viene eseguito con box-shadow
. Al passaggio del mouse su un input non disattivato, aumenta la dimensione di --highlight-size
. Se l'utente è d'accordo con il movimento, effettuiamo la transizione di box-shadow
e lo vediamo crescere; se non è d'accordo con il movimento, l'evidenziazione appare immediatamente:
.gui-switch > input::before {
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
@media (--motionOK) { & {
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}}
}
.gui-switch > input:not(:disabled):hover::before {
--highlight-size: .5rem;
}
JavaScript
Per me, il tentativo di emulare un'interfaccia fisica è inconsueto, soprattutto se si tratta di un cerchio all'interno di una traccia. iOS ha funzionato correttamente con il suo switch, puoi trascinarli da un lato all'altro ed è molto gratificante avere questa opzione. Al contrario, un elemento UI può risultare inattivo se viene tentato un gesto di trascinamento e non succede nulla.
Mi piace trascinabili
Lo pseudo-elemento "thumb" riceve la sua posizione dall'elemento var(--thumb-position)
con ambito .gui-switch > input
. JavaScript può fornire un valore di stile incorporato nell'input per aggiornare in modo dinamico la posizione del pollice, facendo sembrare che segua
il gesto del puntatore. Quando il puntatore viene rilasciato, rimuovi gli stili incorporati e determina se il trascinamento era più vicino a quello disattivato o attivo utilizzando la proprietà personalizzata --thumb-position
. Questa è la struttura portante della soluzione: gli eventi puntatore
monitorano in modo condizionale le posizioni dei puntatore per modificare le proprietà personalizzate del CSS.
Poiché il componente era già funzionante al 100% prima della visualizzazione di questo script, è necessario un po' di lavoro per mantenere il comportamento esistente, ad esempio fare clic su un'etichetta per attivare/disattivare l'input. Il nostro JavaScript non dovrebbe aggiungere funzionalità a scapito di quelle esistenti.
touch-action
Trascinare è un gesto personalizzato, quindi è perfetto per i vantaggi di touch-action
. In questo caso, il nostro script deve gestire un gesto orizzontale o un gesto verticale per la variante del sensore verticale. Con touch-action
possiamo indicare al browser quali gesti gestire su questo elemento, in modo che uno script possa gestire un gesto senza concorrenza.
Il codice CSS seguente indica al browser che quando un gesto del puntatore parte dall'interno di questa traccia di cambio, vengono gestiti i gesti verticali e non gli eventuali gesti orizzontali.
.gui-switch > input {
touch-action: pan-y;
}
Il risultato desiderato è un gesto orizzontale che non consente anche di eseguire la panoramica o lo scorrimento della pagina. Un puntatore può far scorrere l'inizio e la pagina verticalmente dall'input, mentre quelli orizzontali sono gestiti in modo personalizzato.
Utilità stile Pixel
Durante la configurazione e il trascinamento, sarà necessario estrarre vari valori numerici calcolati dagli elementi. Le seguenti funzioni JavaScript restituiscono valori dei pixel calcolati in base a una proprietà CSS. Viene utilizzato nello script di configurazione come questo:
getStyle(checkbox, 'padding-left')
.
const getStyle = (element, prop) => {
return parseInt(window.getComputedStyle(element).getPropertyValue(prop));
}
const getPseudoStyle = (element, prop) => {
return parseInt(window.getComputedStyle(element, ':before').getPropertyValue(prop));
}
export {
getStyle,
getPseudoStyle,
}
Osserva come window.getComputedStyle()
accetta un secondo argomento, uno pseudo elemento di destinazione. È pratico perché JavaScript possa leggere tanti valori dagli elementi, anche da pseudo elementi.
dragging
Questo è un momento fondamentale per la logica di trascinamento e occorre notare alcuni elementi dal gestore di eventi della funzione:
const dragging = event => {
if (!state.activethumb) return
let {thumbsize, bounds, padding} = switches.get(state.activethumb.parentElement)
let directionality = getStyle(state.activethumb, '--isLTR')
let track = (directionality === -1)
? (state.activethumb.clientWidth * -1) + thumbsize + padding
: 0
let pos = Math.round(event.offsetX - thumbsize / 2)
if (pos < bounds.lower) pos = 0
if (pos > bounds.upper) pos = bounds.upper
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
}
L'elemento hero dello script è state.activethumb
, il cerchio che è posizionato
insieme a un puntatore. L'oggetto switches
è un oggetto Map()
in cui le chiavi sono di .gui-switch
e i valori sono limiti e dimensioni memorizzati nella cache che mantengono efficiente lo script. La scrittura da destra a sinistra viene gestita utilizzando la stessa proprietà personalizzata del CSS --isLTR
e può utilizzarla per invertire la logica e continuare a supportare RTL. Anche event.offsetX
è importante, poiché contiene un valore
delta utile per il posizionamento del pollice.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Questa ultima riga di CSS imposta la proprietà personalizzata utilizzata dall'elemento thumb. Questa assegnazione del valore subirebbe una transizione nel tempo, ma un evento puntatore precedente ha impostato temporaneamente --thumb-transition-duration
su 0s
, rimuovendo quella che sarebbe stata un'interazione lenta.
dragEnd
Per consentire all'utente di trascinare all'esterno dell'opzione e rilasciare, è stato necessario registrare un evento di finestra globale:
window.addEventListener('pointerup', event => {
if (!state.activethumb) return
dragEnd(event)
})
Penso che sia molto importante che un utente abbia la libertà di trascinarsi senza stringere e che l'interfaccia sia abbastanza intelligente da tenerne conto. Non ci è voluto molto per gestirlo con questo passaggio, ma è stato necessario un'attenta considerazione durante il processo di sviluppo.
const dragEnd = event => {
if (!state.activethumb) return
state.activethumb.checked = determineChecked()
if (state.activethumb.indeterminate)
state.activethumb.indeterminate = false
state.activethumb.style.removeProperty('--thumb-transition-duration')
state.activethumb.style.removeProperty('--thumb-position')
state.activethumb.removeEventListener('pointermove', dragging)
state.activethumb = null
padRelease()
}
L'interazione con l'elemento è stata completata. È ora di impostare la proprietà di input verificata e rimuovere tutti gli eventi dei gesti. La casella di controllo è stata modificata con
state.activethumb.checked = determineChecked()
.
determineChecked()
Questa funzione, chiamata da dragEnd
, determina dove la corrente di apertura si trova entro i limiti della traccia e restituisce true se è uguale o superiore a metà della traccia:
const determineChecked = () => {
let {bounds} = switches.get(state.activethumb.parentElement)
let curpos =
Math.abs(
parseInt(
state.activethumb.style.getPropertyValue('--thumb-position')))
if (!curpos) {
curpos = state.activethumb.checked
? bounds.lower
: bounds.upper
}
return curpos >= bounds.middle
}
Riflessioni extra
Il gesto di trascinamento ha generato un debito di codice a causa della struttura HTML iniziale scelta, in particolare il wrapping dell'input in un'etichetta. Essendo un elemento padre, l'etichetta riceve interazioni con clic dopo l'input. Alla fine dell'evento dragEnd
, potresti aver notato padRelease()
come una funzione dal suono strano.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
Questo tiene conto dell'etichetta che riceve questo clic successivo, in quanto deseleziona o verifica l'interazione eseguita da un utente.
Se dovessi farlo di nuovo, potrei prendere in considerazione di modificare il DOM con JavaScript durante l'upgrade dell'UX, in modo da creare un elemento che gestisca i clic sulle etichette e non contrasti con il comportamento integrato.
Questo tipo di JavaScript è l'approccio che preferisco scrivere. Non voglio gestire il bubbling degli eventi condizionale:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Conclusione
Finora, questo piccolo componente di switch è stato il lavoro più impegnativo di tutte le sfide GUI. Ora che sai come ci sono riuscito, come faresti? 🙂
Diversifica i nostri approcci e scopriamo tutti i modi per creare sul web. Crea una demo, inviami un tweet con i link e lo aggiungerò alla sezione Remix della community di seguito.
Remix della community
- @KonstantinRouda con un elemento personalizzato: demo e codice.
- @jhvanderschee con un pulsante: Codepen.
Risorse
Trova il codice sorgente di .gui-switch
su GitHub.