Bab 3: UI Events

5 menit baca

Detail interaksi user: mouse, keyboard, dan scroll — event yang paling sering kamu pakai sehari-hari.


3.1 Mouse Events

💡Analogi

Mouse itu kayak jari kamu di layar sentuh — bisa klik, double-klik, tekan tahan, dan gerak. Browser melacak semua gerakan ini dan memberitahu kamu lewat event.

Jenis Mouse Events

EventKapan
clickKlik kiri (mousedown + mouseup di elemen yang sama)
dblclickDouble-click
mousedownTombol mouse ditekan
mouseupTombol mouse dilepas
contextmenuKlik kanan (atau menu key)

Urutan Event

Satu klik sebenarnya menghasilkan beberapa event berurutan:

mousedown → mouseup → click

Double-click:

mousedown → mouseup → click → mousedown → mouseup → click → dblclick

Tombol Mouse: event.button

javascript
elem.addEventListener("mousedown", function(event) {
  switch (event.button) {
    case 0: console.log("Kiri"); break;
    case 1: console.log("Tengah (scroll wheel)"); break;
    case 2: console.log("Kanan"); break;
  }
});

Modifier Keys

Cek apakah user menekan Shift/Ctrl/Alt/Meta saat klik:

javascript
elem.addEventListener("click", function(event) {
  if (event.shiftKey) console.log("Shift + Click");
  if (event.ctrlKey) console.log("Ctrl + Click");
  if (event.altKey) console.log("Alt + Click");
  if (event.metaKey) console.log("Cmd/Win + Click");
  
  // Contoh: Ctrl+Click untuk buka di tab baru
  if (event.ctrlKey || event.metaKey) {
    // Biarkan browser handle (buka tab baru)
    return;
  }
  event.preventDefault();
  // Handle klik biasa...
});

Koordinat Mouse

javascript
elem.addEventListener("click", function(event) {
  // Relatif terhadap viewport (layar)
  console.log("Client:", event.clientX, event.clientY);
  
  // Relatif terhadap dokumen (termasuk scroll)
  console.log("Page:", event.pageX, event.pageY);
});

Mencegah Seleksi Teks saat Double-Click

javascript
elem.addEventListener("mousedown", function(event) {
  if (event.detail > 1) { // detail = jumlah klik berturut
    event.preventDefault(); // Cegah seleksi teks
  }
});

// Atau via CSS:
// user-select: none;
⚠️Jebakan!
javascript
// 1. click event HANYA untuk tombol kiri
// Untuk kanan → pakai contextmenu
// Untuk semua tombol → pakai mousedown/mouseup

// 2. Jangan disable contextmenu di seluruh halaman
// User butuh copy-paste!

// 3. Mobile tidak punya mouse events yang sama
// touchstart/touchend berbeda dari mousedown/mouseup
// Tapi click tetap bekerja di mobile (dengan delay ~300ms)

🎯 Challenge

javascript
// Buat "drawing canvas" sederhana:
// 1. Buat <div> besar (500x300px, border)
// 2. mousedown → mulai "menggambar" (set flag isDrawing = true)
// 3. mousemove + isDrawing → buat <div> kecil (5x5px, background hitam) 
//    di posisi mouse (pakai event.clientX/Y + getBoundingClientRect)
// 4. mouseup → berhenti menggambar
// Hint: position:relative pada canvas, position:absolute pada titik

3.2 Pergerakan Mouse: mouseover/out, mouseenter/leave

💡Analogi

Bayangin kamu punya sensor pintu:

  • mouseover/mouseout = sensor yang juga deteksi gerakan di DALAM ruangan (antar furniture)
  • mouseenter/mouseleave = sensor yang hanya deteksi masuk/keluar ruangan

mouseover dan mouseout

javascript
elem.addEventListener("mouseover", function(event) {
  // Mouse MASUK elemen (atau child-nya)
  console.log("Masuk:", event.target.tagName);
  console.log("Dari:", event.relatedTarget?.tagName); // Elemen sebelumnya
});

elem.addEventListener("mouseout", function(event) {
  // Mouse KELUAR elemen (atau pindah ke child)
  console.log("Keluar:", event.target.tagName);
  console.log("Ke:", event.relatedTarget?.tagName); // Elemen tujuan
});

