Bagian 1 berakhir dengan state yang bisa menampung kata kunci — tetapi nilai kataKunci itu belum dipakai di mana pun. Bagian ini menghidupkannya: kita menulis fungsi pencarian yang menyaring katalog berdasarkan teks. Sebelum kodenya, mari lihat dulu betapa mudah pencarian terasa salah. Pengguna mengetik javascript dengan huruf kecil, sementara data menyimpan JavaScript — pencocokan langsung dengan includes mengembalikan nol hasil. Pengguna mengetik dengan spasi berlebih atau menyisakan tanda koma, dan begitu lagi nol hasil. Pencarian yang baik menormalisasi kedua sisi sebelum membandingkan, itulah pekerjaan pertama kita.

normalisasiTeks: Empat Langkah Pembersihan #

Kita ingin ' JavaScript: DASAR! ' dan 'javascript dasar' diperlakukan sama. Caranya adalah satu fungsi normalisasi yang dipakai untuk dua sisi sekaligus — kata kunci dari pengguna maupun teks judul dan penulis dari data. Berikut fungsinya di src/daftar.mjs:

js src/daftar.mjs — normalisasiTeks
// Normalisasi teks: huruf kecil, tanda baca ringan diganti spasi,
// spasi berlebih dirapatkan. Dipakai untuk kata kunci maupun data.
export function normalisasiTeks(teks) {
  return teks
    .toLowerCase()
    .replace(/[.,;:!?"'()\-]/g, ' ')
    .trim()
    .replace(/\s+/g, ' ');
}

Mari kita telaah empat langkahnya — metode berantai ini dijalankan berurutan dari atas ke bawah:

  1. toLowerCase(): seluruh huruf dibuat kecil, sehingga JavaScript dan javascript menjadi identik.
  2. replace(/[.,;:!?"'()\-]/g, ' '): tanda baca ringan — koma, titik, titik dua, titik koma, tanda seru dan tanya, tanda kutip, kurung, dan tanda hubung — diganti dengan spasi, bukan dihapus. Mengganti dengan spasi menjaga dua kata yang dipisah tanda baca tetap berjarak; menghapus tanpa spasi bisa menyambung kata yang seharusnya terpisah.
  3. trim(): spasi di awal dan akhir dipangkas, misalnya hasil ketikan yang terlanjur ada spasi di ujungnya.
  4. replace(/\s+/g, ' '): rangkaian spasi dirapatkan menjadi satu. Setelah langkah kedua, 'JavaScript: DASAR!' berubah menjadi 'javascript dasar' dengan dua spasi; langkah ini menyusunnya menjadi 'javascript dasar'.

Fungsi cari: Semua Kata Harus Ada #

Sekarang fungsi pencariannya. Kebiasaan pengguna nyata: mengetik dua kata, misalnya javascript pemula, dan berharap hasil yang memuat keduanya — bukan semua buku ber-“javascript” ditambah semua buku ber-“pemula”. Aturan kita: semua kata harus ada di judul atau penulis. Berikut daftar.mjs lengkap pada akhir Bagian 2; bandingkan dengan versi Bagian 1: ada dua tambahan — normalisasiTeks dan cari — sementara fungsi state tidak berubah:

js daftar.mjs
// daftar.mjs — logika inti pencarian dan filter katalog buku.
// Semua fungsi murni: menerima data atau state, mengembalikan hasil baru,
// tanpa menyentuh DOM sehingga dapat diuji dengan node --test.

export function buatState() {
  return { kataKunci: '', kategori: 'semua', urutan: 'judul-asc' };
}

export function ubahKataKunci(state, teks) {
  return { ...state, kataKunci: teks };
}

export function ubahKategori(state, kategori) {
  return { ...state, kategori };
}

export function ubahUrutan(state, urutan) {
  return { ...state, urutan };
}

// Normalisasi teks: huruf kecil, tanda baca ringan diganti spasi,
// spasi berlebih dirapatkan. Dipakai untuk kata kunci maupun data.
export function normalisasiTeks(teks) {
  return teks
    .toLowerCase()
    .replace(/[.,;:!?"'()\-]/g, ' ')
    .trim()
    .replace(/\s+/g, ' ');
}

// Cari: kembalikan buku yang memuat SEMUA kata kunci di judul atau penulis.
// Kata kunci kosong (setelah normalisasi) mengembalikan salinan seluruh data.
export function cari(data, kataKunci) {
  const kata = normalisasiTeks(kataKunci).split(' ').filter((k) => k !== '');
  if (kata.length === 0) {
    return [...data];
  }
  return data.filter((buku) => {
    const sumber = normalisasiTeks(`${buku.judul} ${buku.penulis}`);
    return kata.every((k) => sumber.includes(k));
  });
}

// Ringkasan satu baris untuk demo dan tampilan sederhana.
export function ringkasBuku(buku) {
  return `${buku.judul} — ${buku.penulis} (Rp${buku.harga.toLocaleString('id-ID')})`;
}

Mari kita bedah cari baris demi baris:

  1. Baris 3 (const kata = ...): kata kunci dinormalisasi lalu dipecah menjadi array kata dengan split(' '). Karena normalisasi sudah merapatkan spasi, split(' ') cukup; filter((k) => k !== '') membuang elemen kosong yang muncul bila kata kunci memang kosong.
  2. Baris 4–6 (if (kata.length === 0)): kasus tepi terpenting. Kata kunci kosong — atau berisi spasi dan tanda baca saja — berarti pengguna tidak sedang menyaring apa pun, sehingga fungsi mengembalikan [...data]: salinan seluruh data. Menyalin (bukan mengembalikan data langsung) menjaga kontrak yang sama dengan fungsi lain — hasil selalu array baru yang bebas diolah tanpa menyesentuh sumber.
  3. Baris 8 (data.filter(...)): setiap buku diperiksa satu per satu dengan filter, sehingga yang tidak lolos cukup dilewati.
  4. Baris 9 (const sumber = ...): judul dan penulis digabung menjadi satu teks lalu dinormalisasi dengan fungsi yang sama. Dari titik ini, pencarian menyentuh keduanya tanpa perlu dua pemeriksaan terpisah.
  5. Baris 10 (kata.every(...)): every memaksa semua kata kunci lolos — satu kata saja yang tidak ditemukan, buku itu keluar dari hasil. Pencocokannya memakai includes, artinya pencocokan cuplikan: java menemukan javascript.

Mencoba Pencarian dari Terminal #

Berkas demo berikut menyapu semua perilaku yang baru saja kita tulis. Jalankan dari folder bagian ini:

js demo-cari.mjs
// demo-cari.mjs — mencoba normalisasi dan pencarian teks dari terminal.
import { BUKU } from './data.mjs';
import { cari, normalisasiTeks, ringkasBuku } from './daftar.mjs';

console.log(
  'normalisasiTeks("  JavaScript: DASAR! ") ->',
  JSON.stringify(normalisasiTeks('  JavaScript: DASAR! ')),
);

const hasilJava = cari(BUKU, 'javascript');
console.log('cari "javascript" :', hasilJava.length, 'buku');
hasilJava.forEach((buku) => console.log('  -', ringkasBuku(buku)));

console.log('cari "java"       :', cari(BUKU, 'java').length, 'buku');
console.log('cari "javascript pemula" :', cari(BUKU, 'javascript pemula').length, 'buku');
console.log('cari "JavaScript, PEMULA!" :', cari(BUKU, 'JavaScript, PEMULA!').length, 'buku');
console.log('cari "bahasa"     :', cari(BUKU, 'bahasa').length, 'buku');
console.log('cari spasi saja   :', cari(BUKU, '   ').length, 'buku');
bash
node demo-cari.mjs

Output yang Dihasilkan:

text
normalisasiTeks("  JavaScript: DASAR! ") -> "javascript dasar"
cari "javascript" : 2 buku
  - Pemrograman JavaScript untuk Pemula — Raka Pratama (Rp99.000)
  - JavaScript Modern dari Variabel sampai Module — Sinta Larasati (Rp125.000)
cari "java"       : 2 buku
cari "javascript pemula" : 1 buku
cari "JavaScript, PEMULA!" : 1 buku
cari "bahasa"     : 0 buku
cari spasi saja   : 22 buku

Setiap baris keluaran membuktikan satu aturan. Baris pertama memperlihatkan normalisasiTeks mengubah input berantakan menjadi "javascript dasar". Pencarian javascript menemukan dua buku — keduanya dari judul, membuktikan sumber mencakup judul dan penulis. Pencarian java menghasilkan jumlah sama karena pencocokan cuplikan. Multi kata javascript pemula menyaring sampai tinggal satu: judul Pemrograman JavaScript untuk Pemula memuat keduanya, sementara JavaScript Modern dari Variabel sampai Module hanya memuat satu kata dan tersaring keluar. Kata kunci berantakan JavaScript, PEMULA! tetap menemukan buku itu — tanda baca diabaikan. Terakhir, bahasa tidak ada di judul maupun penulis mana pun sehingga hasilnya nol, dan kata kunci berisi spasi saja mengembalikan seluruh 22 buku.

Uji Kasus Tepi #

Perilaku di atas kini dikunci uji. Tambahkan dua kelompok berikut ke tests/daftar.test.mjs; impornya diperluas dengan cari dan normalisasiTeks:

js tests/daftar.test.mjs — tambahan bagian 2
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { BUKU } from '../src/data.mjs';
import {
  buatState,
  cari,
  normalisasiTeks,
  ringkasBuku,
  ubahKategori,
  ubahKataKunci,
  ubahUrutan,
} from '../src/daftar.mjs';

// ... kelompok data, buatState, dan perubahan state tetap sama ...

describe('normalisasiTeks', () => {
  test('membuat huruf kecil dan memangkas spasi', () => {
    assert.strictEqual(normalisasiTeks('  JavaScript DASAR '), 'javascript dasar');
  });

  test('mengganti tanda baca ringan dengan spasi', () => {
    assert.strictEqual(normalisasiTeks('JavaScript: DASAR!'), 'javascript dasar');
  });
});

describe('cari', () => {
  test('kata kunci kosong mengembalikan salinan seluruh data', () => {
    const hasil = cari(BUKU, '');
    assert.strictEqual(hasil.length, BUKU.length);
    assert.notStrictEqual(hasil, BUKU);
  });

  test('satu kata mencocokkan judul dan penulis', () => {
    assert.strictEqual(cari(BUKU, 'javascript').length, 2);
    assert.strictEqual(cari(BUKU, 'sinta').length, 1);
  });

  test('cuplikan kata ikut cocok tanpa perlu kata utuh', () => {
    assert.strictEqual(cari(BUKU, 'java').length, 2);
  });

  test('banyak kata: semua kata harus ada', () => {
    assert.strictEqual(cari(BUKU, 'javascript pemula').length, 1);
    assert.strictEqual(cari(BUKU, 'javascript dasar').length, 0);
  });

  test('tanda baca dan spasi berlebih pada kata kunci diabaikan', () => {
    assert.strictEqual(cari(BUKU, '  JavaScript, PEMULA! ').length, 1);
  });

  test('tidak ada yang cocok mengembalikan array kosong', () => {
    assert.deepStrictEqual(cari(BUKU, 'bahasa'), []);
  });
});

Jalankan kembali dari folder proyek:

bash
node --test

Output yang Dihasilkan:

text
▶ data
  ✔ katalog berisi 20+ buku dengan lima properti (0.586736ms)
✔ data (1.398953ms)
▶ buatState
  ✔ state awal menampilkan semua kategori terurut judul (0.148147ms)
✔ buatState (0.266245ms)
▶ perubahan state
  ✔ ubahKataKunci memperbarui kata kunci (0.136694ms)
  ✔ ubahKategori dan ubahUrutan mengganti pilihan aktif (0.109246ms)
  ✔ fungsi ubah mengembalikan state baru, bukan mengubah yang lama (0.125353ms)
✔ perubahan state (0.576146ms)
▶ normalisasiTeks
  ✔ membuat huruf kecil dan memangkas spasi (0.250668ms)
  ✔ mengganti tanda baca ringan dengan spasi (0.121764ms)
✔ normalisasiTeks (0.596521ms)
▶ cari
  ✔ kata kunci kosong mengembalikan salinan seluruh data (0.220897ms)
  ✔ satu kata mencocokkan judul dan penulis (0.536977ms)
  ✔ cuplikan kata ikut cocok tanpa perlu kata utuh (0.228842ms)
  ✔ banyak kata: semua kata harus ada (1.036701ms)
  ✔ tanda baca dan spasi berlebih pada kata kunci diabaikan (0.167525ms)
  ✔ tidak ada yang cocok mengembalikan array kosong (0.624916ms)
✔ cari (3.09845ms)
ℹ tests 13
ℹ suites 5
ℹ pass 13
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 75.162703

Tiga belas uji lulus — delapan uji baru bergabung tanpa mengganggu lima uji Bagian 1. Perhatikan pola uji kasus tepinya: kelompok cari menyasar satu perilaku per uji — kosong, satu kata, cuplikan, multi kata, tanda baca, dan nol hasil — sehingga bila kelak satu perilaku berubah, hanya satu nama uji yang ikut berubah. Uji kata kunci kosong mengembalikan salinan seluruh data menggabungkan dua asersi: panjangnya sama dengan katalog, dan assert.notStrictEqual(hasil, BUKU) memastikan yang dikembalikan salinan, bukan array aslinya.

Ringkasan Bagian 2 #

Pencarian teks kini utuh: normalisasiTeks membersihkan kedua sisi (huruf kecil, tanda baca menjadi spasi, spasi rapat), cari menuntut semua kata hadir di judul atau penulis, kata kunci kosong mengembalikan salinan seluruh data, dan hasil nol tetap berupa array kosong yang sopan. Tiga belas uji menjaga semuanya. Pencarian hanyalah satu sisi dari antarmuka katalog — pengguna juga memilih kategori, membatasi harga, dan menuntut urutan. Ketiganya menjadi pekerjaan Bagian 3.

Berkas Sumber

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

styles/styles.css

Unduh berkas
css
/* styles.css — tampilan katalog buku pencarian dan filter */

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  padding: 24px 16px;
  background-color: #f3f4f6;
  color: #1f2937;
  font-family: system-ui, sans-serif;
}

.katalog {
  max-width: 640px;
  margin: 0 auto;
}

h1 {
  margin: 0 0 16px;
  font-size: 1.6rem;
}

.panel-kontrol {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12px;
  padding: 16px;
  background-color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

.panel-kontrol label {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 0.85rem;
  font-weight: 600;
}

.panel-kontrol input,
.panel-kontrol select {
  padding: 8px 10px;
  font-size: 1rem;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  background-color: #ffffff;
  font-family: inherit;
}

.kolom-cari {
  grid-column: span 2;
}

.status {
  margin: 16px 0 8px;
  font-size: 0.9rem;
  color: #6b7280;
}

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

.kartu-buku {
  padding: 12px 16px;
  background-color: #ffffff;
  border-radius: 10px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}

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

.kartu-buku .keterangan {
  margin: 0 0 6px;
  font-size: 0.85rem;
  color: #6b7280;
}

.kartu-buku .harga {
  margin: 0;
  font-weight: 700;
  color: #047857;
}