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.
<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>.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; }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-labeldi 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?