Masalah: Event Berlebihan

html
<div id="parent">
  <span>Teks</span>
</div>

Dengan mouseover/mouseout, pindah dari <div> ke <span> di dalamnya menghasilkan:

  1. mouseout dari <div> (keluar div)
  2. mouseover pada <span> (masuk span)

Ini sering tidak diinginkan!

mouseenter dan mouseleave (Solusi)

javascript
elem.addEventListener("mouseenter", function(event) {
  // Mouse masuk elemen — TIDAK trigger saat pindah antar child
  this.style.background = "yellow";
});

elem.addEventListener("mouseleave", function(event) {
  // Mouse keluar elemen — TIDAK trigger saat pindah antar child
  this.style.background = "";
});

Perbedaan Kunci

mouseover/mouseoutmouseenter/mouseleave
Trigger saat pindah ke child?✅ Ya❌ Tidak
Bubble?✅ Ya❌ Tidak
Event delegation?✅ Bisa❌ Tidak bisa

Kapan Pakai Yang Mana?

javascript
// Pakai mouseenter/mouseleave untuk:
// - Highlight saat hover (paling umum)
// - Tooltip
// - Dropdown menu

// Pakai mouseover/mouseout untuk:
// - Event delegation (karena bubble)
// - Perlu tahu perpindahan antar child elements

Contoh: Tooltip dengan mouseenter/mouseleave

javascript
const buttons = document.querySelectorAll("[data-tooltip]");

buttons.forEach(btn => {
  btn.addEventListener("mouseenter", function() {
    const tooltip = document.createElement("div");
    tooltip.className = "tooltip";
    tooltip.textContent = this.dataset.tooltip;
    this.append(tooltip);
  });
  
  btn.addEventListener("mouseleave", function() {
    this.querySelector(".tooltip")?.remove();
  });
});

Event Delegation dengan mouseover/mouseout

javascript
// Karena mouseenter tidak bubble, pakai mouseover untuk delegation
const table = document.getElementById("data-table");
let currentHighlight = null;

table.addEventListener("mouseover", function(event) {
  const td = event.target.closest("td");
  if (!td || !table.contains(td)) return;
  if (td === currentHighlight) return; // Sudah di-highlight
  
  if (currentHighlight) {
    currentHighlight.style.background = ""; // Hapus highlight lama
  }
  currentHighlight = td;
  td.style.background = "lightyellow"; // Highlight baru
});

table.addEventListener("mouseout", function(event) {
  if (!currentHighlight) return;
  
  // Cek apakah benar-benar keluar dari td
  const relatedTarget = event.relatedTarget;
  if (currentHighlight.contains(relatedTarget)) return; // Masih di dalam td
  
  currentHighlight.style.background = "";
  currentHighlight = null;
});
⚠️Jebakan!
javascript
// 1. relatedTarget bisa null (mouse dari luar window)
event.relatedTarget; // null kalau mouse dari luar browser

// 2. mouseenter/mouseleave TIDAK bubble
// Jadi tidak bisa pakai event delegation!
// Solusi: pakai mouseover/mouseout dengan logic tambahan

// 3. Cepat gerakkan mouse → bisa skip elemen
// Browser tidak menjamin mouseover di SETIAP elemen yang dilewati

🎯 Challenge

html
<table id="schedule">
  <tr><td>09:00</td><td>Meeting</td><td>Room A</td></tr>
  <tr><td>10:00</td><td>Coding</td><td>Desk</td></tr>
  <tr><td>12:00</td><td>Lunch</td><td>Cafeteria</td></tr>
</table>

<!-- Tulis JavaScript untuk:
1. Hover pada <tr> → seluruh row background jadi kuning
2. Pakai event delegation (mouseover/mouseout di <table>)
3. Pastikan highlight tidak "flicker" saat pindah antar <td> dalam row yang sama
-->

3.3 Drag and Drop dengan Mouse Events

💡Analogi

Drag and drop itu kayak angkat barang dan taruh di tempat lain. Prosesnya:

  1. Pegang (mousedown)
  2. Geser (mousemove)
  3. Lepas (mouseup)

Algoritma Dasar

javascript
const draggable = document.getElementById("ball");

