Bab 6: Web Components

3 menit baca

6.1 Pengantar Web Components

Apa Itu Web Components?

Web Components adalah standar browser untuk membuat komponen UI custom yang reusable — tanpa framework! Kamu bisa bikin tag HTML sendiri seperti <my-button>, <user-card>, <date-picker>.

Analogi: Bayangkan LEGO. Setiap brick punya bentuk dan fungsi sendiri, bisa dipakai ulang di mana saja. Web Components = bikin brick LEGO custom sendiri yang bisa dipakai di project mana pun.

3 Teknologi Utama

  1. Custom Elements — Bikin tag HTML baru
  2. Shadow DOM — Enkapsulasi style (CSS tidak bocor keluar/masuk)
  3. Templates & Slots — Template HTML yang reusable

Kenapa Web Components?

  • Framework-agnostic: Works di React, Vue, Angular, atau vanilla JS
  • Native browser: Tidak perlu library tambahan
  • Enkapsulasi: Style dan logic terisolasi
  • Reusable: Sekali bikin, pakai di mana saja

6.2 Custom Elements

Membuat Custom Element

javascript
// Definisikan class untuk element custom
class MyCounter extends HTMLElement {
  constructor() {
    super(); // WAJIB panggil super() pertama!
    this.count = 0;
  }

  // Dipanggil saat element ditambahkan ke DOM
  connectedCallback() {
    this.render();
    this.querySelector('button.plus').onclick = () => this.increment();
    this.querySelector('button.minus').onclick = () => this.decrement();
  }

  increment() {
    this.count++;
    this.render();
  }

  decrement() {
    this.count--;
    this.render();
  }

  render() {
    this.innerHTML = `
      <div>
        <button class="minus">-</button>
        <span>${this.count}</span>
        <button class="plus">+</button>
      </div>
    `;
    // Re-attach event listeners setelah innerHTML
    this.querySelector('button.plus').onclick = () => this.increment();
    this.querySelector('button.minus').onclick = () => this.decrement();
  }
}

// Daftarkan element (nama HARUS pakai dash/strip!)
customElements.define('my-counter', MyCounter);
html
<!-- Sekarang bisa dipakai di HTML! -->
<my-counter></my-counter>
<my-counter></my-counter> <!-- instance terpisah -->

Lifecycle Callbacks

javascript
class MyElement extends HTMLElement {
  constructor() {
    super();
    // Inisialisasi state. JANGAN akses DOM di sini!
  }

  connectedCallback() {
    // Element MASUK ke DOM
    // Setup event listeners, fetch data, render
    console.log('Element ditambahkan ke halaman');
  }

  disconnectedCallback() {
    // Element KELUAR dari DOM
    // Cleanup: remove listeners, cancel timers
    console.log('Element dihapus dari halaman');
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // Attribute berubah
    console.log(`Attribute "${name}": "${oldValue}" → "${newValue}"`);
    this.render(); // re-render saat attribute berubah
  }

  // WAJIB deklarasi attribute mana yang di-observe
  static get observedAttributes() {
    return ['color', 'size', 'disabled'];
  }

  adoptedCallback() {
    // Element dipindah ke document lain (jarang dipakai)
  }
}

customElements.define('my-element', MyElement);

Contoh: User Card Component

javascript
class UserCard extends HTMLElement {
  static get observedAttributes() {
    return ['name', 'avatar', 'role'];
  }

  connectedCallback() {
    this.render();
  }

  attributeChangedCallback() {
    this.render();
  }

  render() {
    const name = this.getAttribute('name') || 'Anonymous';
    const avatar = this.getAttribute('avatar') || 'https://via.placeholder.com/50';
    const role = this.getAttribute('role') || 'Member';

    this.innerHTML = `
      <div style="display:flex; align-items:center; gap:12px; padding:12px; border:1px solid #ddd; border-radius:8px;">
        <img src="${avatar}" alt="${name}" style="width:50px; height:50px; border-radius:50%;">
        <div>
          <strong>${name}</strong>
          <p style="margin:0; color:#666; font-size:0.9em;">${role}</p>
        </div>
      </div>
    `;
  }
}

customElements.define('user-card', UserCard);
html
<user-card name="Yazid" role="Developer" avatar="photo.jpg"></user-card>

Customized Built-in Elements

javascript
// Extend element yang sudah ada
class FancyButton extends HTMLButtonElement {
  connectedCallback() {
    this.style.background = 'linear-gradient(45deg, #ff6b6b, #feca57)';
    this.style.border = 'none';
    this.style.padding = '10px 20px';
    this.style.borderRadius = '20px';
    this.style.color = 'white';
    this.style.cursor = 'pointer';
  }
}

