Podstawowe informacje o tym, jak tworzyć elastyczny i dostępny komponent przełącznika.
W tym poście chcę podzielić się z Wami sposobem na tworzenie komponentów przełączników. Wypróbuj wersję demonstracyjną.
Jeśli wolisz film, oto wersja tego posta w YouTube:
Omówienie
Przełącznik działa podobnie jak pole wyboru, ale wyraźnie reprezentuje stany włączone i wyłączone.
W tym pokazie większość funkcji jest realizowana za pomocą <input type="checkbox" role="switch">
, co ma tę zaletę, że nie wymaga korzystania z CSS ani JavaScriptu, aby zapewnić pełną funkcjonalność i dostępność. Wczytywanie CSS umożliwia obsługę języków zapisywanych od prawej do lewej, animacji i wielu innych funkcji. Po załadowaniu JavaScriptu przełącznik staje się możliwy do przeciągnięcia i dotykowy.
Właściwości niestandardowe
Podane niżej zmienne reprezentują różne części przełącznika i ich opcje. Klasa najwyższego poziomu .gui-switch
zawiera właściwości niestandardowe używane w podrzędnych komponentach oraz punkty wejścia do scentralizowanej 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 zaznaczenia 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%);
}
}
Ograniczony ruch
Aby dodać czytelny alias i zmniejszyć powtarzanie, możesz umieścić w usłudze niestandardowej za pomocą wtyczki PostCSS żądanie dotyczące multimediów z uwzględnieniem preferencji użytkownika dotyczących ograniczonego ruchu na podstawie tej specyfikacji w wersji roboczej w zapytaniach dotyczących multimediów 5:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Znacznik
Element <input type="checkbox" role="switch">
został zawinięty w element <label>
, aby powiązać je ze sobą i uniknąć niejasności w powiązaniu pola wyboru z etykietą, a jednocześnie umożliwić użytkownikowi interakcję z etykietą w celu przełączania danych wejściowych.
<label for="switch" class="gui-switch">
Label text
<input type="checkbox" role="switch" id="switch">
</label>
<input type="checkbox">
ma gotowy interfejs API i stan. Przeglądarka zarządza właściwością checked
i zdarzeniami wprowadzania, takimi jak oninput
i onchanged
.
Układy
Flexbox, grid i właściwości niestandardowe są kluczowe dla zachowania stylów tego komponentu. Umożliwiają one centralizację wartości, nadawanie nazw niejednoznacznym obliczeniom lub obszarom oraz zapewniają dostęp do małego interfejsu API usługi niestandardowej, która 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 dzieci używają do obliczania swoich układów.
.gui-switch {
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
}
Rozszerzanie i modyfikowanie układu flexbox przebiega tak samo jak w przypadku każdego innego układu flexbox.
Aby na przykład umieścić etykiety nad lub pod przełącznikiem albo zmienić ich 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 ma styl ścieżki przełącznika – w tym przypadku usunięto normalny element appearance: checkbox
i podano zamiast niego własny rozmiar:
.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;
}
Utwór tworzy też siatkę z pojedynczych komórek, w której można umieścić miniaturę do zgłoszenia.
Miniatura
Styl appearance: none
usuwa też znacznik wyboru dostarczany przez przeglądarkę. Ten komponent używa pseudoelementu i pseudoklasy :checked
w danych wejściowych, aby zastąpić ten wizualny wskaźnik.
Miniatura to pseudoelement podrzędny dołączony do input[type="checkbox"]
, który jest umieszczany na wierzchu ścieżki zamiast pod nią, zajmując obszar siatki:track
.gui-switch > input::before {
content: "";
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
}
Style
Właściwości niestandardowe umożliwiają wszechstronny komponent przełącznika, który dostosowuje się do schematów kolorów, języków z pisemiem od prawej do lewej i preferencji dotyczących ruchu.
Style interakcji dotykowe
Na urządzeniach mobilnych przeglądarki dodają podświetlenia dotykiem i funkcje zaznaczania tekstu do etykiet i wprowadzanych danych. Te problemy negatywnie wpływały na styl i wizualne interakcje, które były potrzebne do przeprowadzenia tej zmiany. Za pomocą kilku linii kodu CSS mogę usunąć te efekty i dodać własny styl cursor: pointer
:
.gui-switch {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
Nie zawsze warto usuwać te style, ponieważ mogą one stanowić cenny wizualny element interakcji. Pamiętaj, aby w przypadku ich usunięcia udostępnić niestandardowe alternatywne wersje.
Monitoruj
Style tego elementu dotyczą głównie jego kształtu i koloru, do których uzyskuje 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 pochodzi z 4 właściwości niestandardowych. Dodano border: none
, ponieważ appearance: none
nie usuwa obramowań z pola wyboru we wszystkich przeglądarkach.
Miniatura
Element miniatury znajduje się już po prawej stronie track
, ale wymaga stylu koła:
.gui-switch > input::before {
background: var(--thumb-color);
border-radius: 50%;
}
Interakcja
Użyj właściwości niestandardowych, aby przygotować się na interakcje, które będą wyświetlać podświetlenia podczas najechania kursorem i zmiany pozycji palca. Przed przejściem na styl animacji lub podświetlenia najechania uwzględnia się 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łasne właściwości stanowią mechanizm pojedynczego źródła do pozycjonowania miniatury na ścieżce. Mamy do dyspozycji rozmiary ścieżki i miniatury, których użyjemy w obliczeniach, aby miniatura była odpowiednio przesunięta i znajdowała się w obrębie ścieżki:
0%
i 100%
.
Element input
jest właścicielem zmiennej pozycji --thumb-position
, a pseudoelement kciuka używa jej jako pozycji translateX
:
.gui-switch > input {
--thumb-position: 0%;
}
.gui-switch > input::before {
transform: translateX(var(--thumb-position));
}
Teraz możesz swobodnie zmieniać --thumb-position
z CSS i pseudoklas podanych w elementach pól wyboru. Ponieważ wcześniej ustawiliśmy warunek transition: transform
var(--thumb-transition-duration) ease
dla tego elementu, zmiany te 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)
);
}
Myślałem, że ta dezagregowana aranżacja działała dobrze. Element kciuka dotyczy tylko 1 stylu: pozycji translateX
. Dane wejściowe umożliwiają zarządzanie
różną złożonością i obliczeniami.
Branża
Obsługa została zrealizowana za pomocą klasy modyfikatora -vertical
, która dodaje do elementu input
rotację za pomocą przekształceń CSS.
Obrócenie elementu 3D nie zmienia jednak całkowitej wysokości komponentu, co może zaburzyć układ bloku. Uwzględnij to, używając zmiennych --track-size
i --track-padding
. Oblicz minimalną ilość miejsca wymaganą do tego, aby przycisk pionowy działał w układzie zgodnie z oczekiwaniami:
.gui-switch.-vertical {
min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));
& > input {
transform: rotate(-90deg);
}
}
(RTL) od prawej do lewej
Razem z moim znajomym Eladem Schecterem stworzyliśmy prototyp wysuwnego menu bocznego z użyciem przekształceń CSS, które obsługują języki zapisywane od prawej do lewej, przez odwrócenie jednej zmiennej. Zrobiliśmy to, ponieważ w CSS nie ma logicznych przekształceń właściwości i mogą nigdy nie wystąpić. Elad wpadł na świetny pomysł, aby za pomocą niestandardowej wartości właściwości odwrócić wartości procentowe, co umożliwiłoby zarządzanie pojedynczą lokalizacją za pomocą naszej niestandardowej logiki do transformacji logicznych. Użyłem tej samej techniki w tym przypadku i myślę, że bardzo dobrze się sprawdziła:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Właściwość niestandardowa o nazwie --isLTR
początkowo zawiera wartość 1
, co oznacza, że jest to
true
, ponieważ nasz układ jest domyślnie zorientowany poziomo. Następnie za pomocą pseudoklasy CSS :dir()
wartość jest ustawiana na -1
, gdy komponent znajduje się w układzie od prawej do lewej.
Użyj funkcji --isLTR
, używając jej w ramach funkcji calc()
w transformacji:
.gui-switch.-vertical > input {
transform: rotate(-90deg);
transform: rotate(calc(90deg * var(--isLTR) * -1));
}
Teraz obrót pionowy przełącznika na pozycję po przeciwnej stronie wymaganą przez układ od prawej do lewej.
Transformacje translateX
w pseudoelemencie miniatury też trzeba zaktualizować, 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)
);
}
Chociaż to podejście nie rozwiąże wszystkich problemów związanych z koncepcją, taką jak transformacje logiczne w CSS, to w wielu przypadkach umożliwia stosowanie zasad DRY.
Stany
Korzystanie z wbudowanego input[type="checkbox"]
nie byłoby kompletne bez obsługi różnych stanów, w których może się on znajdować: :checked
, :disabled
, :indeterminate
i :hover
. :focus
został celowo pozostawiony bez zmian, z jedynie skorygowanym przesunięciem. Pierścień ostrości wyglądał świetnie w 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 kolor tła „ścieżki” wejścia jest ustawiony na aktywny kolor, a pozycja kursora 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ż sprawia, że element jest niezmienny. Niezmienność interakcji nie zależy od przeglądarki, 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 trudny, ponieważ wymaga motywów ciemnego i jasnego w obu stanach: wyłączonym i zaznaczonym. Stylistycznie wybrałam minimalne style dla tych stanów, aby zmniejszyć obciążenie pracą różnych stylów.
Nieokreślona
Często zapominanym stanem jest :indeterminate
, w którym pole wyboru nie jest ani zaznaczone, ani odznaczone. To stan zabawy, jest zachęcający i skromny. Pamiętaj, że stany logiczne mogą mieć stany pośrednie.
Ustawienie pola wyboru jako nieokreślonego jest trudne, ponieważ może to zrobić tylko JavaScript:
<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 jest dla mnie skromny i zachęcający, więc uznałem, że pozycja przełącznika w środku jest odpowiednia:
.gui-switch > input:indeterminate {
--thumb-position: calc(
calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
* var(--isLTR)
);
}
Najechanie
Interakcje z kursorem powinny zapewniać wizualne wsparcie dla połączonego interfejsu użytkownika, a także wskazywać kierunek interakcji z interfejsem interaktywnym. Ten przełącznik podświetla kciuk półprzezroczystym pierścieniem po najechaniu na etykietę lub dane wejściowe. Animacja najechania wskazuje na interaktywny element miniatury.
Efekt „podświetlenia” jest realizowany za pomocą funkcji box-shadow
. Po najechaniu kursorem na niewyłączone pole wejściowe zwiększ rozmiar --highlight-size
. Jeśli użytkownik zgadza się na ruch, przechodzimy do box-shadow
i obserwujemy jego wzrost. Jeśli nie zgadza się na ruch, 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
W moim odczuciu przełącznik może być niepokojący, ponieważ próbuje emulować interfejs fizyczny, zwłaszcza ten z okręgiem wewnątrz ścieżki. iOS dobrze sobie z tym poradził, umożliwiając przeciąganie przełącznika na boki. To bardzo przydatna opcja. Z drugiej strony, element interfejsu może wydawać się nieaktywny, jeśli użytkownik spróbuje go przeciągnąć, a nic się nie stanie.
Przeciągane kciuki
Pseudoelement kciuka otrzymuje swoją pozycję z var(--thumb-position)
o zakresie .gui-switch > input
. JavaScript może dostarczyć wbudowaną wartość stylu w danych wejściowych, aby dynamicznie aktualizować pozycję kciuka, przez co wydaje się, że podąża on za gestem wskaźnika. Po zwolnieniu wskaźnika usuń style wbudowane i za pomocą właściwości niestandardowej --thumb-position
określ, czy przeciąganie było bliżej pozycji wyłączonej czy włączonej. Jest to szkielet rozwiązania: zdarzenia wskaźnika warunkowo śledzą pozycje wskaźników w celu modyfikacji właściwości niestandardowych CSS.
Ponieważ komponent działał już w 100% przed wyświetleniem tego skryptu, wymaga to sporo pracy, aby zachować dotychczasowe działanie, np. klikania etykiety w celu przełączania danych wejściowych. Kod JavaScript nie powinien dodawać funkcji kosztem dotychczasowych funkcji.
touch-action
Przeciąganie to gest, który jest niestandardowy, co czyni go idealnym kandydatem na funkcję touch-action
. W przypadku tego przełącznika gest poziomy powinien być obsługiwany przez nasz skrypt lub gest pionowy – w przypadku opcji przełącznika pionowego. Za pomocą touch-action
możemy powiedzieć przeglądarce, jakie gesty mają być obsługiwane w tym elemencie, aby skrypt mógł obsługiwać gest bez konkurencji.
Ten kod CSS instruuje przeglądarkę, że gdy gest wskaźnika rozpoczyna się w ramach tego przełącznika, należy obsłużyć gesty pionowe, a nie poziome:
.gui-switch > input {
touch-action: pan-y;
}
Pożądanym efektem jest gest poziomy, który nie powoduje przesuwania ani przewijania strony. Wskaźnik może przewijać pionowo od początku w ramach pola wejściowego i przewijać stronę, ale poziome przewijanie jest obsługiwane w sposób niestandardowy.
Narzędzia związane ze stylem wartości Pixela
Podczas konfiguracji i przeciągania z elementów trzeba pobrać różne obliczone wartości liczbowe. Podane niżej funkcje JavaScriptu zwracają obliczony rozmiar pikseli na podstawie właściwości CSS. Jest używany w skrypcie konfiguracji 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. Fajnie, że JavaScript może odczytać tak wiele wartości z elementów, nawet z elementów pseudo.
dragging
Jest to kluczowy moment w logice przeciągania. W modułu obsługi zdarzeń funkcji należy zwrócić uwagę na kilka kwestii:
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`)
}
Głównym elementem skryptu jest state.activethumb
, czyli mały okrąg, który skrypt umieszcza wraz z wskaźnikiem. Obiekt switches
to Map()
, gdzie klucze to .gui-switch
, a wartości to zapisane w pamięci podręcznej granice i rozmiary, które zapewniają wydajność skryptu. Obsługa tekstu od prawej do lewej jest realizowana za pomocą tej samej właściwości niestandardowej, która jest używana w CSS (--isLTR
), i może służyć do odwrócenia logiki i dalszego obsługiwania tekstu w układzie poziomym. Wartość event.offsetX
jest również przydatna, ponieważ zawiera wartość delta, która jest przydatna do pozycjonowania palca.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Ten ostatni wiersz kodu CSS ustawia właściwość niestandardową używaną przez element miniatury. W przeciwnym razie to przypisanie wartości mogłoby zostać przeniesione z czasem, ale poprzednie zdarzenie wskaźnika tymczasowo ustawiło --thumb-transition-duration
na 0s
, usuwając to, co byłoby powolną interakcją.
dragEnd
Aby umożliwić użytkownikowi przeciąganie poza przełącznik i puszczenie, należy zarejestrować globalne zdarzenie okna:
window.addEventListener('pointerup', event => {
if (!state.activethumb) return
dragEnd(event)
})
Uważam, że bardzo ważne jest, aby użytkownik mógł swobodnie przeciągać i interfejs był na tyle inteligentny, by to uwzględnić. Nie wymagało to wiele pracy, ale wymagało dokładnego przeanalizowania podczas procesu tworzenia.
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, więc czas ustawić właściwość inputChecked i usunąć wszystkie zdarzenia gestów. Pole wyboru zmieni się na state.activethumb.checked = determineChecked()
.
determineChecked()
Ta funkcja, wywoływana przez dragEnd
, określa, gdzie znajduje się aktualny palec wskazujący w obrębie granic ścieżki, i zwraca wartość true, jeśli palec znajduje się na 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 uwagi
Gest przeciągania spowodował powstanie pewnego zadłużenia kodu ze względu na wybraną początkową strukturę HTML, głównie z powodu owijania danych wejściowych w etykiecie. Etykieta będąca elementem nadrzędnym
otrzymuje po danych wejściowych interakcje związane z kliknięciami. Na końcu zdarzenia dragEnd
udało Ci się zauważyć funkcję padRelease()
jako funkcję, która brzmi dziwnie.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
Ma to na celu uwzględnienie przyczyny późniejszego kliknięcia etykiety, ponieważ spowoduje to odznaczenie lub sprawdzenie interakcji użytkownika.
Gdybym miał to robić jeszcze raz, można rozważyć dostosowanie DOM za pomocą JavaScriptu podczas uaktualniania interfejsu użytkownika, aby utworzyć element, który sam obsługuje kliknięcia etykiety i nie koliduje z wbudowanym działaniem.
Ten rodzaj kodu JavaScript jest najmniej lubianym przeze mnie, ponieważ nie chcę zarządzać warunkowym przenoszeniem zdarzeń:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Podsumowanie
Ten mały komponent przełącznika okazał się najbardziej czasochłonnym ze wszystkich dotychczasowych wyzwań związanych z GUI. Teraz, gdy już wiesz, jak to zrobić, jak Ty to zrobisz? 🙂
Zróżnicujemy nasze podejścia i poznamy wszystkie sposoby tworzenia stron internetowych. Utwórz wersję demonstracyjną, wyślij mi linki, a ja dodam je do sekcji z remiksami społeczności.
Remiksy utworzone przez społeczność
- @KonstantinRouda z elementem niestandardowym: demo i kod.
- @jhvanderschee z przyciskiem: Codepen.
Zasoby
Znajdź .gui-switch
kod źródłowy na GitHubie.