draggable.addEventListener("mousedown", function(event) {
  // Hitung offset (jarak mouse dari pojok kiri atas elemen)
  const shiftX = event.clientX - draggable.getBoundingClientRect().left;
  const shiftY = event.clientY - draggable.getBoundingClientRect().top;
  
  // Pindahkan elemen ke body agar bisa bergerak bebas
  draggable.style.position = "absolute";
  draggable.style.zIndex = 1000;
  document.body.append(draggable);
  
  moveAt(event.pageX, event.pageY);
  
  function moveAt(pageX, pageY) {
    draggable.style.left = pageX - shiftX + "px";
    draggable.style.top = pageY - shiftY + "px";
  }
  
  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }
  
  // Gerak
  document.addEventListener("mousemove", onMouseMove);
  
  // Lepas
  document.addEventListener("mouseup", function onMouseUp() {
    document.removeEventListener("mousemove", onMouseMove);
    document.removeEventListener("mouseup", onMouseUp);
  });
});

// Cegah drag bawaan browser
draggable.addEventListener("dragstart", function(event) {
  event.preventDefault();
});

Mendeteksi "Drop Zone"

Masalah: saat drag, elemen yang di-drag ada di bawah mouse, jadi elementFromPoint mengembalikan elemen itu sendiri.

Solusi: sembunyikan sebentar:

javascript
function onMouseMove(event) {
  moveAt(event.pageX, event.pageY);
  
  // Sembunyikan elemen yang di-drag
  draggable.hidden = true;
  
  // Cek elemen di bawah mouse
  const elemBelow = document.elementFromPoint(event.clientX, event.clientY);
  
  // Tampilkan lagi
  draggable.hidden = false;
  
  if (!elemBelow) return;
  
  // Cek apakah di atas drop zone
  const dropZone = elemBelow.closest(".drop-zone");
  if (dropZone) {
    dropZone.classList.add("highlight");
  }
}

Contoh Lengkap: Sortable List

javascript
function makeSortable(list) {
  let draggedItem = null;
  
  list.addEventListener("mousedown", function(event) {
    const item = event.target.closest("li");
    if (!item) return;
    
    draggedItem = item;
    draggedItem.classList.add("dragging");
    
    document.addEventListener("mousemove", onMove);
    document.addEventListener("mouseup", onUp);
  });
  
  function onMove(event) {
    if (!draggedItem) return;
    
    // Cari item di bawah mouse
    const items = [...list.querySelectorAll("li:not(.dragging)")];
    const nextItem = items.find(item => {
      const rect = item.getBoundingClientRect();
      return event.clientY < rect.top + rect.height / 2;
    });
    
    // Pindahkan
    if (nextItem) {
      list.insertBefore(draggedItem, nextItem);
    } else {
      list.append(draggedItem);
    }
  }
  
  function onUp() {
    if (draggedItem) {
      draggedItem.classList.remove("dragging");
      draggedItem = null;
    }
    document.removeEventListener("mousemove", onMove);
    document.removeEventListener("mouseup", onUp);
  }
}
⚠️Jebakan!
javascript
// 1. HARUS preventDefault pada dragstart
elem.ondragstart = () => false; // Cegah drag bawaan browser

// 2. mousemove listener di DOCUMENT, bukan elemen
// Karena mouse bisa bergerak keluar elemen saat drag cepat

// 3. Jangan lupa cleanup (removeEventListener) saat mouseup

// 4. Performa: mousemove fire SANGAT sering
// Gunakan requestAnimationFrame kalau perlu

🎯 Challenge

javascript
// Buat slider sederhana:
// 1. Buat track (div panjang horizontal, background abu-abu)
// 2. Buat thumb (div kecil bulat di atas track)
// 3. Drag thumb → hanya bisa gerak horizontal (dalam track)
// 4. Tampilkan nilai 0-100 berdasarkan posisi thumb
// Hint: clamp posisi antara 0 dan track.offsetWidth

3.4 Pointer Events

💡Analogi

Dulu ada mouse events dan touch events — dua sistem terpisah. Pointer events itu penyatuan — satu API untuk mouse, touch, dan stylus. Kayak adapter universal yang cocok untuk semua colokan.

Kenapa Pointer Events?

DeviceEvent LamaPointer Event
Mousemousedownpointerdown
Touchtouchstartpointerdown
Pen/Styluspointerdown