customElements.define('fancy-button', FancyButton, { extends: 'button' });
html
<!-- Pakai dengan is="" -->
<button is="fancy-button">Klik Saya!</button>
⚠️Jebakan!
  1. Nama HARUS pakai dash: my-element ✅, myelement
  2. Jangan akses DOM di constructor: DOM belum ready. Pakai connectedCallback
  3. innerHTML hapus event listeners: Setelah re-render, pasang ulang listeners
  4. Safari tidak support extends: Customized built-in elements tidak work di Safari

6.3 Shadow DOM

Apa Itu Shadow DOM?

Shadow DOM membuat "dunia terpisah" di dalam element — CSS dari luar TIDAK mempengaruhi, dan CSS dari dalam TIDAK bocor keluar.

Analogi: Shadow DOM itu kayak kamar hotel. Dekorasi di lobby (halaman utama) tidak mempengaruhi kamar kamu (shadow DOM), dan dekorasi kamar kamu tidak mengubah lobby.

Membuat Shadow DOM

javascript
class MyWidget extends HTMLElement {
  constructor() {
    super();
    // Buat shadow root
    const shadow = this.attachShadow({ mode: 'open' });

    // Style di sini TIDAK bocor keluar!
    shadow.innerHTML = `
      <style>
        /* Style ini HANYA berlaku di dalam shadow DOM */
        p { color: red; font-size: 20px; }
        .container { padding: 16px; border: 2px solid blue; }
      </style>
      <div class="container">
        <p>Ini di dalam Shadow DOM!</p>
      </div>
    `;
  }
}

customElements.define('my-widget', MyWidget);
html
<style>
  /* Style ini TIDAK mempengaruhi shadow DOM */
  p { color: green; font-size: 12px; }
</style>

<p>Ini paragraf biasa (hijau, 12px)</p>
<my-widget></my-widget> <!-- paragraf di dalam tetap merah, 20px -->

Mode: open vs closed

javascript
// open — bisa diakses dari luar via element.shadowRoot
const shadow = this.attachShadow({ mode: 'open' });
// document.querySelector('my-widget').shadowRoot → bisa akses

// closed — tidak bisa diakses dari luar
const shadow = this.attachShadow({ mode: 'closed' });
// document.querySelector('my-widget').shadowRoot → null

Contoh: Toggle Switch Component

javascript
class ToggleSwitch extends HTMLElement {
  constructor() {
    super();
    this._checked = false;
    const shadow = this.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
      <style>
        :host {
          display: inline-block;
          cursor: pointer;
        }
        .track {
          width: 50px; height: 26px;
          background: #ccc;
          border-radius: 13px;
          position: relative;
          transition: background 0.3s;
        }
        .track.active { background: #4CAF50; }
        .thumb {
          width: 22px; height: 22px;
          background: white;
          border-radius: 50%;
          position: absolute;
          top: 2px; left: 2px;
          transition: transform 0.3s;
          box-shadow: 0 1px 3px rgba(0,0,0,0.3);
        }
        .track.active .thumb { transform: translateX(24px); }
      </style>
      <div class="track">
        <div class="thumb"></div>
      </div>
    `;

    shadow.querySelector('.track').onclick = () => this.toggle();
  }

  get checked() { return this._checked; }
  set checked(val) {
    this._checked = val;
    const track = this.shadowRoot.querySelector('.track');
    track.classList.toggle('active', val);
    this.dispatchEvent(new CustomEvent('change', { detail: { checked: val } }));
  }

  toggle() {
    this.checked = !this.checked;
  }
}

customElements.define('toggle-switch', ToggleSwitch);
html
<toggle-switch id="darkMode"></toggle-switch>
<script>
  document.getElementById('darkMode').addEventListener('change', (e) => {
    console.log('Dark mode:', e.detail.checked);
  });
</script>

6.4 Template Element

Apa Itu <template>?

<template> adalah HTML yang TIDAK di-render sampai kamu clone dan masukkan ke DOM secara manual. Berguna sebagai "cetakan".

html
<template id="cardTemplate">
  <div class="card">
    <h3 class="title"></h3>
    <p class="body"></p>
    <button class="action">Detail</button>
  </div>
</template>
javascript
// Clone template dan isi data
function createCard(title, body) {
  const template = document.getElementById('cardTemplate');
  const clone = template.content.cloneNode(true); // deep clone

  clone.querySelector('.title').textContent = title;
  clone.querySelector('.body').textContent = body;

  return clone;
}

// Buat beberapa card
const container = document.getElementById('cards');
container.appendChild(createCard('Belajar JS', 'JavaScript itu seru!'));
container.appendChild(createCard('Web Components', 'Bikin komponen sendiri'));

Template di Web Component

javascript
const template = document.createElement('template');
template.innerHTML = `
  <style>
    .alert { padding: 12px; border-radius: 4px; margin: 8px 0; }
    .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
    .alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
  </style>
  <div class="alert">
    <slot></slot>
  </div>
`;

class AlertBox extends HTMLElement {
  static get observedAttributes() { return ['type']; }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }

  connectedCallback() { this.updateType(); }
  attributeChangedCallback() { this.updateType(); }

  updateType() {
    const type = this.getAttribute('type') || 'success';
    const div = this.shadowRoot.querySelector('.alert');
    div.className = `alert alert-${type}`;
  }
}

customElements.define('alert-box', AlertBox);
html
<alert-box type="success">Berhasil disimpan!</alert-box>
<alert-box type="error">Terjadi kesalahan!</alert-box>

6.5 Slots — Komposisi Konten

Apa Itu Slot?

Slot adalah "lubang" di Shadow DOM yang bisa diisi konten dari luar. Kayak frame foto — frame-nya tetap, tapi foto-nya bisa diganti.

Named Slots

javascript
class CardComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .card { border: 1px solid #ddd; border-radius: 8px; overflow: hidden; }
        .header { background: #f5f5f5; padding: 12px; font-weight: bold; }
        .body { padding: 16px; }
        .footer { background: #f5f5f5; padding: 12px; text-align: right; }
      </style>
      <div class="card">
        <div class="header"><slot name="header">Default Header</slot></div>
        <div class="body"><slot>Default content</slot></div>
        <div class="footer"><slot name="footer"></slot></div>
      </div>
    `;
  }
}

customElements.define('card-component', CardComponent);
html
<card-component>
  <span slot="header">Judul Kartu</span>

