Una panoramica di base su come creare un componente di commutazione reattivo e accessibile.
In questo post voglio condividere il mio pensiero su un modo per creare componenti di commutazione. Prova la 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 booleani di attivazione e disattivazione.
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 di CSS offre il supporto per le lingue
da destra a sinistra, la verticalità, l'animazione e altro ancora. Il caricamento di JavaScript rende l'interruttore
trascinabile e tangibile.
Proprietà personalizzate
Le seguenti variabili rappresentano le varie parti dell'interruttore e le relative
opzioni. In qualità di classe di primo livello, .gui-switch
contiene proprietà personalizzate utilizzate
in tutti gli elementi secondari del componente e punti di ingresso 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 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 media query per gli utenti con preferenza per il movimento ridotto 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);
Segni e linee
Ho scelto di racchiudere l'elemento <input type="checkbox" role="switch">
in un
<label>
, raggruppando la loro relazione per evitare ambiguità
nell'associazione tra casella di controllo ed etichetta, 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">
viene fornito precompilato con un'API e uno stato. Il browser gestisce la proprietà checked
e gli eventi di input come oninput
e onchanged
.
Layout
Flexbox, griglia e proprietà personalizzate sono fondamentali per mantenere gli stili di questo componente. Centralizzano i valori, assegnano nomi a calcoli o aree altrimenti ambigui e consentono una piccola API delle proprietà personalizzate per personalizzare facilmente i componenti.
.gui-switch
Il layout di primo livello per l'interruttore è flexbox. La classe .gui-switch
contiene
le proprietà personalizzate private e pubbliche utilizzate dai figli per calcolare i
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 posizionare le etichette sopra o sotto un interruttore 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>
Traccia
L'input della casella di controllo viene stilizzato come una traccia di interruttore rimuovendo il suo appearance: checkbox
normale e fornendo invece 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 di traccia della griglia a una cella per una miniatura da rivendicare.
Miniature
Lo stile appearance: none
rimuove anche il segno di spunta visivo fornito dal
browser. Questo componente utilizza uno
pseudo-elemento e la
pseudo-classe sull'input per
sostituire questo indicatore visivo.:checked
Il pollice è un elemento secondario pseudo-elemento collegato a input[type="checkbox"]
e
si impila sopra la 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 di commutazione versatile che si adatta a schemi di colori, lingue da destra a sinistra e preferenze di movimento.
Stili di interazione touch
Sui dispositivi mobili, i browser aggiungono funzionalità di evidenziazione al tocco e di selezione del testo alle etichette e
ai campi di input. Questi elementi hanno influito negativamente sullo stile e sul feedback visivo
di cui questo interruttore aveva bisogno. 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 fornire un feedback visivo prezioso. 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.
.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 la traccia dell'interruttore deriva da quattro
proprietà personalizzate. border: none
viene aggiunto perché appearance: none
non
rimuove i bordi dalla casella di controllo su tutti i browser.
Miniature
L'elemento pollice si trova già a destra track
, ma necessita di stili circolari:
.gui-switch > input::before {
background: var(--thumb-color);
border-radius: 50%;
}
Interazione
Utilizza le proprietà personalizzate per prepararti alle interazioni che mostreranno i punti salienti al passaggio del mouse e le modifiche alla posizione del pollice. Prima di eseguire la transizione degli stili di evidenziazione del movimento o al passaggio del mouse, viene controllata anche 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 meccanismo di origine singola per posizionare il cursore nella traccia. Abbiamo a disposizione le dimensioni della traccia e del pollice, che utilizzeremo nei calcoli per mantenere il pollice correttamente sfalsato e all'interno della traccia:
0%
e 100%
.
L'elemento input
possiede la variabile di posizione --thumb-position
e 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 liberamente --thumb-position
da CSS e dalle pseudo-classi
fornite sugli elementi della casella di controllo. Poiché in precedenza abbiamo impostato transition: transform
var(--thumb-transition-duration) ease
in modo condizionale su 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 funzionasse bene. L'elemento pollice riguarda
solo uno stile, una posizione translateX
. L'input può gestire tutta
la complessità e i calcoli.
Verticale
Il supporto è stato eseguito con una classe modificatore -vertical
che aggiunge una rotazione con
le trasformazioni CSS all'elemento input
.
Un elemento ruotato in 3D non modifica l'altezza complessiva del componente,
il che può compromettere il layout dei blocchi. Tieni conto di questo utilizzando le variabili --track-size
e
--track-padding
. Calcola lo spazio minimo richiesto per
un pulsante verticale per il flusso 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
Un amico CSS, Elad Schecter, e io abbiamo creato insieme un prototipo di menu laterale a scorrimento utilizzando le trasformazioni CSS che gestiscono le lingue da destra a sinistra invertendo una singola variabile. Abbiamo fatto questo perché non esistono trasformazioni di proprietà logiche in CSS e probabilmente non esisteranno mai. Elad ha avuto la brillante idea di utilizzare un valore della proprietà personalizzata per invertire le percentuali, per consentire la gestione di una singola posizione della nostra logica personalizzata per le trasformazioni logiche. Ho utilizzato la stessa tecnica in questo cambio e credo che abbia funzionato alla grande:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Una proprietà personalizzata chiamata --isLTR
inizialmente contiene il valore 1
, il che significa che è
true
, dato che il nostro 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.
Metti in pratica --isLTR
utilizzandolo all'interno di un calc()
in una trasformazione:
.gui-switch.-vertical > input {
transform: rotate(-90deg);
transform: rotate(calc(90deg * var(--isLTR) * -1));
}
Ora la rotazione dell'interruttore verticale tiene conto della posizione del lato opposto richiesta dal layout da destra a sinistra.
Anche le trasformazioni translateX
sullo pseudo-elemento 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 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 lasciato intenzionalmente invariato, con
una modifica apportata solo al suo offset; l'anello di messa a fuoco aveva un ottimo aspetto 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 cursore è impostata su "la
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 visivo diverso, ma deve anche rendere
l'elemento immutabile.L'immutabilità dell'interazione è senza costi per il 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 è complesso perché richiede temi scuri e chiari con stati disattivati e selezionati. Ho scelto stili minimali per questi stati per semplificare la manutenzione delle combinazioni di stili.
Indeterminato
Uno stato spesso dimenticato è :indeterminate
, in cui una casella di controllo non è
selezionata né deselezionata. Questo è uno stato divertente, invitante e modesto. Un buon
promemoria che gli stati booleani possono avere stati intermedi subdoli.
È difficile impostare una casella di controllo su Indeterminato, 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, mi è sembrato opportuno posizionare il cursore dell'interruttore 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 al passaggio del mouse devono fornire supporto visivo per l'interfaccia utente connessa e anche indicazioni per l'interfaccia utente interattiva. Questo interruttore evidenzia il pollice con un anello semitrasparente quando il cursore passa sopra l'etichetta o l'input. Questa animazione al passaggio del mouse fornisce poi indicazioni sull'elemento interattivo del pollice.
L'effetto "Evidenzia" viene realizzato con box-shadow
. Al passaggio del mouse su un input non disattivato, aumenta le dimensioni di --highlight-size
. Se l'utente non ha problemi con il movimento, la box-shadow
viene visualizzata e cresce. Se invece non vuole che ci siano movimenti, 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 commutazione può sembrare inquietante nel suo tentativo di emulare un'interfaccia fisica, soprattutto 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 tentato un gesto di trascinamento e non succede nulla.
Miniature trascinabili
Lo pseudo-elemento pollice riceve la sua posizione dall'elemento .gui-switch > input
con ambito var(--thumb-position)
. JavaScript può fornire un valore di stile in linea
all'input per aggiornare dinamicamente la posizione del pollice, in modo che sembri seguire
il gesto del puntatore. Quando il puntatore viene rilasciato, rimuovi gli stili in linea e
determina se il trascinamento è più vicino a off o on utilizzando la proprietà personalizzata
--thumb-position
. Si tratta della spina dorsale della soluzione: gli eventi puntatore
tracciano in modo condizionale le posizioni del puntatore per modificare le proprietà personalizzate CSS.
Poiché il componente era già funzionale al 100% prima della visualizzazione di questo script, è necessario un notevole lavoro per mantenere il comportamento esistente, ad esempio fare clic su un'etichetta per attivare/disattivare l'input. Il nostro JavaScript non deve aggiungere funzionalità a scapito di quelle esistenti.
touch-action
Il trascinamento è un gesto personalizzato, il che lo rende un ottimo candidato per
i vantaggi di touch-action
. Nel caso di questo interruttore, un gesto orizzontale deve
essere gestito dal nostro script, mentre un gesto verticale deve essere acquisito per la variante
dell'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 puntatore inizia da all'interno di questa traccia di commutazione, gestisce i gesti verticali e non fa nulla con quelli orizzontali:
.gui-switch > input {
touch-action: pan-y;
}
Il risultato desiderato è un gesto orizzontale che non sposta o scorre la pagina. Un puntatore può scorrere verticalmente a partire dall'interno dell'input e scorrere la pagina, ma quelli orizzontali vengono gestiti in modo personalizzato.
Utilità di stile del valore pixel
Durante la configurazione e il trascinamento, è necessario recuperare vari valori numerici calcolati
dagli elementi. Le seguenti funzioni JavaScript restituiscono valori in pixel calcolati
data una proprietà CSS. Viene utilizzato nello script di configurazione nel seguente modo:
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 di destinazione. È piuttosto interessante 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 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 questo script
posiziona insieme a un puntatore. 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 direzione da destra a sinistra viene gestita utilizzando la stessa proprietà personalizzata
che CSS è --isLTR
e può utilizzarla per invertire la logica e continuare
a supportare la direzione da destra a sinistra. Anche event.offsetX
è importante, in quanto contiene un valore delta
utile per posizionare il pollice.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
L'ultima riga di CSS imposta la proprietà personalizzata utilizzata dall'elemento miniatura. Questa
assegnazione di valori altrimenti cambierebbe nel tempo, ma un evento
puntatore precedente ha impostato temporaneamente --thumb-transition-duration
su 0s
, rimuovendo quella che
sarebbe stata un'interazione lenta.
dragEnd
Affinché l'utente possa trascinare il cursore molto lontano dall'interruttore e rilasciarlo, è necessario registrare un evento della 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 in modo approssimativo e che l'interfaccia sia abbastanza intelligente da tenerne conto. Non è stato difficile gestirlo con questo interruttore, ma è stato necessario prestare molta attenzione durante lo 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 la proprietà di input selezionata
e rimuovere tutti gli eventi di movimento. La casella di controllo viene sostituita con
state.activethumb.checked = determineChecked()
.
determineChecked()
Questa funzione, chiamata da dragEnd
, determina la posizione attuale del cursore
all'interno dei 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
}
Ulteriori riflessioni
Il gesto di trascinamento ha comportato un po' di debito tecnico a causa della struttura HTML iniziale
scelta, in particolare il wrapping dell'input in un'etichetta. L'etichetta, essendo un elemento
principale, riceverebbe le interazioni di 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 per tenere conto del fatto che l'etichetta riceve questo clic successivo, in quanto deseleziona o seleziona l'interazione eseguita da un utente.
Se dovessi farlo di nuovo, 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 autonomamente i clic sulle etichette e non entri in conflitto con il comportamento integrato.
Questo tipo di JavaScript è quello che preferisco scrivere di meno, non voglio gestire il bubbling condizionale degli eventi:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Conclusione
Questo minuscolo componente di commutazione si è rivelato il più difficile di tutte le sfide della GUI finora. Ora che sai come ho fatto, come faresti tu?‽ 🙂
Diversifichiamo i nostri approcci e impariamo tutti i modi per creare sul web. Crea una demo, inviami un tweet con i link e la aggiungerò alla sezione dei remix della community qui sotto.
Remix della community
- @KonstantinRouda con un elemento personalizzato: demo e codice.
- @jhvanderschee con un pulsante: Codepen.
Risorse
Trova il .gui-switch
codice sorgente su
GitHub.