Satu kode untuk semua device!

Mapping Mouse → Pointer

Mouse EventPointer Event
mousedownpointerdown
mouseuppointerup
mousemovepointermove
mouseenterpointerenter
mouseleavepointerleave
mouseoverpointerover
mouseoutpointerout

Properti Tambahan

javascript
elem.addEventListener("pointerdown", function(event) {
  console.log(event.pointerId);    // ID unik pointer (untuk multi-touch)
  console.log(event.pointerType);  // "mouse", "touch", atau "pen"
  console.log(event.width);        // Lebar area kontak
  console.log(event.height);       // Tinggi area kontak
  console.log(event.pressure);     // Tekanan (0-1, untuk stylus)
  console.log(event.isPrimary);    // true = pointer utama
});

Pointer Capture

"Kunci" semua event pointer ke satu elemen (berguna untuk drag):

javascript
elem.addEventListener("pointerdown", function(event) {
  // Semua pointermove/pointerup akan ke elem ini, 
  // meskipun mouse di luar elemen
  elem.setPointerCapture(event.pointerId);
});

elem.addEventListener("pointermove", function(event) {
  // Selalu diterima selama capture aktif
  console.log(event.clientX, event.clientY);
});

elem.addEventListener("pointerup", function(event) {
  // Capture otomatis dilepas saat pointerup
  console.log("Selesai drag");
});

// Event saat capture dimulai/berakhir
elem.addEventListener("gotpointercapture", () => console.log("Capture dimulai"));
elem.addEventListener("lostpointercapture", () => console.log("Capture berakhir"));

Drag dengan Pointer Events (Lebih Simpel!)

javascript
const slider = document.getElementById("thumb");

slider.addEventListener("pointerdown", function(event) {
  slider.setPointerCapture(event.pointerId);
  // Tidak perlu document.addEventListener!
});

slider.addEventListener("pointermove", function(event) {
  // Otomatis diterima karena capture
  slider.style.left = event.clientX + "px";
});

// Tidak perlu cleanup manual — capture otomatis lepas saat pointerup

touch-action CSS

css
/* Cegah browser handle touch (scroll, zoom) pada elemen tertentu */
.draggable {
  touch-action: none; /* Kita yang handle semua touch */
}

.horizontal-slider {
  touch-action: pan-y; /* Browser handle scroll vertikal, kita handle horizontal */
}
⚠️Jebakan!
javascript
// 1. Pointer events MENGGANTIKAN mouse events
// Kalau pakai pointer events, jangan campur dengan mouse events

// 2. touch-action HARUS di-set di CSS
// Tanpa ini, browser bisa "steal" touch untuk scroll/zoom

// 3. Browser masih fire mouse events setelah touch (untuk kompatibilitas)
// Urutan: pointerdown → touchstart → mousedown → click

// 4. setPointerCapture harus di dalam pointerdown handler

🎯 Challenge

javascript
// Refactor challenge drag-and-drop dari sub-bab 3.3 menggunakan Pointer Events:
// 1. Ganti mousedown → pointerdown
// 2. Gunakan setPointerCapture (tidak perlu document listener)
// 3. Tambahkan touch-action: none di CSS
// 4. Test: harus bekerja di mouse DAN touch device

3.5 Keyboard Events

💡Analogi

Keyboard events itu kayak mesin ketik — setiap tombol yang ditekan dan dilepas menghasilkan sinyal. Kamu bisa menangkap sinyal ini untuk membuat shortcut, game controls, atau validasi input.

Dua Event Utama

javascript
document.addEventListener("keydown", function(event) {
  // Tombol DITEKAN (fire berulang kalau ditahan!)
  console.log("Key down:", event.key);
});

document.addEventListener("keyup", function(event) {
  // Tombol DILEPAS
  console.log("Key up:", event.key);
});

event.key vs event.code

javascript
document.addEventListener("keydown", function(event) {
  console.log("key:", event.key);   // Karakter yang dihasilkan: "a", "A", "Enter"
  console.log("code:", event.code); // Tombol fisik: "KeyA", "Enter", "ShiftLeft"
});
Tombolevent.keyevent.code
A (tanpa shift)"a""KeyA"
A (dengan shift)"A""KeyA"
Enter"Enter""Enter"
Shift kiri"Shift""ShiftLeft"
Angka 1 (atas)"1""Digit1"
Angka 1 (numpad)"1""Numpad1"

