Ringkasan dasar tentang cara mem-build komponen tombol aksesibel dan responsif.
Dalam postingan ini, saya ingin membagikan pemikiran tentang cara membuat komponen tombol. Coba demo.
Jika Anda lebih suka video, berikut versi YouTube dari postingan ini:
Ringkasan
Tombol berfungsi 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 tidak memerlukan CSS atau JavaScript agar
berfungsi penuh dan dapat diakses. Memuat CSS menghadirkan dukungan untuk bahasa
kanan ke kiri, vertikalitas, animasi, dan lainnya. Memuat JavaScript membuat tombol
dapat ditarik dan terlihat.
Properti kustom
Variabel berikut mewakili berbagai bagian tombol dan
opsi tombol. Sebagai class tingkat atas, .gui-switch
berisi properti kustom yang digunakan
di seluruh turunan komponen, dan titik entri untuk penyesuaian
terpusat.
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, kueri media pengguna preferensi gerakan yang dikurangi dapat dimasukkan ke dalam properti kustom dengan plugin PostCSS berdasarkan spesifikasi draf dalam Kueri Media 5 ini:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Markup
Saya memilih untuk menggabungkan elemen <input type="checkbox" role="switch">
dengan
<label>
, menggabungkan hubungannya untuk menghindari ambiguitas hubungan kotak centang dan label, sekaligus memberi pengguna kemampuan untuk berinteraksi dengan label untuk
mengubah input.
<label for="switch" class="gui-switch">
Label text
<input type="checkbox" role="switch" id="switch">
</label>
<input type="checkbox">
sudah di-build sebelumnya dengan
API
dan status. 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, memberi nama pada penghitungan atau area yang ambigu, dan mengaktifkan API properti kustom kecil untuk penyesuaian komponen yang mudah.
.gui-switch
Tata letak tingkat atas untuk tombol adalah flexbox. Class .gui-switch
berisi
properti kustom 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 mengubah tata letak flexbox sama seperti mengubah tata letak flexbox lainnya.
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
Input kotak centang ditata gayanya sebagai jalur tombol dengan menghapus
appearance: checkbox
normalnya dan menyediakan 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;
}
Jalur juga membuat area jalur petak sel tunggal satu per satu untuk ibu jari yang akan diklaim.
Kalimba
Gaya appearance: none
juga menghapus tanda centang visual yang disediakan oleh browser. Komponen ini menggunakan
pseudo-elemen dan :checked
pseudo-class pada input untuk
mengganti indikator visual ini.
Thumb adalah turunan elemen pseudo yang dilampirkan ke input[type="checkbox"]
dan
ditumpuk di atas jalur, bukan di bawahnya, dengan mengklaim area petak
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 serbaguna yang 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. Hal ini berdampak negatif pada gaya dan masukan interaksi visual yang
diperlukan oleh 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 gaya tersebut dapat menjadi umpan balik interaksi visual yang berharga. Pastikan untuk memberikan alternatif kustom jika Anda menghapusnya.
Lacak
Gaya elemen ini sebagian besar berkaitan dengan bentuk dan warnanya, yang diakses
dari .gui-switch
induk melalui
cascade.
.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 tombol berasal dari empat
properti kustom. border: none
ditambahkan karena appearance: none
tidak
menghapus batas dari kotak centang di semua browser.
Kalimba
Elemen thumb sudah berada di track
kanan, 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 tanda kursor dan perubahan posisi ibu jari. Preferensi pengguna juga diperiksa sebelum melakukan transisi gaya gerakan atau sorotan 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. Kita memiliki ukuran trek dan thumb yang akan digunakan dalam
penghitungan untuk menjaga thumb tetap diimbangi dengan benar dan di antara dalam trek:
0%
dan 100%
.
Elemen input
memiliki variabel posisi --thumb-position
, dan elemen pseudo ibu jari
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 di elemen kotak centang. Karena kita menetapkan transition: transform
var(--thumb-transition-duration) ease
secara kondisional sebelumnya pada elemen ini, perubahan ini
dapat dianimasikan 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 rasa orkestrasi yang dipisahkan ini berhasil dengan baik. Elemen thumb
hanya berkaitan dengan satu gaya, posisi translateX
. Input dapat mengelola semua
kompleksitas dan penghitungan.
Vertikal
Dukungan dilakukan dengan class pengubah -vertical
yang menambahkan rotasi dengan
transformasi CSS ke elemen input
.
Namun, elemen yang diputar 3D tidak mengubah tinggi komponen secara keseluruhan,
yang dapat mengacaukan tata letak blok. Perhitungkan hal ini menggunakan variabel --track-size
dan
--track-padding
. Hitung jumlah minimum ruang yang diperlukan agar
tombol vertikal 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 menu samping geser menggunakan transformasi CSS yang menangani bahasa kanan ke kiri dengan membalik satu variabel. Kami melakukannya karena tidak ada transformasi properti logis di CSS, dan mungkin tidak akan pernah ada. Elad memiliki ide bagus untuk menggunakan nilai properti kustom untuk membalik persentase, guna memungkinkan pengelolaan lokasi tunggal logika kustom kami sendiri untuk transformasi logis. Saya menggunakan teknik yang sama dalam tombol ini dan saya pikir hasilnya sangat bagus:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
Properti kustom yang disebut --isLTR
awalnya menyimpan nilai 1
, yang berarti
true
karena tata letak kita adalah dari kiri ke kanan secara default. Kemudian, 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 yang berlawanan yang diperlukan oleh tata letak kanan-ke-kiri.
Transformasi translateX
pada pseudo-elemen thumb juga perlu diperbarui 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 pendekatan ini tidak akan berfungsi untuk memenuhi semua kebutuhan terkait konsep seperti transformasi CSS logis, pendekatan ini menawarkan beberapa prinsip DRY untuk banyak kasus penggunaan.
Negara bagian
Penggunaan input[type="checkbox"]
bawaan tidak akan lengkap tanpa
menangani berbagai status yang dapat dimilikinya: :checked
, :disabled
,
:indeterminate
, dan :hover
. :focus
sengaja dibiarkan, dengan
penyesuaian hanya dilakukan pada offset-nya; cincin fokus terlihat 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, latar belakang "jalur"
input ditetapkan ke warna aktif dan posisi ibu jari ditetapkan 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.Ketidakmampuan 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%);
}}
}
}
Status ini rumit karena memerlukan tema gelap dan terang dengan status dinonaktifkan dan diperiksa. Saya secara gaya memilih gaya minimal untuk status ini guna meringankan beban pemeliharaan kombinasi gaya.
Tidak tentu
Status yang sering dilupakan adalah :indeterminate
, dengan kotak centang tidak
dicentang atau tidak dicentang. Ini adalah status yang menyenangkan, mengundang, dan sederhana. Pengingat
yang baik bahwa status boolean dapat memiliki status tersembunyi di antara status.
Menyetel kotak centang ke tidak ditentukan cukup sulit, 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 statusnya, bagi saya, sederhana dan menarik, rasanya 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)
);
}
Arahkan kursor
Interaksi pengarahan kursor harus memberikan dukungan visual untuk UI yang terhubung dan juga memberikan arah ke UI interaktif. Tombol ini akan menandai ibu jari dengan cincin semi-transparan saat kursor diarahkan ke label atau input. Animasi kursor ini kemudian memberikan arah ke elemen thumb interaktif.
Efek "sorotan" dilakukan dengan box-shadow
. Saat mengarahkan kursor ke input yang tidak dinonaktifkan, perbesar ukuran --highlight-size
. Jika pengguna tidak keberatan dengan gerakan, kita akan mentransisikan box-shadow
dan melihatnya tumbuh, jika mereka tidak keberatan dengan gerakan, sorotan akan muncul secara instan:
.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 tombol dapat terasa aneh dalam upayanya untuk mengemulasi antarmuka fisik, terutama jenis ini dengan lingkaran di dalam jalur. iOS melakukannya dengan benar dengan tombolnya, Anda dapat menariknya dari sisi ke sisi, dan sangat memuaskan untuk memiliki opsi tersebut. Sebaliknya, elemen UI dapat terasa tidak aktif jika gestur tarik dicoba dan tidak ada yang terjadi.
Jempol yang dapat ditarik
Pseudo-elemen thumb menerima posisinya dari var(--thumb-position)
yang dicakup .gui-switch > input
, JavaScript dapat menyediakan nilai gaya inline pada
input untuk memperbarui posisi thumb secara dinamis sehingga tampak mengikuti
gestur pointer. Saat pointer dilepaskan, hapus gaya inline dan
tentukan apakah tarik lebih dekat ke nonaktif atau aktif dengan menggunakan properti kustom
--thumb-position
. Ini adalah tulang punggung solusi; peristiwa pointer
melacak posisi pointer secara kondisional untuk mengubah properti kustom CSS.
Karena komponen sudah berfungsi 100% sebelum skrip ini muncul, perlu cukup banyak pekerjaan untuk mempertahankan perilaku yang ada, seperti mengklik label untuk mengalihkan input. JavaScript kami tidak boleh menambahkan fitur dengan mengurangi fitur yang sudah ada.
touch-action
Menarik adalah gestur, gestur kustom, yang membuatnya menjadi kandidat yang bagus untuk
mendapatkan manfaat touch-action
. Dalam kasus tombol ini, gestur horizontal harus ditangani oleh skrip kita, atau gestur vertikal yang diambil untuk varian tombol vertikal. Dengan touch-action
, kita dapat memberi tahu browser gestur apa yang akan ditangani pada
elemen ini, sehingga skrip dapat menangani gestur tanpa persaingan.
CSS berikut menginstruksikan browser bahwa saat gestur pointer dimulai dari dalam jalur tombol ini, tangani gestur vertikal, jangan lakukan apa pun dengan gestur horizontal:
.gui-switch > input {
touch-action: pan-y;
}
Hasil yang diinginkan adalah gestur horizontal yang juga 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 kustom.
Utilitas gaya nilai piksel
Saat penyiapan dan selama tarik, berbagai nilai angka yang dihitung harus diambil
dari elemen. Fungsi JavaScript berikut menampilkan nilai piksel yang dihitung
dengan properti CSS. Ini digunakan dalam skrip penyiapan seperti
getStyle(checkbox, 'padding-left')
ini.
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 bagaimana window.getComputedStyle()
menerima argumen kedua, elemen pseudo target. Cukup menarik bahwa 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`)
}
Hero skrip adalah state.activethumb
, lingkaran kecil yang diposisikan skrip ini bersama dengan pointer. Objek switches
adalah Map()
dengan
kunci .gui-switch
dan nilai adalah batas dan ukuran yang di-cache yang membuat
skrip tetap efisien. Kanan ke kiri ditangani menggunakan properti kustom yang sama
dengan CSS yaitu --isLTR
, dan dapat menggunakannya untuk membalik logika dan terus
mendukung RTL. event.offsetX
juga berharga, karena berisi nilai
delta yang berguna untuk memosisikan ibu jari.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
Baris terakhir CSS ini menetapkan properti kustom 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 di 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 menarik dengan longgar dan antarmuka yang cukup cerdas untuk memperhitungkannya. Tidak perlu banyak hal untuk menanganinya dengan tombol ini, tetapi perlu 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 menetapkan properti centang input
dan menghapus semua peristiwa gestur. Kotak centang diubah dengan
state.activethumb.checked = determineChecked()
.
determineChecked()
Fungsi ini, yang dipanggil oleh dragEnd
, menentukan letak arus ibu jari
dalam batas jalurnya dan menampilkan nilai benar jika sama dengan atau lebih dari
setengah jalan 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 merupakan elemen
induk, akan menerima interaksi klik setelah input. Di akhir peristiwa dragEnd
, Anda mungkin telah melihat padRelease()
sebagai fungsi yang terdengar aneh.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
Hal ini untuk memperhitungkan label yang mendapatkan klik ini nanti, karena akan menghapus centang, atau mencentang, interaksi yang dilakukan pengguna.
Jika harus melakukannya lagi, saya mungkin akan mempertimbangkan untuk menyesuaikan DOM dengan JavaScript selama upgrade UX, untuk membuat elemen yang menangani klik label itu sendiri dan tidak bertentangan dengan perilaku bawaan.
JavaScript jenis ini adalah yang paling tidak saya sukai untuk ditulis, saya tidak ingin mengelola bubbling peristiwa bersyarat:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Kesimpulan
Komponen tombol kecil ini ternyata menjadi pekerjaan paling banyak dari semua Tantangan GUI sejauh ini. Setelah Anda tahu cara saya melakukannya, bagaimana Anda melakukannya‽ 🙂
Mari kita diversifikasi pendekatan dan pelajari semua cara untuk mem-build di web. Buat demo, tweet link-nya, dan saya akan menambahkannya ke bagian remix komunitas di bawah.
Remix komunitas
- @KonstantinRouda dengan elemen kustom: demo dan code.
- @jhvanderschee dengan tombol: Codepen.
Resource
Temukan kode sumber .gui-switch
di GitHub.