Gambaran dasar tentang cara membangun komponen {i>switch<i} yang responsif dan mudah diakses.
Dalam posting ini saya ingin berbagi pemikiran tentang cara membangun komponen {i>switch<i}. Coba demonya.
Jika Anda lebih suka video, berikut versi YouTube 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 fungsinya, yang memiliki keunggulan karena tidak memerlukan CSS atau JavaScript untuk berfungsi sepenuhnya dan dapat diakses. Pemuatan CSS memberikan dukungan untuk bahasa
yang ditulis dari kanan ke kiri, vertikal, animasi, dan lainnya. Memuat JavaScript membuat tombol
dapat ditarik dan nyata.
Properti khusus
Variabel berikut mewakili berbagai bagian tombol dan opsinya. Sebagai class tingkat atas, .gui-switch
berisi properti kustom yang digunakan
di seluruh turunan komponen, dan titik entri untuk penyesuaian
terpusat.
Lagu
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%);
}
}
Gerakan yang dikurangi
Untuk menambahkan alias yang jelas dan mengurangi pengulangan, kueri media pengguna dengan preferensi gerakan yang lebih sedikit dapat dimasukkan ke dalam properti kustom dengan plugin PostCSS berdasarkan spesifikasi draf di Kueri Media 5:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Markup
Saya memilih untuk menggabungkan elemen <input type="checkbox" role="switch">
saya dengan <label>
, yang memaketkan hubungan keduanya untuk menghindari ambiguitas kotak centang dan pengaitan label, sekaligus memberi pengguna kemampuan untuk berinteraksi dengan label untuk mengalihkan input.
<label for="switch" class="gui-switch">
Label text
<input type="checkbox" role="switch" id="switch">
</label>
<input type="checkbox">
sudah dilengkapi dengan
API
dan state. Browser
mengelola
properti checked
dan peristiwa
input
seperti oninput
dan onchanged
.
Tata letak
Flexbox, petak, dan properti kustom sangat penting dalam mempertahankan gaya komponen ini. Fungsi ini memusatkan nilai, memberikan nama ke penghitungan atau area yang ambigu, dan mengaktifkan API properti kustom berukuran kecil untuk penyesuaian komponen yang mudah.
.gui-switch
Tata letak tingkat atas untuk tombol adalah flexbox. Class .gui-switch
berisi
properti khusus pribadi dan publik yang digunakan turunan 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 meletakkan 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>
Lagu
Input kotak centang ditata sebagai jalur switch dengan menghapus appearance: checkbox
normalnya
dan menyediakan ukurannya sendiri sebagai gantinya:
.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;
}
Jalur ini juga membuat area jalur petak sel satu per satu untuk diklaim oleh thumb.
Kalimba
appearance: none
gaya juga menghapus tanda centang visual yang disediakan oleh
browser. Komponen ini menggunakan
elemen semu dan
class semu :checked
pada input untuk
mengganti indikator visual ini.
thumb adalah turunan elemen pseudo yang melekat pada input[type="checkbox"]
dan
menumpuk 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 khusus memungkinkan komponen tombol akses serbaguna yang dapat beradaptasi dengan skema warna, bahasa kanan-ke-kiri, dan preferensi gerakan.
Gaya interaksi sentuh
Di perangkat seluler, browser menambahkan sorotan ketuk dan fitur pemilihan teks ke label dan
input. Perubahan ini berdampak negatif pada masukan gaya dan interaksi visual yang
diperlukan tombol ini. Dengan beberapa baris CSS, saya dapat menghapus efek tersebut 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 masukan interaksi visual yang berharga. Pastikan untuk memberikan alternatif kustom jika Anda menghapusnya.
Lagu
Gaya elemen ini sebagian besar terkait dengan bentuk dan warnanya, yang diakses
dari .gui-switch
induk 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 opsi penyesuaian untuk jalur switch berasal dari empat properti khusus. border: none
ditambahkan karena appearance: none
tidak
menghapus batas dari kotak centang di semua browser.
Kalimba
Elemen thumb sudah ada di track
yang tepat, tetapi memerlukan gaya lingkaran:
.gui-switch > input::before {
background: var(--thumb-color);
border-radius: 50%;
}
Interaksi
Gunakan properti kustom untuk mempersiapkan interaksi yang akan menampilkan sorotan pengarahan kursor dan perubahan posisi ibu jari. Preferensi pengguna juga akan diperiksa sebelum mentransisikan gaya sorotan gerakan 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 ibu jari
Properti kustom menyediakan mekanisme sumber tunggal untuk memosisikan thumb di
jalur. Kami menyediakan ukuran track dan thumb yang akan kita gunakan dalam
perhitungan untuk mempertahankan thumb offset dengan benar di antara dalam trek:
0%
dan 100%
.
Elemen input
memiliki variabel posisi --thumb-position
, dan elemen pseudo thumb 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 disediakan pada elemen kotak centang. Karena kita menetapkan transition: transform
var(--thumb-transition-duration) ease
lebih awal pada elemen ini secara kondisional, perubahan ini
dapat bergerak 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)
);
}
Menurut saya, orkestrasi terpisah ini berjalan dengan baik. Elemen thumb hanya berkaitan dengan satu gaya, yaitu posisi translateX
. Input dapat mengelola semua
kompleksitas dan perhitungan.
Vertikal
Dukungan dilakukan dengan class pengubah -vertical
yang menambahkan rotasi dengan
transformasi CSS ke elemen input
.
Namun, elemen yang diputar 3D tidak mengubah tinggi keseluruhan komponen,
sehingga dapat merusak tata letak blok. Perhitungkan hal ini menggunakan variabel --track-size
dan --track-padding
. Hitung jumlah ruang minimum yang diperlukan agar
tombol vertikal dapat 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 menu samping geser menggunakan transformasi CSS yang menangani bahasa dari kanan ke kiri dengan membalik satu variabel. Kita melakukan ini karena tidak ada transformasi properti logis di CSS, dan mungkin tidak akan pernah ada. Elad memiliki ide bagus menggunakan nilai properti khusus untuk membalikkan persentase, agar memungkinkan pengelolaan lokasi tunggal dari logika kustom kita sendiri untuk transformasi logis. Saya menggunakan teknik yang sama dalam {i>switch<i} ini dan saya pikir hasilnya sangat baik:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Properti khusus yang disebut --isLTR
awalnya memiliki nilai 1
, yang berarti properti tersebut
true
karena tata letaknya dari kiri ke kanan secara default. Kemudian, dengan menggunakan class pseudo
CSS :dir()
,
nilai ditetapkan ke -1
saat 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 tombol vertikal memperhitungkan posisi sisi berlawanan yang diperlukan oleh tata letak kanan-ke-kiri.
Transformasi translateX
pada elemen pseudo thumb juga perlu diupdate untuk
mempertimbangkan 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 tidak akan berhasil untuk menyelesaikan semua kebutuhan terkait konsep seperti transformasi CSS yang logis, pendekatan ini menawarkan beberapa prinsip KERINGAN untuk banyak kasus penggunaan.
Status
Menggunakan input[type="checkbox"]
bawaan tidak akan lengkap tanpa
menangani berbagai status: :checked
, :disabled
,
:indeterminate
, dan :hover
. :focus
sengaja dibiarkan sendiri, dengan
penyesuaian yang 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 merepresentasikan status on
. Dalam status ini, latar belakang "track"
input disetel ke warna aktif dan posisi thumb 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 tidak dapat diubah.Ketetapan interaksi tidak dapat diubah 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%);
}}
}
}
Status ini rumit karena memerlukan tema gelap dan terang dengan status dinonaktifkan dan dicentang. Saya memilih gaya minimal untuk status ini guna memudahkan beban pemeliharaan kombinasi gaya.
Tidak pasti
Status yang sering terlupakan adalah :indeterminate
, dengan kotak centang tidak
dicentang atau dihapus centangnya. Ini adalah keadaan yang menyenangkan, mengundang, dan sederhana. Pengingat yang baik bahwa status boolean bisa tersembunyi di antara status.
Terkadang sulit untuk menetapkan kotak centang ke 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 status itu, bagi saya, sederhana dan mengundang, terasa tepat untuk menempatkan posisi ibu jari tombol di tengah:
.gui-switch > input:indeterminate {
--thumb-position: calc(
calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
* var(--isLTR)
);
}
Pengarahan kursor
Interaksi pengarahan kursor harus memberikan dukungan visual untuk UI yang terhubung dan juga memberikan arahan ke UI interaktif. Tombol ini menandai thumb dengan cincin semi-transparan saat label atau input diarahkan ke atas. Animasi pengarahan kursor ini kemudian memberikan arah ke elemen thumb interaktif.
Efek "sorot" dilakukan dengan box-shadow
. Saat mengarahkan kursor, input yang tidak dinonaktifkan, tingkatkan ukuran --highlight-size
. Jika pengguna setuju dengan gerakan, kita akan mentransisikan box-shadow
dan melihatnya berkembang. Jika pengguna tidak keberatan dengan gerakan, sorotan akan muncul seketika:
.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 switch bisa terasa aneh dalam upayanya untuk mengemulasi antarmuka fisik, terutama seperti ini dengan lingkaran di dalam track. iOS melakukannya dengan benar dengan switch-nya, Anda dapat menariknya dari sisi ke sisi, dan sangat memuaskan untuk memiliki opsi ini. Sebaliknya, elemen UI dapat terasa tidak aktif jika gestur tarik dicoba dan tidak ada yang terjadi.
Jempol yang dapat ditarik
Elemen semu thumb menerima posisinya dari var(--thumb-position)
cakupan .gui-switch > input
, JavaScript dapat menyediakan nilai gaya inline pada
input untuk memperbarui posisi thumb secara dinamis sehingga tampak mengikuti
gestur pointer. Saat pointer dirilis, hapus gaya inline dan
tentukan apakah tarik 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 sudah 100% berfungsi sebelum skrip ini muncul, diperlukan sedikit pekerjaan untuk mempertahankan perilaku yang ada, seperti mengklik label untuk mengalihkan input. JavaScript tidak boleh menambahkan fitur dengan mengorbankan fitur yang ada.
touch-action
Menarik adalah gestur, yang merupakan gestur kustom, yang menjadikannya kandidat bagus untuk
manfaat touch-action
. Dalam kasus pengalihan ini, gestur horizontal harus
ditangani oleh skrip, atau gestur vertikal yang diambil untuk varian tombol
vertikal. Dengan touch-action
, kita dapat memberi tahu browser gestur apa yang harus ditangani pada
elemen ini, sehingga skrip dapat menangani gestur tanpa kompetisi.
CSS berikut memberi tahu browser bahwa saat gestur pointer dimulai dari dalam jalur switch ini, akan menangani gestur vertikal, dan tidak melakukan apa pun pada gestur horizontal:
.gui-switch > input {
touch-action: pan-y;
}
Hasil yang diinginkan adalah gestur horizontal yang tidak menggeser atau men-scroll halaman. Pointer dapat men-scroll secara vertikal mulai dari dalam input dan men-scroll halaman, tetapi pointer horizontal ditangani secara khusus.
Utilitas gaya nilai piksel
Saat penyiapan dan selama penarikan, berbagai nilai angka yang dihitung harus diambil
dari elemen. Fungsi JavaScript berikut menampilkan nilai piksel yang dihitung berdasarkan properti CSS. ID ini digunakan dalam skrip penyiapan 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 sehingga JavaScript dapat membaca begitu banyak nilai dari elemen, bahkan dari elemen pseudo.
dragging
Ini adalah momen inti untuk logika tarik 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 yang diposisikan oleh
skrip ini bersama dengan pointer. Objek switches
adalah Map()
dengan
kunci adalah .gui-switch
dan nilainya adalah batas serta ukuran yang di-cache yang menjaga
skrip tetap efisien. Kanan-ke-kiri ditangani menggunakan properti khusus yang sama dengan
CSS adalah --isLTR
, dan dapat menggunakannya untuk membalik logika dan terus
mendukung RTL. event.offsetX
juga berharga karena berisi nilai
delta yang berguna untuk memosisikan thumb.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Baris terakhir CSS ini menetapkan properti khusus yang digunakan oleh elemen thumb. Penetapan nilai
ini akan bertransisi dari waktu ke waktu, tetapi peristiwa pointer sebelumnya
telah menetapkan --thumb-transition-duration
ke 0s
untuk sementara, sehingga menghapus
interaksi yang lambat.
dragEnd
Agar pengguna diizinkan untuk menarik jauh ke luar tombol dan melepaskannya, peristiwa jendela global perlu didaftarkan:
window.addEventListener('pointerup', event => {
if (!state.activethumb) return
dragEnd(event)
})
Menurut saya, sangat penting bagi pengguna untuk memiliki kebebasan untuk menariknya dengan bebas dan membuat antarmuka yang cukup cerdas untuk menjelaskannya. Tidak membutuhkan banyak waktu untuk menanganinya dengan peralihan ini, tetapi memerlukan pertimbangan yang cermat selama proses pengembangan.
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, saatnya untuk menyetel properti input yang dicentang
dan menghapus semua peristiwa gestur. Kotak centang diubah dengan
state.activethumb.checked = determineChecked()
.
determineChecked()
Fungsi ini, yang dipanggil oleh dragEnd
, menentukan di mana arus thumb berada
dalam batas-batas jalurnya dan menampilkan nilai benar jika sama dengan atau lebih
setengah di sepanjang jalur:
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
}
Pikiran tambahan
Gestur tarik menimbulkan sedikit utang kode karena struktur HTML awal yang dipilih, terutama menggabungkan input dalam label. Label, yang menjadi elemen
induk, akan menerima interaksi klik setelah input. Di akhir peristiwa dragEnd
, Anda mungkin melihat padRelease()
sebagai fungsi yang terdengar ganjil.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
Hal ini untuk memperhitungkan label yang mendapatkan klik nanti ini, karena akan menghapus centang, atau memeriksa, interaksi yang dilakukan pengguna.
Jika harus melakukannya lagi, sebaiknya pertimbangkan untuk menyesuaikan DOM dengan JavaScript selama upgrade UX, karena membuat elemen yang menangani klik label itu sendiri dan tidak melawan perilaku bawaan.
Jenis JavaScript ini adalah yang paling tidak saya sukai untuk ditulis, saya tidak ingin mengelola gelembung peristiwa bersyarat:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Kesimpulan
Komponen {i>switch<i} muda ini akhirnya menjadi pekerjaan terbanyak dari semua Tantangan GUI sejauh ini! Setelah Anda tahu cara saya melakukannya, bagaimana Anda‽ 🙂
Mari lakukan diversifikasi pendekatan dan pelajari semua cara untuk membangun di web. Buat demo, link tweet me, dan saya akan menambahkannya ke bagian remix komunitas di bawah.
Remix komunitas
- @KonstantinRouda dengan elemen kustom: demo dan kode.
- @jhvanderschee dengan tombol: Codepen.
Referensi
Temukan .gui-switch
kode sumber di GitHub.