Kapan pakai yang mana?

  • event.key → untuk input teks, shortcut yang bergantung pada karakter
  • event.code → untuk game controls, shortcut yang bergantung pada posisi tombol

Modifier Keys

javascript
document.addEventListener("keydown", function(event) {
  // Ctrl+S (Save)
  if (event.ctrlKey && event.key === "s") {
    event.preventDefault(); // Cegah browser save
    console.log("Custom save!");
  }
  
  // Ctrl+Shift+Z (Redo)
  if (event.ctrlKey && event.shiftKey && event.key === "Z") {
    console.log("Redo!");
  }
});

Auto-repeat

Kalau tombol ditahan, keydown fire berulang-ulang:

javascript
document.addEventListener("keydown", function(event) {
  if (event.repeat) {
    console.log("Tombol ditahan (repeat)");
  } else {
    console.log("Tekan pertama");
  }
});

Contoh: Keyboard Shortcut System

javascript
const shortcuts = {
  "ctrl+s": () => { console.log("Save"); },
  "ctrl+z": () => { console.log("Undo"); },
  "ctrl+shift+z": () => { console.log("Redo"); },
  "escape": () => { console.log("Close modal"); }
};

document.addEventListener("keydown", function(event) {
  let combo = "";
  if (event.ctrlKey) combo += "ctrl+";
  if (event.shiftKey) combo += "shift+";
  if (event.altKey) combo += "alt+";
  combo += event.key.toLowerCase();
  
  if (shortcuts[combo]) {
    event.preventDefault();
    shortcuts[combo]();
  }
});
⚠️Jebakan!
javascript
// 1. keydown di input → karakter BELUM masuk ke input
// Pakai "input" event kalau mau baca nilai setelah ketik

// 2. Beberapa kombinasi di-"curi" browser/OS
// Ctrl+T (tab baru), Ctrl+W (tutup tab) → tidak bisa dicegah

// 3. event.keyCode DEPRECATED — jangan pakai
// Pakai event.key atau event.code

// 4. Layout keyboard berbeda → event.code lebih reliable untuk posisi
// Di keyboard AZERTY, "KeyA" menghasilkan "q" (bukan "a")

// 5. Mobile keyboard: keydown/keyup tidak reliable
// Pakai "input" event untuk mobile

🎯 Challenge

javascript
// Buat "typing game":
// 1. Tampilkan huruf random di layar (A-Z)
// 2. User harus tekan huruf yang benar
// 3. Kalau benar → skor +1, tampilkan huruf baru
// 4. Kalau salah → skor -1, warna merah sebentar
// 5. Timer 30 detik, tampilkan skor akhir
// Hint: event.key, Math.random(), setTimeout

3.6 Scrolling

💡Analogi

Scroll event itu kayak sensor di eskalator — setiap kali ada pergerakan, sensor memberitahu. Berguna untuk: lazy loading gambar, infinite scroll, "back to top" button, animasi saat scroll.

Mendengarkan Scroll

javascript
window.addEventListener("scroll", function() {
  console.log("Scroll position:", window.pageYOffset);
});

// Untuk elemen tertentu (yang punya overflow:auto/scroll)
const box = document.getElementById("scroll-box");
box.addEventListener("scroll", function() {
  console.log("Box scroll:", box.scrollTop);
});

Contoh: Show/Hide Header saat Scroll

javascript
let lastScroll = 0;
const header = document.querySelector("header");

window.addEventListener("scroll", function() {
  const currentScroll = window.pageYOffset;
  
  if (currentScroll > lastScroll && currentScroll > 100) {
    // Scroll ke bawah & sudah lewat 100px → sembunyikan header
    header.style.transform = "translateY(-100%)";
  } else {
    // Scroll ke atas → tampilkan header
    header.style.transform = "translateY(0)";
  }
  
  lastScroll = currentScroll;
});

Contoh: Infinite Scroll

