Sekali pengguna menyimpan lebih dari segelintir catatan, menggulir satu per satu bukan lagi cara mencari. Mereka mengetik satu kata — “kopi”, misalnya — dan mengharapkan aplikasi menampilkan catatan resep minuman itu, sekalipun kata kuncinya ditulis dengan huruf besar sebagian, sekalipun kata “kopi” hanya muncul di dalam isi catatan dan tidak ada di judulnya. Pencarian yang terasa wajar bagi pengguna ternyata menyimpan serangkaian keputusan teknis yang harus dinyatakan eksplisit: dicocokkan di mana, dengan cara apa, dan bagaimana menangani kata kunci yang berisi beberapa kata sekaligus.

Bagian ini menambahkan satu fungsi murni baru ke catatan.mjs — cariCatatan(catatan, kataKunci) — plus kotak pencarian kecil di antarmuka. Fungsinya menerima daftar catatan (biasanya hasil daftarCatatan()) dan mengembalikan array baru berisi catatan yang cocok; tidak ada satu pun state yang berubah karena pencarian. Tujuh uji baru mengunci semua keputusannya, dan uji proyek bertambah dari tiga belas menjadi dua puluh.

Kontrak Pencarian #

Sebelum menulis kode, mari kita tetapkan aturan mainnya — dan setiap aturan berangkat dari perilaku yang diharapkan pengguna:

  • Dicocokkan pada judul ATAU isi. Satu catatan diperlakukan sebagai gabungan teksnya: kata kunci yang muncul di judul maupun di isi sama-sama dihitung cocok.
  • Tidak peka huruf besar-kecil. KOPI, Kopi, dan kopi adalah kata kunci yang sama. Penyimpanan catatan memakai huruf kapital di awal judul, sedangkan pengguna jarang repot mengetiknya.
  • Potongan kata pun cocok. Mencari gul menemukan catatan berjudul “Gula Aren”. Pencocokan berbasis includes() yang menyasar potongan teks, bukan kata utuh — pengguna tidak harus menebak ejaan lengkapnya.
  • Spasi di ujung diabaikan. Ketikan yang diakhiri spasi tanpa sengaja tidak boleh mengubah hasil.
  • Beberapa kata berarti “semua harus ada”. Kata kunci kopi susu menampilkan catatan yang memuat kedua kata itu — satu di judul dan satu di isi pun dihitung — dan menyembunyikan catatan yang hanya memuat salah satunya. Ini perilaku mesin pencari yang sudah akrab bagi siapa pun.
  • Kata kunci kosong berarti tanpa filter. Kolom pencarian yang baru dikosongkan mengembalikan seluruh catatan.

Tabel berikut merangkum kontraknya dengan contoh; data di kolom kanan adalah dua catatan yang dipakai demo di bawah:

Kata kunciCatatan yang cocokAlasan
'' (kosong)semua catatantanpa filter
'GULA'“Resep Kopi Gula Aren”huruf besar dinormalkan
'gul'“Resep Kopi Gula Aren”potongan kata cukup
'kopi susu'“Resep Kopi Gula Aren”kedua kata ada di catatan yang sama
'kamera'tidak adatidak ada potongan yang cocok

Normalisasi dan Pencocokan #

Berikut bagian baru di catatan.mjs — ditambahkan setelah daftarCatatan, sebelum resetCatatan. Dua fungsi kecil mengerjakan seluruh pekerjaan:

js catatan.mjs — bagian pencarian
// Pencarian: fungsi murni yang menerima daftar catatan apa pun (biasanya
// hasil daftarCatatan()) dan mengembalikan array baru berisi yang cocok.

function normalisasiTeks(teks) {
  return teks.toLowerCase().trim();
}

export function cariCatatan(catatan, kataKunci) {
  const kunci = normalisasiTeks(kataKunci);
  if (kunci === '') {
    return [...catatan];
  }
  const kataKunciBanyak = kunci.split(/\s+/);
  return catatan.filter((item) => {
    const teks = normalisasiTeks(`${item.judul} ${item.isi}`);
    return kataKunciBanyak.every((kataTunggal) => teks.includes(kataTunggal));
  });
}

