Podstawowe informacje o tym, jak tworzyć elastyczne i dostępne komponenty przełączników.
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 kodu CSS ani JavaScriptu, aby działać w pełni i być dostępnym. Wczytywanie CSS umożliwia obsługę języków zapisywanych od prawej do lewej, animacji, pionowości 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
), wypeł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%);
}
}
Mniej animacji
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 Media Queries5:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Znacznik
Element <input type="checkbox" role="switch">
został zapakowany w element <label>
, aby utworzyć ich relację i uniknąć niejasności w powiązaniu pola wyboru i etykiety, 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">
jest wstępnie skompilowany z interfejsem API i stanem. 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 jest stylizowane jako przełącznik, ponieważ ma normalny rozmiarappearance: checkbox
i 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 jest pseudoelementem podrzędnym dołączonym do input[type="checkbox"]
i znajduje się 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 pisaniem od prawej do lewej i preferencji dotyczących animacji.
Style interakcji dotykowych
Na urządzeniach mobilnych przeglądarki dodają do etykiet i elementów wejściowych funkcje podświetlenia i wybierania tekstu. 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. Jeśli je usuniesz, pamiętaj, aby podać alternatywne wersje niestandardowe.
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łączania pochodzi z 4 właściwości niestandardowych. border: none
jest dodawany, ponieważ appearance: none
nie usuwa obramowania 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 miniatury. 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. Do dyspozycji mamy 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
ma zmienną pozycji --thumb-position
, a pseudoelement miniatury 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ć wartość --thumb-position
w CSS i pseudoklasach podanych w elementach checkbox. 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 dobrze. Element miniatury dotyczy tylko jednego stylu, pozycji translateX
. Dane wejściowe mogą zarządzać całą 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 ogólnej wysokości komponentu, co może zaburzyć układ bloku. Uwzględnij to za pomocą zmiennych --track-size
i --track-padding
. Oblicz minimalną ilość miejsca wymaganą dla przycisku pionowego, aby pasował do układu:
.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 żadnych przekształceń właściwości logicznych i może się to nigdy nie zmienić. Elad wpadł na świetny pomysł, aby użyć niestandardowej wartości właściwości w celu odwrócenia wartości procentowych i umożliwienia zarządzania pojedynczym miejscem docelowym 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 wymaga interakcji z przeglądarką, 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 motywu ciemnego i jasnego w obu stanach: wyłączonym i zaznaczonym. Wybrałem minimalne style dla tych stanów, aby ułatwić utrzymanie kombinacji stylów.
Nieokreślony
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 wyróżnia kciuk półprzezroczystym pierścieniem, gdy najedziesz na etykietę lub pole wprowadzania danych. Animacja najechania kursorem wskazuje 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 użytkownik 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 wygodne rozwiązanie. Z drugiej strony, element interfejsu może wydawać się nieaktywny, jeśli użytkownik spróbuje go przeciągnąć, a nic się nie stanie.
Kciuki, które można przeciągać
Pseudoelement miniatury otrzymuje swoją pozycję z .gui-switch > input
var(--thumb-position)
, JavaScript może podać wartość stylu w ramach wejścia, aby dynamicznie aktualizować pozycję miniatury, co sprawia, że wydaje się ona podążać za ruchem 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 podstawa rozwiązania. Zdarzenia wskaźnika warunkowo śledzą pozycje wskaźnika, aby modyfikować właściwości niestandardowe w arkuszu 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, a gesty pionowe powinny być rejestrowane w przypadku wariantu 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ę na tym przełączniku, 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 przesuwać się pionowo w obszarze wprowadzania i po stronie, ale poziome przesunięcia są obsługiwane w sposób niestandardowy.
Narzędzia do stylizacji wartości pikseli
Podczas konfigurowania i przeciągania trzeba będzie pobrać różne wartości liczbowe z elementów. Podane niżej funkcje JavaScriptu zwracają obliczony rozmiar pikseli na podstawie właściwości CSS. Jest on używany w skrypcie konfiguracyjnym w taki 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. To przypisanie wartości normalnie zmieniałoby się z upływem czasu, ale poprzednie zdarzenie wskaźnika tymczasowo ustawiło wartość --thumb-transition-duration
na 0s
, co wyeliminowałoby opóźnienie interakcji.
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 miał swobodę przeciągania, a interfejs był na tyle inteligentny, aby to uwzględnić. Ta zmiana nie wymagała wiele pracy, ale wymagała 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 zostało zmienione 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, będzie otrzymywać interakcje związane z kliknięciem po wprowadzeniu danych. Na końcu zdarzenia dragEnd
możesz zauważyć padRelease()
jako dziwnie brzmiącą funkcję.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
Ma to na celu uwzględnienie etykiety, która otrzyma to późniejsze kliknięcie, ponieważ odznaczy lub zaznaczy interakcję wykonaną przez użytkownika.
Gdybym miał to robić jeszcze raz, można rozważyć dostosowanie DOM za pomocą JavaScriptu podczas uaktualniania UX, 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 społeczności
- @KonstantinRouda z elementem niestandardowym: demo i kod.
- @jhvanderschee z przyciskiem: Codepen.
Zasoby
Znajdź .gui-switch
kod źródłowy na GitHubie.