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.
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.
<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.
.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
:
<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:
.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
:
.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.
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.
.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%;
}
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:
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%);
}}
}
}
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>
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
- @KonstantinRouda con un elemento personalizzato: demo e code.
- @jhvanderschee con un pulsante: Codepen.
Risorse
Trova il codice sorgente di .gui-switch
su
GitHub.