Mari kita bedah baris demi baris:

  1. Baris 65–67 (function normalisasiTeks(teks)): dua langkah kecil — toLowerCase() menyeragamkan huruf, trim() membuang spasi di kedua ujung. Helper normalisasiTeks sengaja dibuat terpisah karena dipanggil dua kali: sekali untuk kata kunci, sekali untuk teks tiap catatan. Menyamakan kedua sisi dengan fungsi yang sama adalah cara paling aman agar “tidak peka huruf besar” berlaku merata.
  2. Baris 70 (const kunci = normalisasiTeks(kataKunci);): kata kunci dinormalkan sekali sebelum loop, bukan berulang kali di dalamnya — pekerjaan yang sama tidak dilakukan ulang untuk setiap catatan.
  3. Baris 71–73 (kata kunci kosong): bila hasil normalisasi adalah string kosong, fungsi mengembalikan salinan seluruh catatan ([...catatan] — array baru, isi sama). Kontrak “kosong berarti tanpa filter” terpenuhi sejak sini, tanpa perlu menjalankan pencocokan.
  4. Baris 74 (kunci.split(/\s+/)): kata kunci dipecah menjadi array kata dengan pola regex /\s+/ — “satu karakter spasi atau lebih”. Mencari "kopi susu" dengan dua spasi tetap menghasilkan dua kata bersih, bukan kata kosong di tengahnya. Regex dasar ini dibahas pada section Strings dan Regex.
  5. Baris 75–77 (pencocokan): filter menyeleksi catatan satu per satu. Untuk tiap catatan, judul dan isi digabung menjadi satu string (${item.judul} ${item.isi}), dinormalkan, lalu every memeriksa apakah semua kata kunci muncul di dalamnya lewat includes. every berhenti segera begitu menemukan satu kata yang tidak ada — catatan itu langsung tersaring keluar.
  6. return catatan.filter(...): hasilnya array baru; daftar asli tidak tersentuh. Karena fungsi ini menerima array apa pun, ia bisa diuji tanpa menyentuh state module sama sekali — sifat yang langsung dipetik uji di bagian akhir.

Membuktikan Pencarian dari Terminal #

Demo berikut membangun tiga catatan lalu mencoba enam kata kunci yang menghuni tiap baris kontrak di atas:

js demo-cari.mjs
// demo-cari.mjs — mencoba pencarian catatan dari terminal.
import {
  buatCatatan,
  cariCatatan,
  daftarCatatan,
  resetCatatan,
} from './catatan.mjs';

resetCatatan();
buatCatatan('Resep Kopi Gula Aren', 'Seduh kopi, tambah gula aren dan susu.');
buatCatatan('Belanja Mingguan', 'Telur, beras, kopi, gula aren.');
buatCatatan('Jadwal Belajar', 'Latihan module ES besok malam.');

function tampilkan(kataKunci) {
  const hasil = cariCatatan(daftarCatatan(), kataKunci);
  console.log(`Cari "${kataKunci}" -> ${hasil.length} catatan`);
  for (const catatan of hasil) {
    console.log(`  #${catatan.id} ${catatan.judul}`);
  }
}

tampilkan('kopi');
tampilkan('GULA');
tampilkan('module');
tampilkan('kopi susu');
tampilkan('mingguan beras');
tampilkan('kamera');

Jalankan dari folder bagian ini:

bash
node demo-cari.mjs

Output yang Dihasilkan:

text
Cari "kopi" -> 2 catatan
  #1 Resep Kopi Gula Aren
  #2 Belanja Mingguan
Cari "GULA" -> 2 catatan
  #1 Resep Kopi Gula Aren
  #2 Belanja Mingguan
Cari "module" -> 1 catatan
  #3 Jadwal Belajar
Cari "kopi susu" -> 1 catatan
  #1 Resep Kopi Gula Aren
Cari "mingguan beras" -> 1 catatan
  #2 Belanja Mingguan
Cari "kamera" -> 0 catatan

