Una panoramica di base su come creare un componente di opzione adattabile e accessibile.
In questo post voglio condividere alcune idee su come creare componenti di switch. Prova la demo.
Se preferisci i video, ecco una versione di questo post su YouTube:
Panoramica
Un pulsante di attivazione/disattivazione funziona in modo simile a una casella di controllo, ma rappresenta esplicitamente gli stati booleani On e Off.
Questa demo utilizza <input type="checkbox" role="switch">
per la maggior parte delle sue funzionalità, il che ha il vantaggio di non richiedere CSS o JavaScript per essere completamente funzionale e accessibile. Il caricamento del CSS supporta le lingue da destra a sinistra, la verticalità, l'animazione e altro ancora. Il caricamento di JavaScript rende il pulsante scorrevole e tangibile.
Proprietà personalizzate
Le seguenti variabili rappresentano le varie parti dell'opzione e le relative opzioni. In qualità di classe di primo livello, .gui-switch
contiene proprietà personalizzate utilizzate
in tutti i componenti secondari e punti di contatto per la personalizzazione
centralizzata.
Traccia
La lunghezza (--track-size
), il padding e due colori:
.gui-switch {
--track-size: calc(var(--thumb-size) * 2);
--track-padding: 2px;
--track-inactive: hsl(80 0% 80%);
--track-active: hsl(80 60% 45%);
--track-color-inactive: var(--track-inactive);
--track-color-active: var(--track-active);
@media (prefers-color-scheme: dark) {
--track-inactive: hsl(80 0% 35%);
--track-active: hsl(80 60% 60%);
}
}
Miniature
Le dimensioni, il colore di sfondo e i colori di evidenziazione delle interazioni:
.gui-switch {
--thumb-size: 2rem;
--thumb: hsl(0 0% 100%);
--thumb-highlight: hsl(0 0% 0% / 25%);
--thumb-color: var(--thumb);
--thumb-color-highlight: var(--thumb-highlight);
@media (prefers-color-scheme: dark) {
--thumb: hsl(0 0% 5%);
--thumb-highlight: hsl(0 0% 100% / 25%);
}
}
Movimento ridotto
Per aggiungere un alias chiaro e ridurre le ripetizioni, una query media per gli utenti con preferenze di movimento ridotte può essere inserita in una proprietà personalizzata con il plug-in PostCSS in base a questa specifica preliminare nelle query media 5:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Segni e linee
Ho scelto di racchiudere l'elemento <input type="checkbox" role="switch">
in un
<label>
, raggruppandone la relazione per evitare ambiguità nell'associazione di caselle di controllo e etichette, offrendo al contempo all'utente la possibilità di interagire con l'etichetta per attivare/disattivare l'input.
<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 impostato come traccia di opzione rimuovendo il valore normaleappearance: checkbox
e specificando le sue 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;
}
Il canale crea anche un'area della traccia con una griglia di singole celle per la rivendicazione di una miniatura.
Miniature
Lo stile appearance: none
rimuove anche il segno di spunta visivo fornito dal browser. Questo componente utilizza un
pseudo-elemento e la pseudo-classe :checked
sull'input per
sostituire questo indicatore visivo.
L'anteprima è un elemento secondario pseudo collegato a input[type="checkbox"]
e si sovrappone alla traccia anziché sotto di essa, occupando l'area della grigliatrack
:
.gui-switch > input::before {
content: "";
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
}
Stili
Le proprietà personalizzate consentono un componente di opzione versatile che si adatta a temi di colori, lingue da destra a sinistra e preferenze di movimento.
Stili di interazione tocco
Sui dispositivi mobili, i browser aggiungono evidenziazioni al tocco e funzionalità di selezione del testo alle etichette e ai campi di immissione. Ciò ha influito negativamente sul feedback relativo allo stile e all'interazione visiva necessario per questo passaggio. Con poche righe di CSS posso rimuovere questi effetti e aggiungere il mio stile cursor: pointer
:
.gui-switch {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
Non è sempre consigliabile rimuovere questi stili, in quanto possono essere un feedback importante sull'interazione visiva. Assicurati di fornire alternative personalizzate se le rimuovi.
Traccia
Gli stili di questo elemento riguardano principalmente la forma e il colore, a cui accede dall'elemento principale .gui-switch
tramite la cascata.
.gui-switch > input {
appearance: none;
border: none;
outline-offset: 5px;
box-sizing: content-box;
padding: var(--track-padding);
background: var(--track-color-inactive);
inline-size: var(--track-size);
block-size: var(--thumb-size);
border-radius: var(--track-size);
}
Un'ampia gamma di opzioni di personalizzazione per il canale di scambio proviene da quattro proprietà personalizzate. border: none
viene aggiunto perché appearance: none
nonrimuove i bordi dalla casella di controllo su tutti i browser.
Miniature
L'elemento miniatura è già a destra track
, ma ha bisogno di stili di cerchio:
.gui-switch > input::before {
background: var(--thumb-color);
border-radius: 50%;
}
Interazione
Utilizza le proprietà personalizzate per prepararti alle interazioni che mostreranno gli evidenziatori al passaggio del mouse e le modifiche alla posizione del cursore. Prima di applicare la transizione degli stili di animazione o di evidenziazione al passaggio del mouse, viene anche controllata la preferenza dell'utente.
.gui-switch > input::before {
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
@media (--motionOK) { & {
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}}
}
Posizione del pollice
Le proprietà personalizzate forniscono un unico meccanismo di origine per il posizionamento dell'anteprima nel brano. A nostra disposizione abbiamo le dimensioni della traccia e del cursore che utilizzeremo nei calcoli per mantenere il cursore correttamente offset e all'interno della traccia: 0%
e 100%
.
L'elemento input
possiede la variabile di posizione --thumb-position
e lo pseudoelemento thumb
la utilizza come posizione translateX
:
.gui-switch > input {
--thumb-position: 0%;
}
.gui-switch > input::before {
transform: translateX(var(--thumb-position));
}
Ora possiamo modificare --thumb-position
dal CSS e le pseudoclassi
fornite per gli elementi di casella di controllo. Poiché abbiamo impostato transition: transform
var(--thumb-transition-duration) ease
in modo condizionale in precedenza in questo elemento, queste modifiche
potrebbero essere animate quando vengono modificate:
/* positioned at the end of the track: track length - 100% (thumb width) */
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
}
/* positioned in the center of the track: half the track - half the thumb */
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
}
Ho pensato che questa orchestrazione disaccoppiata abbia funzionato bene. L'elemento miniatura riguarda solo uno stile, una posizione translateX
. L'input può gestire tutta la complessità e i calcoli.
Verticale
Il supporto è stato realizzato con una classe di modifica -vertical
che aggiunge una rotazione con le trasformazioni CSS all'elemento input
.
Tuttavia, un elemento ruotato in 3D non modifica l'altezza complessiva del componente,
il che può alterare il layout del blocco. Tieni conto di questo utilizzando le variabili --track-size
e
--track-padding
. Calcola lo spazio minimo necessario per far sì che un pulsante verticale si adatti al layout come previsto:
.gui-switch.-vertical {
min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));
& > input {
transform: rotate(-90deg);
}
}
(RTL) da destra a sinistra
Io e un mio amico CSS, Elad Schecter, abbiamo creato insieme il prototipo di un menu laterale scorrevole che utilizza le trasformazioni CSS per gestire le lingue da destra a sinistra capovolgendo una singola variabile. Lo abbiamo fatto perché in CSS non esistono trasformazioni delle proprietà logiche e potrebbe non essercene mai. Elad ha avuto la brillante idea di utilizzare un valore della proprietà personalizzata per invertire le percentuali, in modo da consentire la gestione di una singola località della nostra logica personalizzata per le trasformazioni logiche. Ho utilizzato la stessa tecnica in questo passaggio e ritengo che il risultato sia stato ottimo:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Una proprietà personalizzata denominata --isLTR
ha inizialmente un valore 1
, ovvero true
, poiché il layout è da sinistra a destra per impostazione predefinita. Poi, utilizzando la pseudo-classe CSS :dir()
, il valore viene impostato su -1
quando il componente si trova in un layout da destra a sinistra.
Utilizza --isLTR
all'interno di un calc()
all'interno di una trasformazione:
.gui-switch.-vertical > input {
transform: rotate(-90deg);
transform: rotate(calc(90deg * var(--isLTR) * -1));
}
Ora la rotazione del pulsante di attivazione/disattivazione verticale tiene conto della posizione opposta del lato obbligatoria per il layout da destra a sinistra.
Anche le trasformazioni translateX
sull'elemento pseudo-anteprima devono essere aggiornate per tenere conto del requisito del lato opposto:
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
--thumb-position: calc(
((var(--track-size) / 2) - (var(--thumb-size) / 2))
* var(--isLTR)
);
}
Sebbene questo approccio non sia adatto a risolvere tutte le esigenze relative a un concetto come le trasformazioni CSS logiche, offre alcuni principi DRY per molti casi d'uso.
Stati
L'utilizzo di input[type="checkbox"]
integrato non sarebbe completo senza gestire i vari stati in cui può trovarsi: :checked
, :disabled
, :indeterminate
e :hover
. :focus
è stato intenzionalmente lasciato invariato, con un aggiustamento solo dell'offset; l'anello di messa a fuoco era perfetto su Firefox e Safari:
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 minimal per questi stati per allentare il carico di manutenzione delle combinazioni di stili.
Indeterminato
Uno stato spesso dimenticato è :indeterminate
, in cui una casella di controllo non è selezionata o deselezionata. È uno stato divertente, invitante e senza pretese. Un buon promemoria che gli stati booleani possono avere stati intermedi nascosti.
È complicato impostare una casella di controllo su indeterminata, solo JavaScript può farlo:
<label for="switch-indeterminate" class="gui-switch">
Indeterminate
<input type="checkbox" role="switch" id="switch-indeterminate">
<script>document.getElementById('switch-indeterminate').indeterminate = true</script>
</label>
Poiché lo stato, per me, è modesto e invitante, mi è sembrato appropriato mettere la posizione del pollice dell'opzione al centro:
.gui-switch > input:indeterminate {
--thumb-position: calc(
calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
* var(--isLTR)
);
}
Passaci il mouse sopra
Le interazioni con il passaggio del mouse devono fornire supporto visivo per l'interfaccia utente collegata e anche indicare la direzione verso l'interfaccia utente interattiva. Questo pulsante evidenzia il cursore con un anello semitrasparente quando passi il mouse sopra l'etichetta o il campo di immissione. Questa animazione con il passaggio del mouse fornisce indicazioni sull'elemento di miniatura interattivo.
L'effetto "evidenziazione" viene creato con box-shadow
. Quando passi il mouse sopra un input non disattivato, aumenta le dimensioni di --highlight-size
. Se l'utente accetta il movimento, viene visualizzata la transizione del box-shadow
e lo vediamo crescere. Se non accetta il movimento, l'evidenziazione viene visualizzata immediatamente:
.gui-switch > input::before {
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
@media (--motionOK) { & {
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}}
}
.gui-switch > input:not(:disabled):hover::before {
--highlight-size: .5rem;
}
JavaScript
Per me, un'interfaccia di interruttore può sembrare strana nel suo tentativo di emulare un'interfaccia fisica, in particolare di questo tipo con un cerchio all'interno di una traccia. iOS ha fatto bene con il suo interruttore, puoi trascinarlo da un lato all'altro ed è molto soddisfacente avere questa opzione. Al contrario, un elemento dell'interfaccia utente può sembrare inattivo se viene eseguito un gesto di trascinamento e non succede nulla.
Icone di scorrimento trascinabili
L'elemento pseudo-thumb riceve la sua posizione da .gui-switch > input
var(--thumb-position)
con ambito, JavaScript può fornire un valore di stile in linea sull'input per aggiornare dinamicamente la posizione del cursore in modo che sembri seguire il gesto del cursore. Quando rilasci il cursore, rimuovi gli stili in linea e
determina se il trascinamento era più vicino a off o on utilizzando la proprietà personalizzata
--thumb-position
. Questa è la spina dorsale della soluzione: gli eventi del cursore monitorano condizionatamente le posizioni del cursore per modificare le proprietà CSS personalizzate.
Poiché il componente era già completamente funzionale prima che questo script fosse visualizzato, è necessario un po' di lavoro per mantenere il comportamento esistente, ad esempio fare clic su un'etichetta per attivare/disattivare l'input. Il nostro codice JavaScript non deve aggiungere funzionalità a spese di quelle esistenti.
touch-action
Il trascinamento è un gesto personalizzato, che lo rende un'ottima candidata per i vantaggi di touch-action
. In questo caso, lo script deve gestire un gesto orizzontale o acquisire un gesto verticale per la variante di interruttore verticale. Con touch-action
possiamo indicare al browser quali gesti gestire su questo elemento, in modo che uno script possa gestire un gesto senza concorrenza.
Il seguente CSS indica al browser che quando un gesto del cursore inizia da questo canale di attivazione/disattivazione, deve gestire i gesti verticali e non fare nulla con quelli horizontali:
.gui-switch > input {
touch-action: pan-y;
}
Il risultato desiderato è un gesto orizzontale che non esegue anche la panoramica o lo scorrimento della pagina. Un cursore può scorrere verticalmente dall'interno dell'input e scorrere la pagina, ma quelli orizzontali vengono gestiti in modo personalizzato.
Utilità per lo stile dei valori dei pixel
Durante la configurazione e il trascinamento, sarà necessario recuperare diversi valori numerici calcolati dagli elementi. Le seguenti funzioni JavaScript restituiscono valori in pixel calcolati
in base a una proprietà CSS. Viene utilizzato nello script di configurazione come questo
getStyle(checkbox, 'padding-left')
.
const getStyle = (element, prop) => {
return parseInt(window.getComputedStyle(element).getPropertyValue(prop));
}
const getPseudoStyle = (element, prop) => {
return parseInt(window.getComputedStyle(element, ':before').getPropertyValue(prop));
}
export {
getStyle,
getPseudoStyle,
}
Nota come window.getComputedStyle()
accetta un secondo argomento, uno pseudo elemento target. È fantastico che JavaScript possa leggere così tanti valori dagli elementi, persino dagli pseudo elementi.
dragging
Questo è un momento fondamentale per la logica di trascinamento e ci sono alcune cose da notare dal gestore eventi della funzione:
const dragging = event => {
if (!state.activethumb) return
let {thumbsize, bounds, padding} = switches.get(state.activethumb.parentElement)
let directionality = getStyle(state.activethumb, '--isLTR')
let track = (directionality === -1)
? (state.activethumb.clientWidth * -1) + thumbsize + padding
: 0
let pos = Math.round(event.offsetX - thumbsize / 2)
if (pos < bounds.lower) pos = 0
if (pos > bounds.upper) pos = bounds.upper
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
}
L'eroe dello script è state.activethumb
, il piccolo cerchio che lo script sta posizionando insieme a un cursore. L'oggetto switches
è un Map()
in cui
le chiavi sono .gui-switch
e i valori sono limiti e dimensioni memorizzati nella cache che mantengono
lo script efficiente. La modalità da destra a sinistra viene gestita utilizzando la stessa proprietà personalizzata
del CSS --isLTR
ed è in grado di utilizzarla per invertire la logica e continuare
a supportare la modalità RTL. Anche event.offsetX
è utile, in quanto contiene un valore delta utile per il posizionamento del pollice.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Questa ultima riga di CSS imposta la proprietà personalizzata utilizzata dall'elemento miniatura. In caso contrario, questa assegnazione del valore passerebbe nel tempo, ma un evento del cursore precedente ha impostato temporaneamente --thumb-transition-duration
su 0s
, rimuovendo quella che sarebbe stata un'interazione lenta.
dragEnd
Affinché l'utente possa trascinare molto al di fuori dell'opzione e rilasciare, è stato necessario registrare un evento finestra globale:
window.addEventListener('pointerup', event => {
if (!state.activethumb) return
dragEnd(event)
})
Penso che sia molto importante che un utente abbia la libertà di trascinare liberamente e che l'interfaccia sia abbastanza intelligente da tenerne conto. Non è stato necessario molto per gestire questo passaggio, ma è stato necessario un'attenta considerazione durante il processo di sviluppo.
const dragEnd = event => {
if (!state.activethumb) return
state.activethumb.checked = determineChecked()
if (state.activethumb.indeterminate)
state.activethumb.indeterminate = false
state.activethumb.style.removeProperty('--thumb-transition-duration')
state.activethumb.style.removeProperty('--thumb-position')
state.activethumb.removeEventListener('pointermove', dragging)
state.activethumb = null
padRelease()
}
L'interazione con l'elemento è stata completata, è ora di impostare la proprietà checked
dell'input e rimuovere tutti gli eventi di gesto. La casella di controllo viene modificata con
state.activethumb.checked = determineChecked()
.
determineChecked()
Questa funzione, chiamata da dragEnd
, determina dove si trova il cursore corrente all'interno dei limiti della traccia e restituisce true se è uguale o superiore alla metà della traccia:
const determineChecked = () => {
let {bounds} = switches.get(state.activethumb.parentElement)
let curpos =
Math.abs(
parseInt(
state.activethumb.style.getPropertyValue('--thumb-position')))
if (!curpos) {
curpos = state.activethumb.checked
? bounds.lower
: bounds.upper
}
return curpos >= bounds.middle
}
Considerazioni aggiuntive
Il gesto di trascinamento ha comportato un po' di debito di codice a causa della struttura HTML iniziale scelta, in particolare per l'inserimento di un a capo nell'input in un'etichetta. L'etichetta, in quanto elemento principale, riceverà le interazioni con i clic dopo l'input. Alla fine dell'eventodragEnd
, potresti aver notato che padRelease()
è una funzione dal suono strano.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
Questo serve a tenere conto del fatto che l'etichetta riceve questo clic successivo, poiché deseleziona o seleziona l'interazione eseguita da un utente.
Se dovessi rifarlo, potrei prendere in considerazione la possibilità di modificare il DOM con JavaScript durante l'upgrade dell'esperienza utente, in modo da creare un elemento che gestisca i clic sulle etichette e non entri in conflitto con il comportamento integrato.
Questo tipo di codice JavaScript è il mio preferito da scrivere, non voglio gestire la propagazione degli eventi condizionali:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Conclusione
Questo minuscolo componente di switch è stato il più impegnativo di tutte le sfide GUI finora. Ora che sai come ho fatto, come faresti? 🙂
Diversifichiamo i nostri approcci e impariamo tutti i modi per creare sul web. Crea una demo, twittami i link e io la aggiungerò alla sezione dei remix della community di seguito.
Remix della community
- @KonstantinRouda con un elemento personalizzato: demo e code.
- @jhvanderschee con un pulsante: Codepen.
Risorse
Trova il codice sorgente di .gui-switch
su
GitHub.