Ringkasan dasar tentang cara membangun komponen {i>switch<i} yang responsif dan dapat diakses.
Dalam postingan ini saya ingin berbagi pemikiran tentang cara membangun komponen {i>switch<i}. Coba demo.
Jika Anda lebih suka menonton video, berikut versi YouTube untuk postingan ini:
Ringkasan
Fungsi switch mirip dengan kotak centang tetapi secara eksplisit mewakili status aktif dan nonaktif boolean.
Demo ini menggunakan <input type="checkbox" role="switch">
untuk sebagian besar
fungsionalitasnya, yang memiliki keuntungan karena tidak memerlukan CSS atau JavaScript untuk
berfungsi penuh dan mudah diakses. Pemuatan CSS memberikan dukungan untuk file dari kanan-ke-kiri
bahasa, verticalitas, animasi, dan banyak lagi. Pemuatan JavaScript beralih
dapat ditarik dan berwujud.
Properti kustom
Variabel berikut mewakili berbagai bagian {i>switch<i} dan
lainnya. Sebagai class tingkat atas, .gui-switch
berisi properti khusus yang digunakan
ke seluruh turunan komponen, dan titik entri untuk sistem
dan penyesuaian.
Lacak
Panjang (--track-size
), padding, dan dua warna:
.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%);
}
}
Kalimba
Ukuran, warna latar belakang, dan warna sorotan interaksi:
.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%);
}
}
Pengurangan gerakan
Untuk menambahkan alias yang jelas dan mengurangi pengulangan, pengguna yang memiliki preferensi gerakan yang lebih rendah kueri media dapat dimasukkan ke properti kustom dengan antarmuka PostCSS plugin berdasarkan draf ini spesifikasi di Kueri Media 5:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Markup
Saya memilih untuk menggabungkan elemen <input type="checkbox" role="switch">
dengan
<label>
, memaketkan hubungannya untuk menghindari pengaitan kotak centang dan label
ambiguitas, sekaligus memberikan pengguna
kemampuan untuk berinteraksi dengan label
aktifkan/nonaktifkan input.
<label for="switch" class="gui-switch">
Label text
<input type="checkbox" role="switch" id="switch">
</label>
<input type="checkbox">
telah dilengkapi dengan
API
dan state. Tujuan
browser mengelola
checked
properti dan input
peristiwa
seperti oninput
dan onchanged
.
Tata letak
Flexbox, grid, dan custom properti sangat penting dalam mempertahankan gaya komponen ini. Mereka memusatkan nilai-nilai, memberikan nama untuk penghitungan atau area yang ambigu, dan memungkinkan properti kustom berukuran kecil API untuk penyesuaian komponen yang mudah.
.gui-switch
Tata letak tingkat atas untuk tombol ini adalah flexbox. Class .gui-switch
berisi
properti khusus pribadi dan publik yang digunakan anak-anak untuk menghitung
tata letak.
.gui-switch {
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
}
Memperluas dan memodifikasi tata letak flexbox seperti mengubah tata letak flexbox apa pun.
Misalnya, untuk menempatkan label di atas atau di bawah tombol, atau untuk mengubah
flex-direction
:
<label for="light-switch" class="gui-switch" style="flex-direction: column">
Default
<input type="checkbox" role="switch" id="light-switch">
</label>
Lacak
Gaya input kotak centang ditata sebagai jalur peralihan dengan menghapus
appearance: checkbox
dan memberikan ukurannya sendiri:
.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;
}
Trek ini juga membuat area pelacakan kisi sel satu per satu untuk ibu jari klaim.
Kalimba
Gaya appearance: none
juga menghapus tanda centang visual yang disediakan oleh
browser. Komponen ini menggunakan sebuah
elemen semu dan :checked
pseudo-class pada input untuk
ganti indikator visual ini.
Jempol adalah turunan elemen pseudo yang dilampirkan ke input[type="checkbox"]
dan
tumpukan di atas trek, bukan di bawahnya dengan mengklaim area grid
track
:
.gui-switch > input::before {
content: "";
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
}
Gaya
Properti kustom memungkinkan komponen tombol akses serbaguna yang beradaptasi dengan warna skema, bahasa kanan-ke-kiri dan preferensi {i>motion<i}.
Gaya interaksi sentuh
Di perangkat seluler, {i>browser<i} menambahkan sorotan ketuk dan fitur pemilihan teks ke label dan
input. Ini berdampak negatif pada umpan balik gaya dan interaksi visual yang
{i>switch<i} ini diperlukan. Dengan beberapa baris CSS saya bisa
menghapus efek itu dan menambahkan
gaya cursor: pointer
saya sendiri:
.gui-switch {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
Tidak selalu disarankan untuk menghapus gaya tersebut, karena dapat menjadi aset visual yang berharga masukan interaksi. Pastikan untuk memberikan alternatif khusus jika Anda menghapusnya.
Lacak
Gaya elemen ini sebagian besar tentang bentuk dan warnanya, yang diaksesnya
dari induk .gui-switch
melalui
jenjang.
.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);
}
Berbagai macam pilihan penyesuaian untuk {i>switch track<i} berasal dari empat
properti khusus. border: none
ditambahkan karena appearance: none
tidak
hapus batas dari kotak centang di semua browser.
Kalimba
Elemen thumb sudah berada di track
yang tepat, tetapi memerlukan gaya lingkaran:
.gui-switch > input::before {
background: var(--thumb-color);
border-radius: 50%;
}
Interaksi
Menggunakan properti khusus untuk mempersiapkan interaksi yang akan menampilkan kursor sorotan dan perubahan posisi jempol. Pilihan pengguna juga diperiksa sebelum mentransisikan gaya sorotan {i>motion <i} atau pengarahan kursor.
.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;
}}
}
Posisi jempol
Properti kustom menyediakan mekanisme sumber tunggal untuk memosisikan ibu jari di
trek tersebut. Yang kami miliki adalah ukuran trek dan jempol
yang akan kami gunakan di
perhitungan untuk menjaga ibu jari tetap di-offset dengan benar dan di antara trek:
0%
dan 100%
.
Elemen input
memiliki variabel posisi --thumb-position
, dan ibu jari
elemen pseudo menggunakannya sebagai posisi translateX
:
.gui-switch > input {
--thumb-position: 0%;
}
.gui-switch > input::before {
transform: translateX(var(--thumb-position));
}
Sekarang kita bebas mengubah --thumb-position
dari CSS dan pseudo-class
yang tersedia pada elemen kotak centang. Karena kita secara kondisional menetapkan transition: transform
var(--thumb-transition-duration) ease
sebelumnya pada elemen ini, perubahan ini
dapat beranimasi saat diubah:
/* 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)
);
}
Saya pikir orkestrasi yang terpisah ini berhasil dengan baik. Elemen thumb adalah
hanya terkait dengan satu gaya, posisi translateX
. Input dapat mengelola semua
kompleksitas dan perhitungannya.
Vertikal
Dukungan dilakukan dengan class pengubah -vertical
yang menambahkan rotasi dengan
CSS bertransformasi ke elemen input
.
Elemen yang diputar 3D tidak mengubah tinggi keseluruhan komponen,
yang bisa menggagalkan
tata letak blok. Akun untuk ini menggunakan --track-size
dan
Variabel --track-padding
. Hitung jumlah ruang minimum yang diperlukan untuk
tombol vertikal agar mengalir dalam tata letak seperti yang diharapkan:
.gui-switch.-vertical {
min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));
& > input {
transform: rotate(-90deg);
}
}
(RTL) kanan-ke-kiri
Seorang teman CSS, Elad Schecter, dan saya membuat prototipe bersama-sama geser menu samping menggunakan transformasi CSS yang ditangani dari kanan-ke-kiri bahasa dengan membalik satu variabel. Kita melakukannya karena tidak ada transformasi properti logis di CSS, dan mungkin tidak akan pernah ada. Elad memiliki ide cemerlang untuk menggunakan nilai properti kustom untuk membalik persentase, untuk memungkinkan pengelolaan lokasi tunggal dari logika untuk transformasi logis. Saya menggunakan teknik yang sama di {i>switch<i} ini dan saya berpikir itu berhasil dengan baik:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Properti khusus yang disebut --isLTR
awalnya memiliki nilai 1
, yang berarti
true
karena tata letak dari kiri ke kanan secara default. Lalu, menggunakan CSS
class semu :dir()
,
nilainya disetel ke -1
jika komponen berada dalam tata letak kanan-ke-kiri.
Terapkan --isLTR
dengan menggunakannya dalam calc()
di dalam transformasi:
.gui-switch.-vertical > input {
transform: rotate(-90deg);
transform: rotate(calc(90deg * var(--isLTR) * -1));
}
Sekarang, rotasi {i>switch<i} vertikal memperhitungkan posisi sisi yang berlawanan yang diperlukan oleh tata letak kanan-ke-kiri.
Transformasi translateX
pada elemen semu thumb juga perlu diupdate ke
memperhitungkan persyaratan sisi yang berlawanan:
.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)
);
}
Meskipun pendekatan ini tidak akan berhasil untuk menyelesaikan semua kebutuhan mengenai konsep seperti CSS yang logis bertransformasi, model ini memang menawarkan Prinsip DRY bagi banyak orang kasus penggunaan.
Negara bagian
Menggunakan input[type="checkbox"]
bawaan tidak akan lengkap tanpa
menangani berbagai status yang ada di dalamnya: :checked
, :disabled
,
:indeterminate
dan :hover
. :focus
sengaja dibiarkan sendirian, dengan
penyesuaian hanya dilakukan pada offsetnya; lingkaran fokus tampak
bagus di Firefox dan
Safari:
Dicentang
<label for="switch-checked" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>
Status ini mewakili status on
. Dalam status ini, input "{i>track<i}"
latar belakang disetel ke warna aktif dan posisi ibu jari disetel ke "
"akhir".
.gui-switch > input:checked {
background: var(--track-color-active);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
Nonaktif
<label for="switch-disabled" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>
Tombol :disabled
tidak hanya terlihat berbeda secara visual, tetapi juga harus membuat
elemen immutable.Imutabilitas interaksi bebas dari browser, tetapi
status visual memerlukan gaya karena penggunaan 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%);
}}
}
}
Keadaan ini rumit karena memerlukan tema gelap dan terang dengan status yang dicentang. Saya memilih gaya minimal untuk status ini agar mudah beban pemeliharaan kombinasi gaya.
Tidak tentu
Status yang sering terlupakan adalah :indeterminate
, dengan kotak centang tidak
dicentang atau tidak dicentang. Kondisi ini menyenangkan, mengundang dan tidak terlalu terang-terangan. Bagus
pengingat bahwa status boolean bisa tersembunyi di antara status.
Sangat sulit untuk menetapkan kotak centang ke angka yang tidak tentu, hanya JavaScript yang dapat menyetelnya:
<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>
Karena negara bagian, bagi saya, sederhana dan mengundang, rasanya tepat untuk menempatkan posisi tombol {i>switch<i} di tengah:
.gui-switch > input:indeterminate {
--thumb-position: calc(
calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
* var(--isLTR)
);
}
Arahkan kursor
Interaksi pengarahan kursor harus memberikan dukungan visual untuk UI yang terhubung dan juga memberikan arahan terhadap UI interaktif. Tombol ini menyoroti ibu jari dengan cincin semi-transparan saat label atau input diarahkan ke kursor. Pengarahan kursor ini kemudian memberikan arah ke elemen thumb interaktif.
"Sorotan" Efek dilakukan dengan box-shadow
. Saat mengarahkan kursor, dari input yang tidak dinonaktifkan, tingkatkan ukuran --highlight-size
. Jika pengguna tidak keberatan dengan gerakan, kita akan mentransisikan box-shadow
dan melihatnya berkembang, jika mereka tidak cocok dengan gerakan, sorotan akan langsung muncul:
.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
Bagi saya, antarmuka {i>switch<i} bisa terasa aneh saat mengemulasikan antarmuka khusus, terutama dengan lingkaran di dalam lintasan. iOS benar dengan {i>switch<i} mereka, Anda dapat menariknya dari sisi ke sisi, dan sangat memuaskan untuk memiliki opsi tersebut. Sebaliknya, elemen UI bisa terasa tidak aktif jika {i>drag gestur<i} sudah dicoba dan tidak terjadi apa-apa.
Jempol yang dapat ditarik
Elemen semu thumb menerima posisinya dari .gui-switch > input
dengan cakupan var(--thumb-position)
, JavaScript dapat menyediakan nilai gaya inline pada
input untuk memperbarui posisi ibu jari secara dinamis sehingga tampak mengikuti
gestur pointer. Saat pointer dilepaskan, hapus gaya inline dan
menentukan apakah penyeretan lebih dekat ke nonaktif atau aktif dengan menggunakan properti khusus
--thumb-position
. Ini adalah inti dari solusinya; peristiwa pointer
melacak posisi pointer secara bersyarat untuk mengubah properti khusus CSS.
Karena komponen telah berfungsi 100% sebelum skrip ini ditampilkan dibutuhkan banyak upaya untuk mempertahankan perilaku yang ada, seperti mengeklik label untuk beralih input. JavaScript tidak boleh menambahkan fitur pada mengorbankan fitur yang ada.
touch-action
{i>Drag <i}(menyeret) adalah sebuah {i>gesture<i},
yang merupakan salah satu gestur yang tepat untuk
Manfaat touch-action
. Dalam kasus {i>switch<i} ini,
{i>gesture <i}horizontal harus
ditangani oleh skrip, atau gestur vertikal yang ditangkap untuk tombol vertikal
yang berbeda. Dengan touch-action
, kita dapat memberi tahu browser tentang gestur yang harus ditangani
elemen ini, sehingga skrip dapat menangani {i>gesture <i}tanpa kompetisi.
CSS berikut memberi tahu browser bahwa saat gestur pointer dimulai dari dalam jalur peralihan ini, menangani gestur vertikal, tidak melakukan apa pun dengan satu:
.gui-switch > input {
touch-action: pan-y;
}
Hasil yang diinginkan adalah gestur horizontal yang juga tidak menggeser atau men-scroll kami. Pointer bisa melakukan scroll secara vertikal mulai dari dalam input lalu men-scroll halaman, tetapi yang horizontal ditangani secara khusus.
Utilitas gaya nilai piksel
Saat penyiapan dan selama tarik, berbagai nilai angka yang dihitung perlu diambil
dari elemen. Fungsi JavaScript berikut menampilkan nilai piksel yang dihitung
diberi properti CSS. Ini digunakan dalam
skrip pengaturan seperti ini
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,
}
Perhatikan cara window.getComputedStyle()
menerima argumen kedua, yaitu elemen pseudo target. Cukup rapi bahwa JavaScript dapat membaca begitu banyak nilai dari elemen, bahkan dari elemen semu.
dragging
Ini adalah momen inti untuk logika {i>drag<i} dan ada beberapa hal yang perlu diperhatikan dari pengendali peristiwa fungsi:
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`)
}
Banner besar skrip adalah state.activethumb
, lingkaran kecil tempat skrip ini berada
pemosisian bersama dengan pointer. Objek switches
adalah Map()
dengan
adalah milik .gui-switch
dan nilainya di-cache dalam batas dan ukuran yang menjaga
dan membuat skrip
secara efisien. Kanan-ke-kiri ditangani menggunakan properti khusus yang sama
CSS tersebut adalah --isLTR
, dan dapat menggunakannya untuk membalikkan logika dan melanjutkan
mendukung RTL. event.offsetX
juga berharga, karena berisi delta
untuk memosisikan ibu jari.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Baris terakhir CSS ini menetapkan properti khusus yang digunakan oleh elemen jempol. Ini
penetapan nilai akan bertransisi dari waktu ke waktu, tetapi petunjuk sebelumnya
peristiwa telah menetapkan --thumb-transition-duration
ke 0s
untuk sementara, sehingga menghapus
adalah interaksi yang lambat.
dragEnd
Agar pengguna diizinkan untuk menyeret jauh ke luar {i>switch<i} dan melepaskannya, sebuah peristiwa jendela global yang diperlukan terdaftar:
window.addEventListener('pointerup', event => {
if (!state.activethumb) return
dragEnd(event)
})
Menurut saya, sangat penting bagi pengguna untuk memiliki kebebasan untuk menarik dengan longgar dan memiliki antarmuka yang cukup cerdas untuk memperhitungkannya. Tidak perlu banyak waktu dengan peralihan ini, tetapi perlu pertimbangan yang cermat selama pengembangan {i>checkout<i}.
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()
}
Interaksi dengan elemen telah selesai, waktu untuk menyetel input telah diperiksa
dan menghapus semua peristiwa gestur. Kotak centang diubah dengan
state.activethumb.checked = determineChecked()
.
determineChecked()
Fungsi ini, yang dipanggil oleh dragEnd
, menentukan lokasi thumb saat ini
dalam batas jalurnya dan mengembalikan nilai benar jika sama dengan atau lebih
setengah dari lintasan:
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
}
Masukan tambahan
Gerakan {i>drag<i} menimbulkan sedikit biaya pada kode karena pada struktur HTML awal
dipilih, terutama menggabungkan input dalam label. Label, menjadi orang tua
akan menerima interaksi klik setelah input. Pada akhirnya,
Peristiwa dragEnd
, Anda mungkin menyadari padRelease()
sebagai kata-kata yang aneh
.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
Hal ini untuk memperhitungkan label yang mendapatkan klik nanti, karena akan menghapus centang, atau memeriksa interaksi yang dilakukan pengguna.
Jika harus melakukannya lagi, saya mungkin mempertimbangkan untuk menyesuaikan DOM dengan JavaScript selama upgrade UX, untuk membuat elemen yang menangani klik label itu sendiri dan tidak bertentangan dengan perilaku bawaan.
Jenis JavaScript ini paling tidak saya sukai untuk ditulis, saya tidak ingin mengelola gelembung kejadian bersyarat:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Kesimpulan
Komponen {i>switch<i} kecil ini akhirnya menjadi pekerjaan yang paling banyak dari semua Tantangan GUI sejauh ini! Sekarang setelah Anda tahu bagaimana saya melakukannya, bagaimana Anda akan 🙂
Mari kita diversifikasi pendekatan kami dan mempelajari semua cara untuk membangun di web. Buat demo, link tweet saya, dan saya akan menambahkannya ke bagian remix komunitas di bawah ini.
Remix komunitas
- @KonstantinRouda dengan elemen kustom: demo dan code.
- @jhvanderschee dengan tombol: Codepen.
Resource
Temukan kode sumber .gui-switch
di
GitHub.