Enam baris percobaan mewakili enam aturan. kopi menemukan dua catatan — kata itu ada di judul catatan pertama dan di isi catatan kedua, bukti pencocokan lintas judul dan isi. GULA menghasilkan daftar yang sama persis dengan gula, bukti normalisasi huruf. module hanya cocok dengan isi catatan ketiga. kopi susu tinggal satu catatan: catatan kedua memuat kopi tetapi tidak memuat susu, sehingga tersaring keluar oleh every. Contoh mingguan beras paling menarik — kedua kata ada hanya pada catatan kedua, satu di judul dan satu di isi, dan itupun dihitung cocok. Terakhir, kamera mengembalikan array kosong tanpa galat.

Kotak Pencarian di Antarmuka #

Antarmuka kini punya kolom pencarian dan baris ringkasan. Berikut index.html.txt lengkap pada akhir Bagian 2 — markup bertambah dua elemen di antara form dan daftar:

html index.html.txt
<!DOCTYPE html>
<html lang="id">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Aplikasi Catatan</title>
  <link rel="stylesheet" href="../styles/styles.css">
</head>
<body>
  <main class="aplikasi">
    <h1>Catatan Saya</h1>
    <form id="form-catatan" class="form-catatan">
      <input type="text" id="input-judul" name="judul" placeholder="Judul catatan..." maxlength="100" autocomplete="off">
      <textarea id="input-isi" name="isi" rows="4" placeholder="Tulis isi catatan..."></textarea>
      <div class="aksi-form">
        <button type="button" id="tombol-batal" class="tombol-batal" hidden>Batal</button>
        <button type="submit" id="tombol-kirim" class="tombol-kirim">Tambah</button>
      </div>
    </form>
    <p id="pesan" class="pesan" hidden></p>
    <div class="pencarian">
      <input type="search" id="input-cari" placeholder="Cari catatan..." autocomplete="off">
    </div>
    <p id="ringkasan" class="ringkasan"></p>
    <ul id="daftar-catatan" class="daftar-catatan"></ul>
  </main>
  <script type="module">
    import {
      buatCatatan,
      ubahCatatan,
      hapusCatatan,
      daftarCatatan,
      cariCatatan,
    } from './catatan.mjs';

    let idSedangDiubah = null;
    let kataKunci = '';

    const form = document.querySelector('#form-catatan');
    const inputJudul = document.querySelector('#input-judul');
    const inputIsi = document.querySelector('#input-isi');
    const tombolKirim = document.querySelector('#tombol-kirim');
    const tombolBatal = document.querySelector('#tombol-batal');
    const inputCari = document.querySelector('#input-cari');
    const elemenDaftar = document.querySelector('#daftar-catatan');
    const elemenPesan = document.querySelector('#pesan');
    const elemenRingkasan = document.querySelector('#ringkasan');

    function bersihkanModeEdit() {
      idSedangDiubah = null;
      inputJudul.value = '';
      inputIsi.value = '';
      tombolKirim.textContent = 'Tambah';
      tombolBatal.hidden = true;
    }

    function mulaiModeEdit(catatan) {
      idSedangDiubah = catatan.id;
      inputJudul.value = catatan.judul;
      inputIsi.value = catatan.isi;
      tombolKirim.textContent = 'Simpan Perubahan';
      tombolBatal.hidden = false;
      inputJudul.focus();
    }

    function render() {
      elemenDaftar.textContent = '';
      const semua = daftarCatatan();
      const tampilan = cariCatatan(semua, kataKunci);
      elemenRingkasan.textContent = kataKunci.trim() === ''
        ? `${semua.length} catatan tersimpan`
        : `${tampilan.length} dari ${semua.length} catatan cocok`;

      for (const catatan of tampilan) {
        const item = document.createElement('li');
        item.className = 'kartu';

        const judul = document.createElement('h2');
        judul.textContent = catatan.judul;

        const isi = document.createElement('p');
        isi.textContent = catatan.isi;

        const aksi = document.createElement('div');
        aksi.className = 'aksi-kartu';

        const tombolUbah = document.createElement('button');
        tombolUbah.className = 'tombol-ubah';
        tombolUbah.textContent = 'Ubah';
        tombolUbah.dataset.id = String(catatan.id);

        const tombolHapus = document.createElement('button');
        tombolHapus.className = 'tombol-hapus';
        tombolHapus.textContent = 'Hapus';
        tombolHapus.dataset.id = String(catatan.id);

        aksi.append(tombolUbah, tombolHapus);
        item.append(judul, isi, aksi);
        elemenDaftar.append(item);
      }
    }

    form.addEventListener('submit', (event) => {
      event.preventDefault();
      try {
        if (idSedangDiubah === null) {
          buatCatatan(inputJudul.value, inputIsi.value);
        } else {
          ubahCatatan(idSedangDiubah, {
            judul: inputJudul.value,
            isi: inputIsi.value,
          });
        }
        bersihkanModeEdit();
        elemenPesan.hidden = true;
        render();
      } catch (galat) {
        elemenPesan.textContent = galat.message;
        elemenPesan.hidden = false;
      }
    });

    tombolBatal.addEventListener('click', () => {
      bersihkanModeEdit();
      elemenPesan.hidden = true;
    });

    inputCari.addEventListener('input', () => {
      kataKunci = inputCari.value;
      render();
    });

    elemenDaftar.addEventListener('click', (event) => {
      if (event.target.matches('.tombol-ubah')) {
        const id = Number(event.target.dataset.id);
        const catatan = daftarCatatan().find((item) => item.id === id);
        if (catatan !== undefined) {
          mulaiModeEdit(catatan);
        }
      }
      if (event.target.matches('.tombol-hapus')) {
        const id = Number(event.target.dataset.id);
        if (idSedangDiubah === id) {
          bersihkanModeEdit();
        }
        hapusCatatan(id);
        render();
      }
    });

    render();
  </script>