  <!-- Konten tanpa slot name → masuk ke default slot -->
  <p>Ini isi kartu yang bisa berisi apa saja.</p>
  <p>Paragraf kedua juga masuk ke default slot.</p>

  <button slot="footer">Simpan</button>
</card-component>

Slot Events

javascript
class TabPanel extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = `<slot></slot>`;

    // Detect saat konten slot berubah
    shadow.querySelector('slot').addEventListener('slotchange', (e) => {
      const assigned = e.target.assignedElements();
      console.log('Slot content changed:', assigned.length, 'elements');
    });
  }
}

6.6 Shadow DOM Styling

:host — Style Element Sendiri

javascript
class StyledBox extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        /* Style host element itu sendiri */
        :host {
          display: block;
          padding: 16px;
          border: 2px solid #333;
          border-radius: 8px;
        }

        /* Conditional styling berdasarkan attribute */
        :host([theme="dark"]) {
          background: #333;
          color: white;
        }

        :host([theme="light"]) {
          background: white;
          color: #333;
        }

        /* Saat host di-hover */
        :host(:hover) {
          box-shadow: 0 4px 12px rgba(0,0,0,0.15);
        }

        /* Berdasarkan context (parent) */
        :host-context(.sidebar) {
          font-size: 14px;
        }
      </style>
      <slot></slot>
    `;
  }
}

CSS Custom Properties (Tembus Shadow DOM!)

javascript
class ThemedButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        button {
          /* CSS variables BISA tembus shadow DOM! */
          background: var(--btn-bg, #007bff);
          color: var(--btn-color, white);
          padding: var(--btn-padding, 8px 16px);
          border: none;
          border-radius: var(--btn-radius, 4px);
          font-size: var(--btn-font-size, 14px);
          cursor: pointer;
        }
        button:hover {
          opacity: 0.9;
        }
      </style>
      <button><slot>Click</slot></button>
    `;
  }
}

customElements.define('themed-button', ThemedButton);
html
<style>
  /* Customize dari luar pakai CSS variables! */
  themed-button {
    --btn-bg: #e74c3c;
    --btn-color: white;
    --btn-radius: 20px;
    --btn-padding: 12px 24px;
  }

  .large themed-button {
    --btn-font-size: 18px;
    --btn-padding: 16px 32px;
  }
</style>

<themed-button>Hapus</themed-button>
<div class="large">
  <themed-button>Tombol Besar</themed-button>
</div>

::part() — Expose Bagian Tertentu

javascript
class FancyInput extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .wrapper { display: flex; align-items: center; gap: 8px; }
        input { border: 1px solid #ddd; padding: 8px; border-radius: 4px; }
        label { font-weight: bold; }
      </style>
      <div class="wrapper">
        <label part="label"><slot name="label">Label</slot></label>
        <input part="input" type="text">
      </div>
    `;
  }
}

customElements.define('fancy-input', FancyInput);
css
/* Dari luar, bisa style bagian yang di-expose via part */
fancy-input::part(input) {
  border-color: blue;
  font-size: 16px;
}

fancy-input::part(label) {
  color: navy;
}

6.7 Shadow DOM Events

Event Retargeting

Event yang terjadi di dalam Shadow DOM di-"retarget" — dari luar, event.target menunjuk ke host element, bukan element internal.

javascript
class ClickDemo extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <button id="innerBtn">Klik di dalam Shadow</button>
    `;
  }
}