javascript
window.addEventListener("scroll", function() {
  // Cek apakah sudah dekat bawah halaman
  const nearBottom = 
    window.innerHeight + window.pageYOffset >= 
    document.body.offsetHeight - 200; // 200px sebelum bawah
  
  if (nearBottom) {
    loadMoreContent(); // Muat konten tambahan
  }
});

Contoh: Lazy Load Images

javascript
function lazyLoad() {
  const images = document.querySelectorAll("img[data-src]");
  
  images.forEach(img => {
    const rect = img.getBoundingClientRect();
    
    // Kalau gambar sudah terlihat di viewport
    if (rect.top < window.innerHeight + 200) {
      img.src = img.dataset.src; // Load gambar
      img.removeAttribute("data-src"); // Hapus marker
    }
  });
}

window.addEventListener("scroll", lazyLoad);
lazyLoad(); // Jalankan sekali saat load

Throttling Scroll Event

Scroll event fire SANGAT sering (bisa 60x/detik). Perlu di-throttle:

javascript
// Throttle: jalankan maksimal 1x per 100ms
let isThrottled = false;

window.addEventListener("scroll", function() {
  if (isThrottled) return;
  
  isThrottled = true;
  
  // Kerjakan sesuatu
  console.log("Scroll handled:", window.pageYOffset);
  
  setTimeout(() => {
    isThrottled = false;
  }, 100);
});

// Atau pakai requestAnimationFrame (lebih smooth)
let ticking = false;

window.addEventListener("scroll", function() {
  if (!ticking) {
    requestAnimationFrame(function() {
      handleScroll();
      ticking = false;
    });
    ticking = true;
  }
});

Mencegah Scroll

javascript
// preventDefault pada wheel event
elem.addEventListener("wheel", function(event) {
  event.preventDefault(); // Cegah scroll
}, { passive: false }); // HARUS passive:false untuk bisa preventDefault

// ⚠️ Jangan cegah scroll di seluruh halaman tanpa alasan kuat!

Modern Alternative: Intersection Observer

javascript
// Lebih efisien dari scroll event untuk lazy loading
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      observer.unobserve(img); // Berhenti observe setelah load
    }
  });
}, {
  rootMargin: "200px" // Mulai load 200px sebelum terlihat
});

document.querySelectorAll("img[data-src]").forEach(img => {
  observer.observe(img);
});
⚠️Jebakan!
javascript
// 1. Scroll event fire SANGAT sering — SELALU throttle!
// Tanpa throttle → performa jelek, halaman lag

// 2. passive: true (default di beberapa browser untuk scroll/touch)
// Artinya: browser TIDAK menunggu handler selesai sebelum scroll
// Kalau mau preventDefault → harus { passive: false }

// 3. Scroll position bisa desimal di beberapa browser
// Gunakan Math.round() kalau perlu integer

// 4. Intersection Observer lebih baik untuk kebanyakan use case
// Pakai scroll event hanya kalau perlu posisi scroll exact

🎯 Challenge

javascript
// Buat "progress bar" yang menunjukkan seberapa jauh user sudah scroll:
// 1. Buat <div> fixed di atas halaman (height: 4px, background: blue)
// 2. Lebar berubah dari 0% sampai 100% sesuai scroll position
// 3. Throttle dengan requestAnimationFrame
// 4. Formula: scrolled% = pageYOffset / (scrollHeight - clientHeight) * 100
// 5. Buat halaman panjang (banyak paragraf) untuk testing

Ringkasan Bab 3

Sub-babInti
Mouse Eventsclick, mousedown/up, button, modifier keys, koordinat
Mouse Movementmouseover/out (bubble) vs mouseenter/leave (tidak bubble)
Drag and Dropmousedown → mousemove → mouseup, elementFromPoint
Pointer EventsUnified API (mouse+touch+pen), setPointerCapture
Keyboardkeydown/keyup, event.key (karakter) vs event.code (fisik)
ScrollingThrottle!, Intersection Observer untuk lazy load

Prinsip utama:

  • Pointer events = masa depan (gantikan mouse events)
  • Selalu throttle scroll events
  • event.key untuk shortcut, event.code untuk game
  • Intersection Observer > scroll event untuk visibility detection

Next: Bab 4 — Forms & Controls (input, validasi, focus, submit)

Sudah paham materi ini?

Tandai sebagai selesai untuk melacak progress-mu.