</body>
</html>

Tiga perubahan pada skrip yang layak dibedah:

  1. let kataKunci = '';: kata kunci yang sedang aktif kini bagian dari state tampilan, di samping idSedangDiubah. Perhatikan bahwa kata kunci tidak masuk ke catatan.mjs — menyimpan teks pencarian bukan urusan penyimpanan data.
  2. Baris inputCari.addEventListener('input', ...): setiap ketikan memicu event input (dibahas pada chapter Form Events), dan setiap pemicuan hanya melakukan dua hal: mencatat nilai terbaru lalu render(). Tidak ada logika pencocokan di skrip — render selalu bertanya pada cariCatatan.
  3. const tampilan = cariCatatan(semua, kataKunci); di dalam render(): daftar yang digambar bukan lagi seluruh catatan, melainkan hasil saringan; paragraf ringkasan melengkapinya dengan angka yang jujur — ${tampilan.length} dari ${semua.length} catatan cocok. Markup, bukan logika, yang menentukan apa yang tampil.

Perhatikan juga bahwa tombol Ubah dan Hapus tetap bekerja pada catatan yang tampil: id dibaca dari tombol yang diklik, dan daftarCatatan().find(...) mencari di penyimpanan penuh. Mengedit catatan hasil pencarian tidak memerlukan kode tambahan — manfaat kecil dari id yang menempel pada datanya.

Uji Kasus Tepi Pencarian #

Tujuh uji baru menutup kontrak pencarian — masing-masing satu baris tabel kontrak. Impornya bertambah satu nama:

js tests/catatan.test.mjs — tambahan Bagian 2
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
  buatCatatan,
  ubahCatatan,
  hapusCatatan,
  daftarCatatan,
  cariCatatan,
  resetCatatan,
} from '../src/catatan.mjs';

// ... kelompok uji Bagian 1 tetap sama ...

