kodingup.camp

Beranda/Advanced JavaScript: Interaksi Browser

Manipulasi Antarmuka Dinamis

Modul 3 dari 58 menit bacaDynamic Interface Manipulation

Isi modul

    Kenapa ini penting

    Di modul sebelumnya kamu belajar menemukan elemen. Sekarang saatnya mengubahnya dan membuat halaman bereaksi terhadap pengguna. Coba ingat aplikasi yang kamu pakai setiap hari: menekan tombol "+" di keranjang lalu angkanya bertambah, mengetik di kolom pencarian lalu daftar langsung menyaring, mencentang tugas di to-do list lalu tugas itu dicoret. Semuanya terjadi tanpa reload halaman, dan semuanya adalah kombinasi tiga hal: membuat/mengubah elemen, mendengarkan event, dan menyimpan state.

    Analoginya seperti panggung teater. HTML adalah set panggung yang sudah disiapkan sebelum pertunjukan. JavaScript adalah kru panggung yang bisa memindahkan properti, menyalakan lampu, atau menambah kursi di tengah pertunjukan. Event adalah aba-aba dari sutradara: "saat aktor masuk, nyalakan lampu sorot". Kru tidak bergerak sembarangan; mereka menunggu aba-aba.

    Modul ini juga membahas satu isu keamanan yang sering diremehkan pemula, yaitu XSS (Cross-Site Scripting). Salah memilih antara innerHTML dan textContent bisa membuat situsmu menjalankan kode jahat milik orang lain. Ini bukan teori: celah XSS rutin masuk daftar kerentanan web paling umum.

    Konsep inti

    Membuat dan menyisipkan elemen

    Langkahnya selalu sama: buat, isi, sisipkan.

    const li = document.createElement("li"); // 1. buat (belum tampil di layar)
    li.textContent = "Cappuccino";           // 2. isi
    li.className = "item";
    document.querySelector("#daftar-menu").append(li); // 3. sisipkan → sekarang tampil
    

    Method penyisipan yang perlu kamu tahu:

    Method Posisi
    parent.append(a, b, "teks") di akhir anak-anak parent; bisa banyak sekaligus dan menerima string
    parent.prepend(x) di awal anak-anak parent
    el.before(x) / el.after(x) tepat sebelum atau sesudah el, sebagai saudara
    el.replaceWith(x) mengganti el dengan x
    parent.replaceChildren(a, b) mengganti seluruh isi parent sekaligus
    el.remove() menghapus el dari DOM
    parent.appendChild(x) versi lama: hanya satu node, tidak menerima string

    Kalau kamu menambah banyak elemen sekaligus (misalnya 100 produk), kumpulkan dulu di sebuah DocumentFragment lalu sisipkan sekali. Fragment adalah wadah sementara di memori, jadi DOM yang tampil hanya disentuh satu kali, bukan 100 kali.

    innerHTML vs textContent, dan bahaya XSS

    Properti Membaca Menulis
    textContent semua teks di dalam elemen string dianggap teks biasa; tag ditampilkan apa adanya
    innerHTML HTML di dalam elemen sebagai string string di-parse sebagai HTML; tag menjadi elemen
    innerText teks yang terlihat (memperhitungkan CSS) mirip textContent, tapi lebih lambat

    innerHTML enak dipakai karena bisa menulis banyak struktur sekaligus dengan template literal. Masalahnya muncul ketika isi string berasal dari pengguna atau sumber luar (API, URL, database). Misalkan kamu menampilkan komentar dengan div.innerHTML = komentar, lalu seseorang mengirim komentar:

    <img src="x" onerror="alert(document.cookie)">
    

    Browser akan membuat tag <img>, gambarnya gagal dimuat, lalu onerror menjalankan JavaScript milik penyerang di halaman milikmu. Penyerang bisa mencuri sesi login, mengubah tampilan, atau mengarahkan pengunjung ke situs palsu. Inilah XSS.

    Aturan praktisnya:

    1. Data dari pengguna atau API ditampilkan dengan textContent (atau createElement + textContent).
    2. innerHTML hanya untuk HTML yang kamu tulis sendiri dan tidak mengandung data luar, misalnya mengosongkan elemen dengan el.innerHTML = "" atau kerangka statis.
    3. Kalau terpaksa menyisipkan data ke template innerHTML, escape dulu karakter & < > " ' menjadi entity HTML. Contoh function escapeHTML ada di bawah.

    classList dan style

    Cara terbaik mengubah tampilan adalah mengganti class, lalu biarkan CSS yang mengatur gayanya. JavaScript cukup menyalakan atau mematikan "saklar".

    el.style mengubah inline style satu per satu. Nama properti CSS ditulis camelCase: el.style.backgroundColor = "tomato". Pakai style hanya untuk nilai yang benar-benar dinamis, misalnya posisi hasil hitungan atau lebar progress bar. Pilihan lain yang rapi adalah mengubah CSS custom property: el.style.setProperty("--progress", "70%"). Untuk membaca gaya akhir yang benar-benar diterapkan (termasuk dari file CSS), pakai getComputedStyle(el).

    Event listener dan event object

    el.addEventListener("click", handler) mendaftarkan function handler yang dipanggil setiap kali event terjadi. Hindari atribut onclick="..." di HTML. Atribut itu mencampur logika dengan markup dan hanya bisa menyimpan satu handler.

    Event yang sering dipakai:

    Kategori Event
    Mouse/pointer click, dblclick, mouseenter, mouseleave, pointerdown
    Keyboard keydown, keyup (gunakan e.key, misalnya "Enter" atau "Escape")
    Form input (setiap ketikan), change (setelah nilai selesai diubah), submit, focus, blur
    Dokumen DOMContentLoaded (HTML selesai di-parse), scroll, resize

    Handler menerima event object (biasa dinamai e atau event) berisi detail kejadian:

    Opsi ketiga addEventListener juga berguna: { once: true } membuat listener otomatis dilepas setelah sekali jalan. Untuk melepas listener secara manual dengan removeEventListener, kamu harus memberikan function yang sama (bukan arrow function baru).

    Bubbling dan capturing

    Saat kamu mengklik sebuah tombol di dalam kartu di dalam main, event tidak hanya terjadi di tombol. Event menempuh tiga fase:

    1. Capturing: turun dari window → document → html → body → ... → menuju target.
    2. Target: sampai di elemen yang diklik.
    3. Bubbling: naik kembali dari target ke body, html, document, window.

    Secara default listener berjalan di fase bubbling. Itu sebabnya listener di main tetap terpicu saat kamu mengklik tombol di dalamnya. Pasang { capture: true } untuk mendengarkan di fase capturing. Analoginya seperti batu yang dijatuhkan ke kolam: capturing adalah batu yang tenggelam ke dasar, bubbling adalah gelembung yang naik ke permukaan dan melewati setiap lapisan air.

    Tidak semua event bubbling. Contoh yang sering menjebak: focus dan blur tidak bubbling, sedangkan pasangannya focusin dan focusout bubbling.

    Event delegation

    Bayangkan katalog dengan 200 produk, masing-masing punya tombol "Tambah ke keranjang". Memasang 200 listener boros memori, dan lebih repot lagi: produk yang ditambahkan belakangan (misalnya dari fetch) tidak punya listener. Event delegation memanfaatkan bubbling. Pasang satu listener di elemen pembungkus, lalu cek e.target.closest(...) untuk mengetahui tombol mana yang diklik. Ini pola yang akan kamu pakai di proyek akhir.

    preventDefault dan form handling

    Beberapa elemen punya perilaku bawaan. Link berpindah halaman, form dikirim dan halaman reload, checkbox tercentang. e.preventDefault() membatalkan perilaku itu sehingga JavaScript yang mengambil alih. Untuk form, polanya:

    1. Dengarkan event submit di <form>, bukan click di tombol. Event submit juga terpicu saat pengguna menekan Enter.
    2. Panggil e.preventDefault().
    3. Baca data dengan new FormData(form) lalu Object.fromEntries(...) untuk menjadikannya object. Ini membutuhkan atribut name di setiap input.
    4. Validasi (regex dari modul 1), tampilkan pesan error, atau proses data.
    5. form.reset() untuk mengosongkan.

    Atribut HTML seperti required, type="email", minlength, dan pattern memberikan validasi bawaan browser secara gratis. Tetap validasi lagi di JavaScript untuk pesan yang lebih ramah, dan wajib validasi ulang di server, karena validasi di browser bisa dilewati siapa saja lewat DevTools.

    localStorage: ingatan browser

    localStorage menyimpan pasangan key-value di browser, dan datanya tetap ada walau tab ditutup atau komputer dimatikan. Batasnya kecil, umumnya sekitar 5 MB per origin, dan hanya bisa menyimpan string.

    Jangan simpan data sensitif (password, token rahasia, nomor kartu) di localStorage, karena script apa pun di halaman itu, termasuk script jahat dari celah XSS, bisa membacanya. Bungkus JSON.parse dengan try/catch karena data bisa rusak atau diubah manual oleh pengguna.

    Contoh kode

    Membuat elemen, fragment, dan XSS

    const daftar = document.querySelector("#daftar-menu");
    
    // Tambah satu item dengan aman
    function tambahItem(nama, harga) {
      const li = document.createElement("li");
      li.className = "item";
      li.dataset.harga = harga;
      li.textContent = `${nama}, Rp${harga.toLocaleString("id-ID")}`; // aman: dianggap teks
      daftar.append(li);
      return li;
    }
    tambahItem("Cappuccino", 27000); // tampil: 'Cappuccino, Rp27.000'
    
    // Banyak item sekaligus lewat DocumentFragment
    const menuBaru = [
      { nama: "Mocha", harga: 28000 },
      { nama: "Americano", harga: 20000 },
      { nama: "Matcha Latte", harga: 30000 },
    ];
    const frag = document.createDocumentFragment();
    for (const m of menuBaru) {
      const li = document.createElement("li");
      li.textContent = m.nama;
      frag.append(li);
    }
    daftar.append(frag); // satu kali sisip ke DOM
    
    // Demonstrasi XSS
    const komentarJahat = `<img src="x" onerror="alert('Kamu kena XSS!')">`;
    const kotak = document.createElement("div");
    document.body.append(kotak);
    
    kotak.textContent = komentarJahat; // AMAN → teks tag-nya terlihat apa adanya
    // kotak.innerHTML = komentarJahat; // BAHAYA → alert muncul, script penyerang jalan
    
    // Kalau tetap mau pakai template innerHTML, escape data luar dulu
    function escapeHTML(str) {
      return String(str).replace(/[&<>"']/g, (c) => ({
        "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
      })[c]);
    }
    console.log(escapeHTML(komentarJahat));
    // → '&lt;img src=&quot;x&quot; onerror=&quot;alert(&#39;Kamu kena XSS!&#39;)&quot;&gt;'
    
    const nama = "<b>Budi</b>";
    kotak.innerHTML = `<p class="sapa">Halo, ${escapeHTML(nama)}!</p>`; // tampil: Halo, <b>Budi</b>!
    

    classList, style, dan dark mode

    <button id="tombol-tema" aria-pressed="false">🌙 Mode gelap</button>
    <div class="progress"><div class="progress__bar"></div></div>
    
    <style>
      body { background: #fff; color: #222; transition: background .3s, color .3s; }
      body.gelap { background: #121212; color: #eee; }
      .progress { position: sticky; top: 0; height: 8px; background: #ddd; }
      .progress__bar { height: 100%; width: var(--progress, 0%); background: #e67e22; }
    </style>
    
    const tombolTema = document.querySelector("#tombol-tema");
    
    function terapkanTema(gelap) {
      document.body.classList.toggle("gelap", gelap);
      tombolTema.textContent = gelap ? "☀️ Mode terang" : "🌙 Mode gelap";
      tombolTema.setAttribute("aria-pressed", String(gelap)); // aksesibilitas untuk pembaca layar
    }
    
    tombolTema.addEventListener("click", () => {
      const gelap = !document.body.classList.contains("gelap");
      terapkanTema(gelap);
      localStorage.setItem("tema", gelap ? "gelap" : "terang");
    });
    
    // Terapkan tema tersimpan saat halaman dibuka
    terapkanTema(localStorage.getItem("tema") === "gelap");
    
    // style untuk nilai dinamis: progress bar mengikuti posisi scroll
    const bar = document.querySelector(".progress__bar");
    window.addEventListener("scroll", () => {
      const maksScroll = document.documentElement.scrollHeight - window.innerHeight;
      const persen = maksScroll > 0 ? (window.scrollY / maksScroll) * 100 : 0;
      bar.style.setProperty("--progress", `${persen}%`);
    });
    
    console.log(getComputedStyle(document.body).backgroundColor); // → 'rgb(255, 255, 255)' atau 'rgb(18, 18, 18)'
    

    Event object, bubbling, capturing

    <section id="luar">
      <div id="tengah">
        <button id="dalam">Klik saya</button>
      </div>
    </section>
    
    const log = (label) => (e) =>
      console.log(`${label} | target: ${e.target.id} | currentTarget: ${e.currentTarget.id}`);
    
    document.querySelector("#luar").addEventListener("click", log("luar (bubble)"));
    document.querySelector("#tengah").addEventListener("click", log("tengah (bubble)"));
    document.querySelector("#dalam").addEventListener("click", log("dalam"));
    document.querySelector("#luar").addEventListener("click", log("luar (capture)"), { capture: true });
    
    // Klik tombol, urutan di console:
    // → luar (capture) | target: dalam | currentTarget: luar
    // → dalam | target: dalam | currentTarget: dalam
    // → tengah (bubble) | target: dalam | currentTarget: tengah
    // → luar (bubble) | target: dalam | currentTarget: luar
    
    // Keyboard: tutup modal dengan Escape
    document.addEventListener("keydown", (e) => {
      if (e.key === "Escape") console.log("Tutup modal"); // → 'Tutup modal' saat Esc ditekan
    });
    
    // Listener sekali jalan
    document.querySelector("#dalam").addEventListener(
      "click",
      () => console.log("Bonus klik pertama!"),
      { once: true }
    );
    

    Coba tambahkan e.stopPropagation() di handler tengah. Log luar (bubble) tidak akan muncul lagi karena perjalanan event dihentikan di tengah. Gunakan ini dengan hemat, karena kode lain (misalnya analytics atau penutup dropdown) sering bergantung pada bubbling.

    Event delegation: to-do list lengkap dengan localStorage

    <form id="form-todo">
      <input name="judul" placeholder="Mau ngerjain apa?" required maxlength="80">
      <button>Tambah</button>
    </form>
    <p id="info-todo"></p>
    <ul id="list-todo"></ul>
    
    <style>
      .selesai span { text-decoration: line-through; opacity: .5; }
    </style>
    
    const KUNCI = "todo-v1";
    const form = document.querySelector("#form-todo");
    const list = document.querySelector("#list-todo");
    const info = document.querySelector("#info-todo");
    
    // State: sumber kebenaran ada di array ini, bukan di DOM
    function muat() {
      try {
        return JSON.parse(localStorage.getItem(KUNCI)) ?? [];
      } catch {
        return []; // data rusak → mulai dari kosong
      }
    }
    let todos = muat();
    
    function simpan() {
      localStorage.setItem(KUNCI, JSON.stringify(todos));
    }
    
    // Render ulang seluruh list dari state
    function render() {
      const frag = document.createDocumentFragment();
    
      for (const t of todos) {
        const li = document.createElement("li");
        li.dataset.id = t.id;
        li.classList.toggle("selesai", t.selesai);
    
        const cek = document.createElement("input");
        cek.type = "checkbox";
        cek.checked = t.selesai;
        cek.dataset.aksi = "centang";
    
        const teks = document.createElement("span");
        teks.textContent = t.judul; // input pengguna → textContent, anti-XSS
    
        const hapus = document.createElement("button");
        hapus.textContent = "Hapus";
        hapus.dataset.aksi = "hapus";
    
        li.append(cek, " ", teks, " ", hapus);
        frag.append(li);
      }
      list.replaceChildren(frag); // ganti seluruh isi sekaligus
    
      const sisa = todos.filter((t) => !t.selesai).length;
      info.textContent = `${sisa} dari ${todos.length} tugas belum selesai`;
    }
    
    // Form handling
    form.addEventListener("submit", (e) => {
      e.preventDefault(); // cegah reload halaman
      const data = Object.fromEntries(new FormData(form)); // → { judul: '...' }
      const judul = data.judul.trim();
      if (!judul) return;
    
      todos.push({ id: Date.now(), judul, selesai: false });
      simpan();
      render();
      form.reset();
      form.elements.judul.focus();
    });
    
    // SATU listener untuk semua tombol & checkbox, termasuk yang dibuat nanti
    list.addEventListener("click", (e) => {
      const aksi = e.target.dataset.aksi;
      if (!aksi) return; // klik di area lain, abaikan
    
      const id = Number(e.target.closest("li").dataset.id);
    
      if (aksi === "hapus") {
        todos = todos.filter((t) => t.id !== id);
      } else if (aksi === "centang") {
        todos = todos.map((t) => (t.id === id ? { ...t, selesai: !t.selesai } : t));
      }
      simpan();
      render();
    });
    
    render();
    // Tambah "Belajar DOM" lalu "Push ke GitHub" → info: '2 dari 2 tugas belum selesai'
    // Centang yang pertama → info: '1 dari 2 tugas belum selesai'
    // Reload halaman → data tetap ada karena dibaca ulang dari localStorage
    

    Perhatikan pola state → render. Data disimpan di array todos, lalu tampilan dibangun ulang dari array itu setiap kali ada perubahan. Kamu tidak perlu mengedit DOM satu per satu dan khawatir tampilan tidak sinkron dengan data. Pola inilah yang nanti diotomatiskan oleh React.

    Validasi form dengan pesan per kolom

    <form id="form-daftar" novalidate>
      <label>Email <input name="email" type="email"></label>
      <small class="error" data-untuk="email"></small>
      <label>No. HP <input name="hp" inputmode="tel"></label>
      <small class="error" data-untuk="hp"></small>
      <button>Daftar</button>
    </form>
    
    const formDaftar = document.querySelector("#form-daftar");
    const aturan = {
      email: { pola: /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/, pesan: "Format email belum benar" },
      hp: { pola: /^(?:\+62|62|0)8[1-9]\d{7,10}$/, pesan: "Nomor HP Indonesia tidak valid" },
    };
    
    formDaftar.addEventListener("submit", (e) => {
      e.preventDefault();
      const data = Object.fromEntries(new FormData(formDaftar));
      data.hp = data.hp.replace(/[\s-]/g, "");
    
      let semuaValid = true;
      for (const [kolom, { pola, pesan }] of Object.entries(aturan)) {
        const valid = pola.test(data[kolom]);
        formDaftar.querySelector(`[data-untuk="${kolom}"]`).textContent = valid ? "" : pesan;
        formDaftar.elements[kolom].setAttribute("aria-invalid", String(!valid));
        if (!valid) semuaValid = false;
      }
    
      if (semuaValid) console.log("Kirim ke server:", data); // → { email: '...', hp: '0812...' }
    });
    
    // Validasi langsung saat kolom ditinggalkan (blur tidak bubble, focusout bubble)
    formDaftar.addEventListener("focusout", (e) => {
      const kolom = e.target.name;
      if (!aturan[kolom]) return;
      const nilai = e.target.value.replace(/[\s-]/g, "");
      const valid = nilai === "" || aturan[kolom].pola.test(nilai);
      formDaftar.querySelector(`[data-untuk="${kolom}"]`).textContent = valid ? "" : aturan[kolom].pesan;
    });
    

    Atribut novalidate mematikan balon validasi bawaan browser supaya pesan custom kita yang tampil. Karena blur tidak bubbling, delegation di form memakai pasangannya, focusout.

    Kesalahan umum

    1. Memasukkan data pengguna atau API ke innerHTML. Ini pintu XSS. Pakai textContent atau createElement. Kalau terpaksa memakai template string, escape dulu dengan escapeHTML.
    2. Mendengarkan click di tombol submit alih-alih submit di form. Akibatnya, menekan Enter tetap me-reload halaman karena preventDefault tidak pernah terpanggil. Selalu pasang listener submit di elemen <form>.
    3. Memanggil handler, bukan memberikannya. btn.addEventListener("click", simpan()) langsung menjalankan simpan sekali dan mendaftarkan hasilnya (undefined). Tulis simpan tanpa tanda kurung, atau () => simpan(id) kalau butuh argumen.
    4. Bingung e.target dan e.currentTarget. Pada tombol berisi <span> atau ikon, e.target bisa jadi span-nya. Di delegation, pakai e.target.closest("button") supaya selalu dapat tombolnya.
    5. Menyimpan object langsung ke localStorage. localStorage.setItem("cart", [1, 2]) tersimpan sebagai "1,2", dan object menjadi "[object Object]". Selalu JSON.stringify saat menyimpan dan JSON.parse saat membaca.

    Latihan

    1. Mudah, Buat tombol "Suka" dengan penghitung. Setiap diklik angkanya bertambah, dan setelah reload angkanya tetap (pakai localStorage).
    2. Sedang, Buat kolom pencarian yang menyaring <li> di #daftar-menu secara langsung saat pengguna mengetik (event input), tidak peduli huruf besar/kecil, dan menampilkan teks "Tidak ada menu yang cocok" bila hasilnya kosong.
    3. Menantang, Buat komponen tab (tiga tombol tab dan tiga panel). Pakai satu listener (delegation) di wadah tab, tandai tab aktif dengan class aktif dan aria-selected, tampilkan hanya panel yang sesuai, dan dukung navigasi keyboard panah kiri/kanan.
    Lihat solusi
    <!-- 1. Mudah -->
    <button id="suka">❤️ <span id="jumlah-suka">0</span></button>
    
    const tombolSuka = document.querySelector("#suka");
    const jumlahSuka = document.querySelector("#jumlah-suka");
    let suka = Number(localStorage.getItem("suka")) || 0;
    jumlahSuka.textContent = suka;
    
    tombolSuka.addEventListener("click", () => {
      suka++;
      jumlahSuka.textContent = suka;
      localStorage.setItem("suka", suka); // angka otomatis jadi string, dibaca balik dengan Number()
    });
    
    <!-- 2. Sedang: letakkan di atas #daftar-menu -->
    <input id="cari" type="search" placeholder="Cari menu...">
    <p id="kosong" hidden>Tidak ada menu yang cocok</p>
    
    const cari = document.querySelector("#cari");
    const kosong = document.querySelector("#kosong");
    
    cari.addEventListener("input", () => {
      const kata = cari.value.trim().toLowerCase();
      let tampil = 0;
      document.querySelectorAll("#daftar-menu li").forEach((li) => {
        const cocok = li.textContent.toLowerCase().includes(kata);
        li.hidden = !cocok; // atribut hidden = display:none bawaan browser
        if (cocok) tampil++;
      });
      kosong.hidden = tampil > 0;
    });
    
    <!-- 3. Menantang -->
    <div class="tabs">
      <div role="tablist" id="tablist">
        <button role="tab" class="aktif" aria-selected="true" data-panel="p1">Kopi</button>
        <button role="tab" aria-selected="false" tabindex="-1" data-panel="p2">Teh</button>
        <button role="tab" aria-selected="false" tabindex="-1" data-panel="p3">Snack</button>
      </div>
      <section id="p1" role="tabpanel">Daftar kopi...</section>
      <section id="p2" role="tabpanel" hidden>Daftar teh...</section>
      <section id="p3" role="tabpanel" hidden>Daftar snack...</section>
    </div>
    
    const tablist = document.querySelector("#tablist");
    const tabs = [...tablist.querySelectorAll('[role="tab"]')];
    
    function aktifkan(tab) {
      tabs.forEach((t) => {
        const aktif = t === tab;
        t.classList.toggle("aktif", aktif);
        t.setAttribute("aria-selected", String(aktif));
        t.tabIndex = aktif ? 0 : -1;
        document.getElementById(t.dataset.panel).hidden = !aktif;
      });
      tab.focus();
    }
    
    tablist.addEventListener("click", (e) => {
      const tab = e.target.closest('[role="tab"]');
      if (tab) aktifkan(tab);
    });
    
    tablist.addEventListener("keydown", (e) => {
      const i = tabs.indexOf(document.activeElement);
      if (i === -1) return;
      if (e.key === "ArrowRight") aktifkan(tabs[(i + 1) % tabs.length]);
      if (e.key === "ArrowLeft") aktifkan(tabs[(i - 1 + tabs.length) % tabs.length]);
    });
    

    Operasi % tabs.length membuat navigasi berputar: dari tab terakhir, panah kanan kembali ke tab pertama.

    Ringkasan

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.