customElements.define('click-demo', ClickDemo);
javascript
// Dari luar
document.addEventListener('click', (e) => {
  console.log(e.target); // <click-demo> (bukan #innerBtn!)
  console.log(e.composedPath()); // [button#innerBtn, shadow-root, click-demo, body, html, document, window]
});

composed — Event yang Tembus Shadow Boundary

javascript
// Event bawaan (click, input, dll) otomatis composed: true → tembus shadow
// Custom event harus set manual:

class MyComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<button>Fire Event</button>`;

    this.shadowRoot.querySelector('button').onclick = () => {
      // ❌ Event ini TIDAK keluar dari shadow DOM
      this.shadowRoot.dispatchEvent(new CustomEvent('internal', {
        bubbles: true,
        composed: false // default
      }));

      // ✅ Event ini KELUAR dari shadow DOM
      this.dispatchEvent(new CustomEvent('action', {
        bubbles: true,
        composed: true,
        detail: { message: 'Button clicked!' }
      }));
    };
  }
}

Contoh Lengkap: Modal Component

javascript
class ModalDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: none; }
        :host([open]) { display: block; }

        .overlay {
          position: fixed; inset: 0;
          background: rgba(0,0,0,0.5);
          display: flex; align-items: center; justify-content: center;
          z-index: 1000;
        }
        .modal {
          background: white; border-radius: 12px;
          padding: 24px; min-width: 300px; max-width: 500px;
          box-shadow: 0 20px 60px rgba(0,0,0,0.3);
        }
        .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
        .close { background: none; border: none; font-size: 24px; cursor: pointer; }
        .footer { margin-top: 16px; text-align: right; }
      </style>
      <div class="overlay" part="overlay">
        <div class="modal" part="modal">
          <div class="header">
            <slot name="title"><h3>Dialog</h3></slot>
            <button class="close">&times;</button>
          </div>
          <div class="body">
            <slot></slot>
          </div>
          <div class="footer">
            <slot name="footer"></slot>
          </div>
        </div>
      </div>
    `;

    // Close button
    this.shadowRoot.querySelector('.close').onclick = () => this.close();

    // Click overlay to close
    this.shadowRoot.querySelector('.overlay').onclick = (e) => {
      if (e.target.classList.contains('overlay')) this.close();
    };

    // Escape key
    this._escHandler = (e) => {
      if (e.key === 'Escape' && this.hasAttribute('open')) this.close();
    };
  }

  connectedCallback() {
    document.addEventListener('keydown', this._escHandler);
  }

  disconnectedCallback() {
    document.removeEventListener('keydown', this._escHandler);
  }

  open() {
    this.setAttribute('open', '');
    this.dispatchEvent(new CustomEvent('modal-open', { bubbles: true, composed: true }));
  }

  close() {
    this.removeAttribute('open');
    this.dispatchEvent(new CustomEvent('modal-close', { bubbles: true, composed: true }));
  }
}

customElements.define('modal-dialog', ModalDialog);
html
<modal-dialog id="myModal">
  <h3 slot="title">Konfirmasi</h3>
  <p>Apakah kamu yakin ingin menghapus item ini?</p>
  <div slot="footer">
    <button onclick="myModal.close()">Batal</button>
    <button onclick="handleDelete()">Hapus</button>
  </div>
</modal-dialog>

<button onclick="myModal.open()">Buka Modal</button>
⚠️Jebakan!
  1. Event retargeting: event.target dari luar selalu host element, bukan internal element
  2. composed: false default: Custom event tidak keluar shadow DOM kecuali di-set composed: true
  3. Focus events: focusin/focusout composed, tapi focus/blur TIDAK
  4. Form participation: Element di shadow DOM tidak otomatis ikut form. Perlu ElementInternals API

🏆 Challenge

Buat <star-rating> Web Component:

  1. Tampilkan 5 bintang (★)
  2. Hover → bintang menyala sampai posisi cursor
  3. Click → set rating (tetap menyala)
  4. Attribute value untuk set/get rating
  5. Dispatch event rating-change saat user klik
  6. Pakai Shadow DOM (style terisolasi)
  7. CSS variable --star-color untuk customize warna dari luar
javascript
// Hint:
// - Pakai :host, Shadow DOM, CSS variables
// - 5 span dengan ★ character
// - mouseover/mouseout untuk hover effect
// - click untuk set value
// - dispatchEvent(new CustomEvent('rating-change', { detail: { value }, composed: true }))

Sudah paham materi ini?

Tandai sebagai selesai untuk melacak progress-mu.