describe('cariCatatan', () => {
  test('kata kunci kosong mengembalikan seluruh catatan', () => {
    resetCatatan();
    buatCatatan('Resep Kopi', 'Gula aren dan susu.');
    buatCatatan('Jadwal Belajar', 'Latihan module ES.');
    const hasil = cariCatatan(daftarCatatan(), '');
    assert.strictEqual(hasil.length, 2);
  });

  test('mencocokkan kata kunci pada judul', () => {
    resetCatatan();
    buatCatatan('Resep Kopi', 'Gula aren dan susu.');
    buatCatatan('Jadwal Belajar', 'Latihan module ES.');
    const hasil = cariCatatan(daftarCatatan(), 'Resep');
    assert.deepStrictEqual(hasil.map((c) => c.id), [1]);
  });

  test('mencocokkan kata kunci pada isi', () => {
    resetCatatan();
    buatCatatan('Resep Kopi', 'Gula aren dan susu.');
    buatCatatan('Jadwal Belajar', 'Latihan module ES.');
    const hasil = cariCatatan(daftarCatatan(), 'module');
    assert.deepStrictEqual(hasil.map((c) => c.id), [2]);
  });

  test('mencocokkan potongan kata', () => {
    resetCatatan();
    buatCatatan('Resep Kopi', 'Gula aren dan susu.');
    const hasil = cariCatatan(daftarCatatan(), 'gul');
    assert.deepStrictEqual(hasil.map((c) => c.id), [1]);
  });

  test('pencarian tidak peka huruf besar-kecil dan spasi ujung', () => {
    resetCatatan();
    buatCatatan('Resep Kopi', 'Gula aren dan susu.');
    buatCatatan('Jadwal Belajar', 'Latihan module ES.');
    assert.deepStrictEqual(
      cariCatatan(daftarCatatan(), 'GULA').map((c) => c.id),
      [1],
    );
    assert.deepStrictEqual(
      cariCatatan(daftarCatatan(), '  kopi  ').map((c) => c.id),
      [1],
    );
  });

  test('semua kata harus ditemukan, boleh terpisah judul dan isi', () => {
    resetCatatan();
    buatCatatan('Resep Kopi', 'Gula aren dan susu.');
    buatCatatan('Jadwal Belajar', 'Latihan module ES.');
    assert.deepStrictEqual(
      cariCatatan(daftarCatatan(), 'kopi susu').map((c) => c.id),
      [1],
    );
    assert.deepStrictEqual(
      cariCatatan(daftarCatatan(), 'belajar module').map((c) => c.id),
      [2],
    );
  });

  test('tanpa kecocokan mengembalikan array kosong', () => {
    resetCatatan();
    buatCatatan('Resep Kopi', 'Gula aren dan susu.');
    assert.deepStrictEqual(cariCatatan(daftarCatatan(), 'kamera'), []);
  });
});

Jalankan kembali dari folder proyek:

bash
node --test

Output yang Dihasilkan:

text
▶ buatCatatan
  ✔ membuat catatan pertama dengan id 1 (1.195343ms)
  ✔ id berlanjut untuk catatan berikutnya (0.174056ms)
  ✔ judul dan isi dipangkas spasi di kedua ujungnya (0.120979ms)
  ✔ judul yang kosong setelah dipangkas ditolak (0.350086ms)
  ✔ isi yang kosong setelah dipangkas ditolak (0.13528ms)
  ✔ judul yang melebihi 100 karakter ditolak (0.149487ms)
✔ buatCatatan (3.238857ms)
▶ ubahCatatan
  ✔ mengubah judul dan isi catatan yang dicari (0.262431ms)
  ✔ field yang tidak disebut tidak ikut berubah (0.83139ms)
  ✔ judul yang kosong pada perubahan ditolak (0.261368ms)
  ✔ mengembalikan null bila id tidak ditemukan (0.184658ms)
✔ ubahCatatan (1.848215ms)
▶ hapusCatatan
  ✔ menghapus catatan dengan id yang dicari (0.20151ms)
  ✔ mengembalikan false bila id tidak ditemukan (0.110338ms)
✔ hapusCatatan (0.420178ms)
▶ daftarCatatan
  ✔ mengembalikan salinan yang setara tetapi terpisah (0.290488ms)
✔ daftarCatatan (0.364643ms)
▶ cariCatatan
  ✔ kata kunci kosong mengembalikan seluruh catatan (1.089581ms)
  ✔ mencocokkan kata kunci pada judul (0.223582ms)
  ✔ mencocokkan kata kunci pada isi (0.341556ms)
  ✔ mencocokkan potongan kata (0.120799ms)
  ✔ pencarian tidak peka huruf besar-kecil dan spasi ujung (0.081309ms)
  ✔ semua kata harus ditemukan, boleh terpisah judul dan isi (0.077889ms)
  ✔ tanpa kecocokan mengembalikan array kosong (0.044143ms)
