Podstawowe informacje o tym, jak utworzyć elastyczny i dostępny komponent przełącznika.
W tym poście chcę podzielić się przemyśleniami na temat tworzenia komponentów przełączników. Wypróbuj wersję demonstracyjną
Jeśli wolisz film, obejrzyj tę wersję posta w YouTube:
Przegląd
Przełącznik działa podobnie jak pole wyboru, ale wyraźnie reprezentuje stany włączenia i wyłączenia.
Większość funkcji tej wersji demonstracyjnej korzysta z elementu <input type="checkbox" role="switch">
, który nie wymaga CSS ani JavaScriptu, aby działać w pełni i być dostępny. Wczytywanie CSS umożliwia obsługę języków pisanych od prawej do lewej, orientacji pionowej, animacji i innych funkcji. Wczytanie JavaScriptu sprawia, że przełącznik
można przeciągać i dotykać.
Właściwości niestandardowe
Poniższe zmienne reprezentują różne części przełącznika i ich opcje. Jako klasa najwyższego poziomu .gui-switch
zawiera właściwości niestandardowe używane w komponentach podrzędnych oraz punkty wejścia do centralnej personalizacji.
Monitoruj
Długość (--track-size
), dopełnienie i 2 kolory:
.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%);
}
}
Miniatura
rozmiar, kolor tła i kolory podświetlenia interakcji;
.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%);
}
}
Mniej animacji
Aby dodać jasny alias i zmniejszyć powtórzenia, zapytanie o media dotyczące preferencji zmniejszonego ruchu użytkownika można umieścić we właściwości niestandardowej za pomocą wtyczki PostCSS na podstawie tego projektu specyfikacji w zapytaniach o media 5:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Znacznik
Zdecydowałem się otoczyć element <input type="checkbox" role="switch">
elementem <label>
, aby powiązać je ze sobą i uniknąć niejednoznaczności w zakresie powiązania pola wyboru z etykietą, a jednocześnie umożliwić użytkownikowi interakcję z etykietą w celu przełączania stanu pola.
<label for="switch" class="gui-switch">
Label text
<input type="checkbox" role="switch" id="switch">
</label>
<input type="checkbox">
jest wstępnie skonfigurowany z interfejsem API i stanem. Przeglądarka zarządza usługą checked
i zdarzeniami wejściowymi, takimi jak oninput
i onchanged
.
Układy
Flexbox, grid i niestandardowe właściwości mają kluczowe znaczenie dla zachowania stylów tego komponentu. Centralizują wartości, nadają nazwy niejednoznacznym obliczeniom lub obszarom i umożliwiają korzystanie z małego interfejsu Custom Property API, który ułatwia dostosowywanie komponentów.
.gui-switch
Układ najwyższego poziomu przełącznika to flexbox. Klasa .gui-switch
zawiera prywatne i publiczne właściwości niestandardowe, których elementy podrzędne używają do obliczania układów.
.gui-switch {
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
}
Rozszerzanie i modyfikowanie układu flexbox jest podobne do zmiany dowolnego układu flexbox.
Aby na przykład umieścić etykiety nad lub pod przełącznikiem albo zmienić
flex-direction
:
<label for="light-switch" class="gui-switch" style="flex-direction: column">
Default
<input type="checkbox" role="switch" id="light-switch">
</label>
Monitoruj
Pole wyboru jest stylizowane jako ścieżka przełącznika przez usunięcie jego normalnegoappearance: checkbox
i podanie własnego rozmiaru:
.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;
}
Ścieżka tworzy też obszar ścieżki siatki o wymiarach 1 x 1 komórka, który może zająć kciuk.
Miniatura
Styl appearance: none
usuwa też wizualny znacznik wyboru dostarczany przez przeglądarkę. Ten komponent używa pseudoelementu i :checked
pseudoklasy w polu wejściowym, aby zastąpić ten wskaźnik wizualny.
Suwak jest elementem podrzędnym pseudo-elementu dołączonym do elementu input[type="checkbox"]
i znajduje się nad ścieżką, a nie pod nią, ponieważ zajmuje obszar siatki track
:
.gui-switch > input::before {
content: "";
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
}
Style
Własne właściwości umożliwiają korzystanie z wszechstronnego komponentu przełącznika, który dostosowuje się do schematów kolorów, języków pisanych od prawej do lewej i preferencji dotyczących ruchu.
Style interakcji dotykowych
Na urządzeniach mobilnych przeglądarki dodają do etykiet i pól wejściowych funkcje zaznaczania tekstu i podświetlania po kliknięciu. Negatywnie wpłynęło to na styl i informacje zwrotne dotyczące interakcji wizualnej, których potrzebował ten przełącznik. Za pomocą kilku wierszy kodu CSS mogę usunąć te efekty i dodać własny cursor: pointer
styl:
.gui-switch {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
Nie zawsze zaleca się usuwanie tych stylów, ponieważ mogą one stanowić cenne wizualne informacje zwrotne dotyczące interakcji. Jeśli je usuniesz, pamiętaj o zapewnieniu niestandardowych alternatyw.
Monitoruj
Style tego elementu dotyczą głównie jego kształtu i koloru, do których ma dostęp z elementu nadrzędnego .gui-switch
za pomocą kaskady.
.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);
}
Szeroki wybór opcji dostosowywania ścieżki przełącznika wynika z 4 właściwości niestandardowych. border: none
jest dodawany, ponieważ appearance: none
nie usuwa obramowań z pola wyboru we wszystkich przeglądarkach.
Miniatura
Element kciuka jest już po prawej stronie track
, ale wymaga stylów okręgu:
.gui-switch > input::before {
background: var(--thumb-color);
border-radius: 50%;
}
Interakcja
Używaj właściwości niestandardowych, aby przygotować się na interakcje, które będą wyświetlać wyróżnienia po najechaniu kursorem i zmiany pozycji kciuka. Przed przejściem do stylów podświetlenia ruchu lub najechaniu kursorem sprawdzane są też preferencje użytkownika.
.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;
}}
}
Pozycja kciuka
Właściwości niestandardowe zapewniają pojedynczy mechanizm źródłowy do pozycjonowania suwaka na ścieżce. Mamy do dyspozycji rozmiary ścieżki i suwaka, których użyjemy w obliczeniach, aby suwak był prawidłowo przesunięty i mieścił się w ścieżce: 0%
i 100%
.
Element input
ma zmienną pozycji --thumb-position
, a pseudoelement thumb używa jej jako translateX
position:
.gui-switch > input {
--thumb-position: 0%;
}
.gui-switch > input::before {
transform: translateX(var(--thumb-position));
}
Możemy teraz swobodnie zmieniać --thumb-position
z CSS i pseudoklas
udostępnianych w elementach pól wyboru. Ponieważ wcześniej warunkowo ustawiliśmy transition: transform
var(--thumb-transition-duration) ease
w tym elemencie, te zmiany mogą być animowane:
/* 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)
);
}
Uważam, że to rozdzielone orkiestrowanie dobrze się sprawdziło. Element miniatury dotyczy tylko jednego stylu, pozycji translateX
. Dane wejściowe mogą obsługiwać całą złożoność i obliczenia.
Branża
Wsparcie zostało zapewnione za pomocą klasy modyfikatora -vertical
, która dodaje rotację za pomocą przekształceń CSS do elementu input
.
Element obrócony w 3D nie zmienia jednak ogólnej wysokości komponentu, co może zaburzyć układ blokowy. Uwzględnij to za pomocą zmiennych --track-size
i --track-padding
. Oblicz minimalną ilość miejsca potrzebną do prawidłowego wyświetlania pionowego przycisku w układzie:
.gui-switch.-vertical {
min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));
& > input {
transform: rotate(-90deg);
}
}
(RTL) od prawej do lewej
Wraz ze znajomym, Eladem Schecterem, który też zajmuje się usługami porównywania cen, stworzyliśmy prototyp wysuwanego menu bocznego z użyciem przekształceń CSS, które obsługiwało języki pisane od prawej do lewej przez zmianę jednej zmiennej. Zrobiliśmy to, ponieważ w CSS nie ma logicznych przekształceń właściwości i może ich nigdy nie być. Elad wpadł na świetny pomysł, aby użyć niestandardowej wartości właściwości do odwracania procentów, co umożliwiło zarządzanie w jednym miejscu naszą własną logiką niestandardową do przekształceń logicznych. Zastosowałem tę samą technikę w tym przełączniku i myślę, że sprawdziła się świetnie:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Właściwość niestandardowa o nazwie --isLTR
początkowo ma wartość 1
, co oznacza, że jest true
, ponieważ domyślnie nasz układ jest od lewej do prawej. Następnie za pomocą pseudoklasy CSS :dir()
wartość jest ustawiana na -1
, gdy komponent znajduje się w układzie od prawej do lewej.
Wprowadź --isLTR
w życie, używając go w calc()
w ramach przekształcenia:
.gui-switch.-vertical > input {
transform: rotate(-90deg);
transform: rotate(calc(90deg * var(--isLTR) * -1));
}
Teraz obrót przełącznika pionowego uwzględnia pozycję po przeciwnej stronie wymaganą przez układ od prawej do lewej.
Przekształcenia translateX
w pseudo-elemencie kciuka również muszą zostać zaktualizowane, aby uwzględnić wymagania dotyczące przeciwnej strony:
.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)
);
}
To podejście nie sprawdzi się w przypadku wszystkich potrzeb związanych z koncepcją logicznych przekształceń CSS, ale w wielu przypadkach użycia oferuje pewne zasady DRY.
Stany
Korzystanie z wbudowanego komponentu input[type="checkbox"]
nie byłoby kompletne bez obsługi różnych stanów, w jakich może się on znajdować: :checked
, :disabled
, :indeterminate
i :hover
. :focus
został celowo pozostawiony bez zmian, a dostosowano tylko jego przesunięcie. Pierścień zaznaczenia wyglądał świetnie w przeglądarkach Firefox i Safari:
Zaznaczono
<label for="switch-checked" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>
Ten stan reprezentuje stan on
. W tym stanie tło „ścieżki” wejściowej jest ustawione na aktywny kolor, a pozycja suwaka jest ustawiona na „koniec”.
.gui-switch > input:checked {
background: var(--track-color-active);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
Wyłączono
<label for="switch-disabled" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>
Przycisk :disabled
nie tylko wygląda inaczej, ale też powinien sprawić, że element będzie niezmienny.Niezmienność interakcji jest bezpłatna w przeglądarce, ale stany wizualne wymagają stylów ze względu na użycie 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%);
}}
}
}
Ten stan jest skomplikowany, ponieważ wymaga motywów ciemnego i jasnego z wyłączonymi i zaznaczonymi stanami. W przypadku tych stanów wybrałem minimalistyczne style, aby ułatwić utrzymanie kombinacji stylów.
Nieokreślony
Często zapominanym stanem jest :indeterminate
, w którym pole wyboru nie jest zaznaczone ani odznaczone. To przyjemny stan, który zachęca do działania i nie przytłacza. Dobre przypomnienie, że stany logiczne mogą mieć nieoczywiste stany pośrednie.
Ustawienie stanu nieokreślonego pola wyboru jest trudne, można to zrobić tylko za pomocą JavaScriptu:
<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>
Stan ten wydaje mi się niepozorny i zachęcający, dlatego uznałem, że odpowiednie będzie umieszczenie przełącznika w pozycji środkowej:
.gui-switch > input:indeterminate {
--thumb-position: calc(
calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
* var(--isLTR)
);
}
Najechanie
Interakcje po najechaniu kursorem powinny zapewniać wizualne wsparcie dla połączonego interfejsu, a także wskazywać interaktywny interfejs. Gdy najedziesz kursorem na etykietę lub pole do wprowadzania danych, przełącznik podświetli kciuk półprzezroczystym pierścieniem. Animacja po najechaniu kursorem wskazuje kierunek interaktywnego elementu miniatury.
Efekt „podświetlenia” jest realizowany za pomocą box-shadow
. Po najechaniu kursorem na nieaktywne pole wejściowe zwiększ rozmiar ikony --highlight-size
. Jeśli użytkownikowi nie przeszkadza ruch, przechodzimy do box-shadow
i widzimy, jak rośnie. Jeśli nie, wyróżnienie pojawia się natychmiast:
.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
Interfejs przełącznika może wydawać się dziwny, gdy próbuje naśladować interfejs fizyczny, zwłaszcza taki z okręgiem w środku ścieżki. iOS dobrze to rozwiązał w przypadku przełącznika, który można przeciągać na boki, co jest bardzo satysfakcjonujące. Z kolei element interfejsu może wydawać się nieaktywny, jeśli użytkownik spróbuje go przeciągnąć, ale nic się nie stanie.
Przeciągane miniatury
Pseudoelement suwaka otrzymuje pozycję z elementu .gui-switch > input
scopedvar(--thumb-position)
. JavaScript może dostarczyć wartość stylu wbudowanego w dane wejściowe, aby dynamicznie aktualizować pozycję suwaka, dzięki czemu będzie on wyglądał tak, jakby podążał za gestem wskaźnika. Gdy wskaźnik zostanie zwolniony, usuń style wbudowane i określ, czy przeciągnięcie było bliżej pozycji wyłączonej czy włączonej, używając właściwości niestandardowej --thumb-position
. To podstawa rozwiązania: zdarzenia wskaźnika warunkowo śledzą pozycje wskaźnika, aby modyfikować niestandardowe właściwości CSS.
Ponieważ komponent był w 100% funkcjonalny, zanim pojawił się ten skrypt, utrzymanie dotychczasowego działania, np. klikanie etykiety w celu przełączania danych wejściowych, wymaga sporo pracy. Nasz JavaScript nie powinien dodawać funkcji kosztem istniejących funkcji.
touch-action
Przeciąganie to gest niestandardowy, dlatego doskonale nadaje się do touch-action
. W przypadku tego przełącznika gest poziomy powinien być obsługiwany przez nasz skrypt, a gest pionowy – przez wariant przełącznika pionowego. Za pomocą atrybutu touch-action
możemy określić, jakie gesty ma obsługiwać przeglądarka w przypadku tego elementu, dzięki czemu skrypt może obsługiwać gest bez konkurencji.
Poniższy kod CSS informuje przeglądarkę, że gdy gest wskaźnikiem rozpocznie się w obrębie tego toru przełącznika, należy obsługiwać gesty pionowe, a gesty poziome ignorować:
.gui-switch > input {
touch-action: pan-y;
}
Chodzi o gest poziomy, który nie powoduje też przesuwania ani przewijania strony. Wskaźnik może przewijać w pionie od miejsca w polu wprowadzania i przewijać stronę, ale przewijanie w poziomie jest obsługiwane niestandardowo.
Narzędzia stylu wartości pikseli
Podczas konfiguracji i przeciągania trzeba będzie pobrać z elementów różne obliczone wartości liczbowe. Poniższe funkcje JavaScriptu zwracają obliczone wartości pikseli
dla danej właściwości CSS. W skrypcie konfiguracji jest używany w ten sposób: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,
}
Zwróć uwagę, że funkcja window.getComputedStyle()
przyjmuje drugi argument, czyli docelowy pseudoelement. JavaScript może odczytywać wiele wartości z elementów, nawet z pseudoelementów.
dragging
To kluczowy moment w logice przeciągania. Warto zwrócić uwagę na kilka kwestii związanych z procedurą obsługi zdarzeń funkcji:
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`)
}
Bohaterem skryptu jest state.activethumb
, czyli małe kółko, które skrypt umieszcza wraz ze wskaźnikiem. Obiekt switches
to Map()
, w którym klucze to .gui-switch
, a wartości to zapisane w pamięci podręcznej granice i rozmiary, które zapewniają wydajność skryptu. Kierunek od prawej do lewej jest obsługiwany za pomocą tej samej właściwości niestandardowej, której używa CSS --isLTR
, i może jej używać do odwracania logiki i dalszego obsługiwania kierunku od prawej do lewej. Wartość event.offsetX
jest również przydatna, ponieważ zawiera wartość delta
użyteczną do pozycjonowania kciuka.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Ostatni wiersz CSS ustawia właściwość niestandardową używaną przez element miniatury. W normalnych warunkach przypisanie tej wartości zmieniałoby się z czasem, ale poprzednie zdarzenie wskaźnika tymczasowo ustawiło wartość --thumb-transition-duration
na 0s
, eliminując powolną interakcję.
dragEnd
Aby użytkownik mógł przeciągnąć element daleko poza przełącznik i puścić go, konieczne było zarejestrowanie globalnego zdarzenia okna:
window.addEventListener('pointerup', event => {
if (!state.activethumb) return
dragEnd(event)
})
Uważam, że bardzo ważne jest, aby użytkownik miał swobodę przeciągania, a interfejs był na tyle inteligentny, aby to uwzględniać. Nie wymagało to wiele pracy, ale podczas procesu tworzenia musieliśmy to dokładnie przemyśleć.
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()
}
Interakcja z elementem została zakończona. Czas ustawić właściwość checked i usunąć wszystkie zdarzenia gestów. Pole wyboru jest zmieniane na state.activethumb.checked = determineChecked()
.
determineChecked()
Ta funkcja, wywoływana przez dragEnd
, określa, gdzie znajduje się obecnie suwak w obrębie ścieżki, i zwraca wartość „true”, jeśli znajduje się w połowie ścieżki lub dalej:
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
}
Dodatkowe przemyślenia
Gest przeciągania spowodował pewne zadłużenie techniczne ze względu na początkową strukturę HTML, a zwłaszcza umieszczenie pola w etykiecie. Etykieta, jako element nadrzędny, będzie otrzymywać interakcje kliknięcia po wprowadzeniu danych. Pod koniec zdarzenia dragEnd
mogłeś/mogłaś zauważyć, że padRelease()
brzmi dziwnie jako funkcja.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
Ma to na celu uwzględnienie późniejszego kliknięcia etykiety, ponieważ spowoduje to odznaczenie lub zaznaczenie interakcji wykonanej przez użytkownika.
Gdybym miał to zrobić jeszcze raz, mógłbym rozważyć dostosowanie DOM za pomocą JavaScriptu podczas ulepszania UX, aby utworzyć element, który sam obsługuje kliknięcia etykiet i nie koliduje z wbudowanym działaniem.
Tego rodzaju JavaScriptu nie lubię pisać, nie chcę zarządzać warunkowym propagowaniem zdarzeń:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Podsumowanie
Ten malutki przełącznik okazał się najbardziej pracochłonnym elementem ze wszystkich wyzwań związanych z GUI. Teraz, gdy wiesz, jak to zrobiłem, jak Ty byś to zrobił? 🙂
Urozmaićmy nasze podejście i poznajmy wszystkie sposoby tworzenia treści w internecie. Utwórz demo, wyślij mi na Twitterze linki, a ja dodam je do sekcji remiksów społeczności poniżej.
Remiksy społeczności
- @KonstantinRouda z elementem niestandardowym: demo i code.
- @jhvanderschee z przyciskiem Codepen.
Zasoby
Znajdź .gui-switch
kod źródłowy na GitHubie.