Membuat komponen {i>switch

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.

Demo

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
dan kotak centang yang alami dan tanpa gaya.

<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 oninputdan 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.

Flexbox DevTools menempatkan label horizontal dan tombol, menunjukkan distribusi
tata letaknya.

.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:

Flexbox DevTools menempatkan label vertikal dan tombol.

<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:

Grid DevTools menempatkan jalur tombol, yang menampilkan area jalur
petak bernama dengan nama &#39;track&#39;.

.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:

DevTools menampilkan thumb elemen pseudo yang diposisikan di dalam petak CSS.

.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.

Perbandingan berdampingan antara tema terang dan gelap untuk tombol dan
statusnya.

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.

Varian switch dengan ukuran dan warna jalur kustom.

.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%;
}

DevTools ditampilkan yang menandai elemen pseudo thumb lingkaran.

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:

Screenshot lingkaran fokus yang berfokus pada tombol 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%);
    }}
  }
}

Tombol bergaya gelap dalam status dinonaktifkan, dicentang, dan
tidak dicentang.

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>

Status tidak pasti yang memiliki thumb track di
tengah, untuk menunjukkan ketidakpastian.

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

Referensi

Temukan .gui-switch kode sumber di GitHub.