✔ cariCatatan (2.345912ms)
ℹ tests 20
ℹ suites 5
ℹ pass 20
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 71.220565

Dua puluh uji lulus dalam lima suite. Dua asersi patut dicermati. Uji “semua kata harus ditemukan” mengunci dua skenario sekaligus: kopi susu yang bertemu di satu catatan, dan belajar module yang kata-katanya terpisah antara judul dan isi — aturan “di judul ATAU isi” berlaku per kata, bukan per catatan. Uji “tanpa kecocokan” memakai deepStrictEqual terhadap [] untuk memastikan hasilnya benar-benar array kosong, bukan undefined maupun null — bentuk keluaran yang stabil membuat antarmuka tak perlu menebak.

Perhatikan pula bahwa seluruh uji pencarian memanggil cariCatatan(daftarCatatan(), ...) — argumen pertamanya array, persis seperti pemanggilan di antarmuka. Karena fungsi ini murni, kita bahas bisa mengujinya dengan array buatan tangan; memakai hasil daftarCatatan() yang nyata menjaga uji tetap menyerupai pemakaian sebenarnya.

Ringkasan Bagian 2 #

Pencarian kini hidup di dua sisi dengan tanggung jawab yang rapi: cariCatatan memutuskan catatan mana yang cocok (teruji tujuh kali di terminal), dan skrip antarmuka hanya merender hasilnya plus menghitung ringkasan. Uji proyek berdiri di dua puluh, semuanya lulus. Satu kelemahan besar masih menunggu: tutup tab, dan seluruh catatan menguap memori — pencarian sehebat apa pun tidak menolong daftar yang kosong. Bagian 3 memasang penyimpanan yang bertahan.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

styles/styles.css

Unduh berkas
css
/* styles.css — tampilan aplikasi catatan */

body {
  margin: 0;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #f3f4f6;
  font-family: system-ui, sans-serif;
}

.aplikasi {
  width: 380px;
  padding: 20px;
  background-color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.aplikasi h1 {
  margin: 0 0 16px;
  font-size: 1.4rem;
}

.form-catatan {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-bottom: 12px;
}

.form-catatan input,
.form-catatan textarea {
  padding: 10px;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  font-size: 1rem;
  font-family: inherit;
}

.form-catatan textarea {
  resize: vertical;
}

.aksi-form {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

.tombol-kirim {
  padding: 8px 16px;
  border: none;
  border-radius: 8px;
  background-color: #3b82f6;
  color: #ffffff;
  font-size: 0.95rem;
  cursor: pointer;
}

.tombol-kirim:hover {
  background-color: #2563eb;
}

.tombol-batal {
  padding: 8px 16px;
  border: none;
  border-radius: 8px;
  background-color: #e5e7eb;
  font-size: 0.95rem;
  cursor: pointer;
}

.tombol-batal:hover {
  background-color: #d1d5db;
}

.pencarian {
  margin-bottom: 12px;
}

.pencarian input {
  width: 100%;
  box-sizing: border-box;
  padding: 10px;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  font-size: 1rem;
}

.pesan {
  margin: 0 0 12px;
  color: #b91c1c;
  font-size: 0.9rem;
}

.ringkasan {
  margin: 0 0 12px;
  color: #6b7280;
  font-size: 0.9rem;
}

.daftar-catatan {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 10px;
}

.kartu {
  padding: 12px;
  background-color: #f9fafb;
  border-radius: 8px;
}

.kartu h2 {
  margin: 0 0 6px;
  font-size: 1.05rem;
}

.kartu p {
  margin: 0 0 10px;
  color: #374151;
  white-space: pre-wrap;
}

.aksi-kartu {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

.tombol-ubah {
  padding: 6px 10px;
  border: none;
  border-radius: 6px;
  background-color: #fde68a;
  font-size: 0.85rem;
  cursor: pointer;
}

.tombol-ubah:hover {
  background-color: #fcd34d;
}

.tombol-hapus {
  padding: 6px 10px;
  border: none;
  border-radius: 6px;
  background-color: #fecaca;
  font-size: 0.85rem;
  cursor: pointer;
}

.tombol-hapus:hover {
  background-color: #fca5a5;
}