Menu docs

Komponen · · 2 menit baca

Komponen e-commerce: quantity stepper dengan batas stok

Stepper jumlah yang benar: batas dijaga lewat disabled (bukan alert), satu fungsi render sebagai sumber kebenaran subtotal & sisa stok, plus aria-label dan aria-live — bisa dicoba langsung di preview.

HTMLCSSVanilla JSA11y
Contoh liveDirender langsung oleh komponennya — bukan gambar.
1
2
3
4
5
6
7
8
9
10
11
<div class="row">
  <div class="stepper" data-stepper data-max="8">
    <button class="stepper__btn" type="button" data-dec aria-label="Kurangi">−</button>
    <input class="stepper__val" type="text" inputmode="numeric"
           value="1" readonly aria-live="polite" />
    <button class="stepper__btn" type="button" data-inc aria-label="Tambah">+</button>
  </div>
  <p class="stok">Sisa <strong data-sisa>8</strong> pcs</p>
</div>

<p class="total">Subtotal: <strong data-total>Rp 85.000</strong></p>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
.row {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}

.stepper {
  display: inline-flex;
  border: 1px solid #e4e4e7;
}

.stepper__btn {
  width: 38px;
  height: 38px;
  border: 0;
  background: #fff;
  font-size: 16px;
  cursor: pointer;
  color: #09090b;
}
.stepper__btn:hover:not(:disabled) { background: #fafafa; }
.stepper__btn:disabled { color: #d4d4d8; cursor: not-allowed; }

.stepper__val {
  width: 46px;
  height: 38px;
  border: 0;
  border-left: 1px solid #e4e4e7;
  border-right: 1px solid #e4e4e7;
  text-align: center;
  font-size: 14px;
  background: #fff;
}

.stok { margin: 0; font-size: 13px; color: #71717a; }
.total { margin: 16px 0 0; font-size: 14px; }
.total strong { font-size: 18px; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const HARGA = 85000;
const rupiah = (n) => new Intl.NumberFormat("id-ID", {
  style: "currency", currency: "IDR", maximumFractionDigits: 0,
}).format(n);

document.querySelectorAll("[data-stepper]").forEach((root) => {
  const max = Number(root.dataset.max);
  const input = root.querySelector(".stepper__val");
  const dec = root.querySelector("[data-dec]");
  const inc = root.querySelector("[data-inc]");
  let qty = 1;

  function render() {
    input.value = String(qty);
    // Batas dijaga lewat disabled, bukan lewat alert
    dec.disabled = qty <= 1;
    inc.disabled = qty >= max;
    document.querySelector("[data-sisa]").textContent = String(max - qty);
    document.querySelector("[data-total]").textContent = rupiah(HARGA * qty);
  }

  dec.addEventListener("click", () => { if (qty > 1) { qty--; render(); } });
  inc.addEventListener("click", () => { if (qty < max) { qty++; render(); } });
  render();
});

Preview

Stepper jumlah (− 1 +) terlihat sepele, tapi di halaman produk ia menyentuh tiga hal sekaligus: batas stok, subtotal yang harus ikut berubah, dan aksesibilitas. Coba tombolnya di panel preview — batas atas dan bawahnya benar-benar bekerja.

Batas dijaga lewat disabled, bukan lewat peringatan

Kesalahan yang sering kutemui: user menekan “−” sampai jumlahnya 0 atau minus, lalu muncul alert(). Yang benar, tombol dinonaktifkan tepat di batasnya:

dec.disabled = qty <= 1;
inc.disabled = qty >= max;

Hasilnya: tidak ada aksi yang perlu dibatalkan, tombol yang tidak berlaku otomatis dilewati saat navigasi keyboard, dan screen reader mengumumkannya sebagai “dimmed/unavailable”. Batas yang tidak bisa dilanggar selalu lebih baik daripada pesan error setelah dilanggar.

Satu fungsi render() sebagai sumber kebenaran

Semua yang bergantung pada qty — nilai input, status dua tombol, sisa stok, dan subtotal — diperbarui di satu tempat:

function render() {
  input.value = String(qty);
  dec.disabled = qty <= 1;
  inc.disabled = qty >= max;
  document.querySelector("[data-sisa]").textContent = String(max - qty);
  document.querySelector("[data-total]").textContent = rupiah(HARGA * qty);
}

Event handler-nya hanya mengubah qty lalu memanggil render(). Pola ini mencegah bug klasik “subtotal tidak ikut berubah kalau tombolnya diklik cepat” — karena tidak ada satu pun nilai yang diperbarui di dua tempat berbeda.

Aksesibilitas: tiga atribut yang menentukan

  • aria-label di tombol. Karakter dan + dibaca screen reader sebagai simbol matematika, bukan “kurangi”/“tambah”.
  • aria-live="polite" di input. Saat angkanya berubah karena klik, perubahan itu diumumkan tanpa memotong pembacaan lain.
  • inputmode="numeric". Di HP, keyboard angka langsung muncul kalau input dibuat editable.

Input-nya sengaja readonly di contoh ini: nilainya hanya berubah lewat tombol, sehingga tidak ada state ganjil seperti "abc" atau 007. Kalau kamu ingin user bisa mengetik, validasi harus ditambahkan di change — dan tetap dibulatkan ke rentang 1..max.

Menghubungkannya ke keranjang

Di project sungguhan, render() cukup ditambah satu baris untuk menulis qty ke state keranjang (localStorage atau POST /cart — lihat build note Katalog + keranjang). Karena semua perubahan sudah lewat satu fungsi, titik integrasinya juga cuma satu.

Pelajaran

  • Cegah aksi yang tidak valid, jangan koreksi setelahnya.
  • Satu fungsi render untuk semua turunan state = nol bug sinkronisasi.
  • Komponen kecil pun butuh label — simbol bukan teks bagi screen reader.

Mau fitur seperti ini dibangun untuk bisnismu?