1. Pengenalan Web Components
Web Components adalah sekumpulan API web standar yang memungkinkan Anda membuat elemen HTML kustom yang dapat digunakan kembali (reusable), dengan fungsionalitas terenkapsulasi. Berbeda dengan framework seperti React atau Vue, Web Components menggunakan standar web bawaan yang bekerja di semua browser modern tanpa perlu library tambahan.
Tiga teknologi utama yang membentuk Web Components adalah: Custom Elements (mendefinisikan elemen baru), Shadow DOM (enkapsulasi markup dan style), dan HTML Templates (template yang tidak dirender sampai diaktifkan).
Mengapa Menggunakan Web Components?
| Keunggulan | Penjelasan |
|---|---|
| Framework-Agnostic | Bekerja di React, Vue, Angular, Svelte, atau vanilla JS β tanpa lock-in |
| Standar Web | Didukung langsung oleh browser, tidak perlu bundler atau compiler |
| Enkapsulasi | Shadow DOM mencegah konflik CSS dan JS antar komponen |
| Reusabilitas | Sekali dibuat, bisa dipakai di mana saja β bahkan di halaman HTML statis |
| Longevity | Karena ini standar, kode Anda tidak akan obsolete dengan pergantian framework |
| Interoperabilitas | Tim berbeda bisa pakai framework berbeda tapi berbagi komponen yang sama |
Web Components vs Framework Components
| Aspek | Web Components | React Components | Vue Components |
|---|---|---|---|
| Standar | W3C Web Standards | Library API | Framework API |
| Enkapsulasi | Shadow DOM (native) | CSS Modules / CSS-in-JS | Scoped styles |
| Bundle Size | 0 KB (native) | ~42 KB (React) | ~33 KB (Vue) |
| Reactivity | Manual / Attribute API | Virtual DOM | Reactive Proxy |
| Ecosystem | Kecil tapi tumbuh | Sangat besar | Besar |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β WEB COMPONENTS β β β β βββββββββββββββββββββββββββββββββββββββββββββββββββ β β β Custom Elements API β β β β β’ define() β daftarkan elemen baru β β β β β’ extends β perluas elemen HTML yang ada β β β β β’ Lifecycle callbacks β β β βββββββββββββββββββββββββββββββββββββββββββββββββββ β β β β βββββββββββββββββββββββββββββββββββββββββββββββββββ β β β Shadow DOM β β β β β’ Encapsulated DOM subtree β β β β β’ Scoped CSS (tidak bocor masuk/keluar) β β β β β’ Isolated JavaScript scope β β β βββββββββββββββββββββββββββββββββββββββββββββββββββ β β β β βββββββββββββββββββββββββββββββββββββββββββββββββββ β β β HTML Templates & Slots β β β β β’ β markup inert (tidak dirender) β β β β β’β placeholder untuk konten dinamis β β β β β’ slot assignment β named & default slots β β β βββββββββββββββββββββββββββββββββββββββββββββββββββ β β β β Digunakan di: React, Vue, Angular, Svelte, VanillaJS β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Web Components didukung oleh semua browser modern: Chrome 54+, Firefox 63+, Safari 10.1+, Edge 79+. Untuk browser lama, gunakan polyfill seperti @webcomponents/webcomponentsjs. Fitur seperti Shadow DOM, Custom Elements v1, dan <template> sudah stabil dan production-ready.
2. Custom Elements
Custom Elements adalah API yang memungkinkan Anda mendefinisikan elemen HTML baru dengan nama sendiri. Setiap custom element adalah class yang extends HTMLElement (atau subclass lainnya) dan didaftarkan ke browser menggunakan customElements.define().
Membuat Custom Element Pertama
// ====== my-button.js ======
// Definisikan class yang extends HTMLElement
class MyButton extends HTMLElement {
constructor() {
super(); // WAJIB panggil super() terlebih dahulu
// Buat Shadow DOM
this.attachShadow({ mode: 'open' });
// Render konten
this.shadowRoot.innerHTML = `
<style>
button {
padding: 10px 20px;
border: none;
border-radius: 8px;
background: #6366f1;
color: white;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
button:hover {
background: #4f46e5;
}
</style>
<button>
<slot>Tombol Default</slot>
</button>
`;
}
}
// Daftarkan ke browser
// Nama HARUS mengandung hyphen (-) untuk menghindari konflik dengan HTML asli
customElements.define('my-button', MyButton);
// Sekarang bisa digunakan di HTML:
// <my-button>Klik Saya</my-button>
Custom Element dengan Props & Content
// ====== user-card.js ======
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
// connectedCallback dipanggil saat elemen ditambahkan ke DOM
connectedCallback() {
const nama = this.getAttribute('nama') || 'Anonim';
const role = this.getAttribute('role') || 'Member';
const avatar = this.getAttribute('avatar') || 'https://ui-avatars.com/api/?name=' + encodeURIComponent(nama);
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.card {
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 12px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
max-width: 320px;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
img {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
}
.info h3 {
margin: 0 0 4px;
color: #cdd6f4;
font-size: 16px;
}
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
background: #6366f1;
color: white;
}
</style>
<div class="card">
<img src="${avatar}" alt="${nama}">
<div class="info">
<h3>${nama}</h3>
<span class="badge">${role}</span>
</div>
</div>
`;
}
}
customElements.define('user-card', UserCard);
// Penggunaan di HTML:
// <user-card nama="Budi Santoso" role="Admin" avatar="/img/budi.jpg"></user-card>
// <user-card nama="Sari Dewi" role="Developer"></user-card>
Custom Element dengan Extend HTML Element
// Extend elemen HTML yang sudah ada
class FancyButton extends HTMLButtonElement {
constructor() {
super();
}
connectedCallback() {
// Tambahkan style dan event listener
this.style.cssText = `
padding: 12px 24px;
border: 2px solid #6366f1;
border-radius: 8px;
background: transparent;
color: #6366f1;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
`;
this.addEventListener('mouseenter', () => {
this.style.background = '#6366f1';
this.style.color = 'white';
});
this.addEventListener('mouseleave', () => {
this.style.background = 'transparent';
this.style.color = '#6366f1';
});
}
}
// Daftarkan dengan opsi 'extends'
customElements.define('fancy-button', FancyButton, { extends: 'button' });
// Penggunaan di HTML:
// <button is="fancy-button">Klik Saya</button>
// ====== Extend input ======
class ValidatedInput extends HTMLInputElement {
connectedCallback() {
this.style.border = '2px solid #a6adc8';
this.style.borderRadius = '6px';
this.style.padding = '8px 12px';
this.addEventListener('input', () => {
if (this.validity.valid) {
this.style.borderColor = '#a6e3a1'; // hijau
} else {
this.style.borderColor = '#f38ba8'; // merah
}
});
}
}
customElements.define('validated-input', ValidatedInput, { extends: 'input' });
// <input is="validated-input" type="email" placeholder="Email kamu">
3. Shadow DOM
Shadow DOM adalah salah satu teknologi terpenting dalam Web Components. Shadow DOM menciptakan DOM tree tersembunyi (shadow tree) yang terpisah dari DOM utama dokumen. Ini menyediakan enkapsulasi sejati β CSS dan JavaScript di dalam shadow tree tidak mempengaruhi atau dipengaruhi oleh kode di luar.
Dasar Shadow DOM
// ====== Shadow DOM Open vs Closed ======
class OpenComponent extends HTMLElement {
constructor() {
super();
// mode: 'open' β shadow root bisa diakses dari luar
// this.shadowRoot bisa diakses di JavaScript
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<p>Ini di dalam Shadow DOM (open)</p>
`;
}
}
customElements.define('open-component', OpenComponent);
class ClosedComponent extends HTMLElement {
constructor() {
super();
// mode: 'closed' β shadow root TIDAK bisa diakses dari luar
// this.shadowRoot === null dari luar
this.attachShadow({ mode: 'closed' });
// Simpan referensi internal jika perlu
this._shadow = this.attachShadow({ mode: 'closed' });
}
}
customElements.define('closed-component', ClosedComponent);
// ====== Akses Shadow DOM dari luar ======
const elem = document.querySelector('open-component');
console.log(elem.shadowRoot); // ShadowRoot (mode: open)
console.log(elem.shadowRoot.innerHTML); // Bisa diakses!
const closed = document.querySelector('closed-component');
console.log(closed.shadowRoot); // null (mode: closed)
Enkapsulasi CSS dengan Shadow DOM
// Style di dalam Shadow DOM TIDAK mempengaruhi elemen di luar
// Style di luar TIDAK mempengaruhi elemen di dalam Shadow DOM
class IsolatedWidget extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
/* Style ini HANYA berlaku di dalam shadow tree */
/* Tidak akan mempengaruhi <p> atau <div> di halaman induk */
p {
color: #a6e3a1;
font-size: 18px;
background: #1e1e2e;
padding: 16px;
border-radius: 8px;
}
/* :host merujuk ke elemen host (<isolated-widget>) */
:host {
display: block;
margin: 16px 0;
font-family: 'Segoe UI', system-ui, sans-serif;
}
/* :host(.some-class) saat host punya class tertentu */
:host(.featured) {
border: 2px solid #6366f1;
border-radius: 12px;
padding: 16px;
}
/* :host-context(body.dark) saat halaman tema gelap */
:host-context(.dark) p {
color: #cdd6f4;
}
/* ::slotted() menyeleksi konten yang masuk ke slot */
::slotted(h3) {
color: #89b4fa;
margin-bottom: 8px;
}
::slotted(p) {
color: #a6adc8;
line-height: 1.6;
}
</style>
<p>Teks ini di-enkapsulasi! Style luar tidak berpengaruh.</p>
<slot></slot>
`;
}
}
customElements.define('isolated-widget', IsolatedWidget);
// <isolated-widget>
// <h3>Judul dari luar</h3> β di-style oleh ::slotted(h3)
// <p>Paragraf dari luar</p> β di-style oleh ::slotted(p)
// </isolated-widget>
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β DOCUMENT DOM TREE β
β β
β <html> β
β ββ <head> β
β ββ <body> β
β ββ <h1>Judul Halaman</h1> β
β ββ <style>body { color: white }</style> β
β ββ <user-card nama="Budi"> β HOST ELEMENT β
β β β
β ββ #shadow-root (open) β SHADOW DOM β
β ββ <style>...</style> (scoped) β
β ββ <div class="card"> β
β β ββ <img ...> β
β β ββ <h3>Budi</h3> β
β ββ <slot></slot> β SLOT β
β β
β CSS dari <body> TIDAK masuk ke shadow tree β
β CSS dari shadow TIDAK bocor ke <body> β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
4. HTML Templates & Slots
<template> dan <slot> adalah elemen HTML khusus yang sangat berguna untuk Web Components. <template> mendefinisikan fragmen HTML yang tidak dirender sampai diaktifkan oleh JavaScript. <slot> menyediakan placeholder di dalam komponen untuk konten yang di-supply dari luar.
Menggunakan HTML Template
<!-- Definisikan template di HTML -->
<template id="alert-template">
<style>
.alert {
padding: 16px 20px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
font-family: system-ui, sans-serif;
}
.alert-success { background: #1a3a2a; color: #a6e3a1; border: 1px solid #2a5a3a; }
.alert-warning { background: #3a3a1a; color: #f9e2af; border: 1px solid #5a5a2a; }
.alert-error { background: #3a1a1a; color: #f38ba8; border: 1px solid #5a2a2a; }
.alert-info { background: #1a2a3a; color: #89b4fa; border: 1px solid #2a3a5a; }
.icon { font-size: 20px; }
.close-btn {
margin-left: auto;
background: none;
border: none;
color: inherit;
cursor: pointer;
opacity: 0.6;
font-size: 18px;
}
.close-btn:hover { opacity: 1; }
</style>
<div class="alert">
<span class="icon"></span>
<span class="message"></span>
<button class="close-btn">β</button>
</div>
</template>
<script>
// Ambil template dari DOM
const template = document.getElementById('alert-template');
class AlertBox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Clone template content ke shadow DOM
const content = template.content.cloneNode(true);
this.shadowRoot.appendChild(content);
}
connectedCallback() {
const type = this.getAttribute('type') || 'info';
const pesan = this.getAttribute('message') || 'Ini pesan alert';
const dismissible = this.hasAttribute('dismissible');
const icons = {
success: 'β
',
warning: 'β οΈ',
error: 'β',
info: 'βΉοΈ'
};
const alertDiv = this.shadowRoot.querySelector('.alert');
alertDiv.classList.add(`alert-${type}`);
this.shadowRoot.querySelector('.icon').textContent = icons[type] || icons.info;
this.shadowRoot.querySelector('.message').textContent = pesan;
const closeBtn = this.shadowRoot.querySelector('.close-btn');
if (!dismissible) {
closeBtn.style.display = 'none';
} else {
closeBtn.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('alert-dismissed', {
bubbles: true,
composed: true,
}));
this.remove();
});
}
}
}
customElements.define('alert-box', AlertBox);
</script>
<!-- Penggunaan -->
<alert-box type="success" message="Data berhasil disimpan!" dismissible></alert-box>
<alert-box type="error" message="Gagal menghubungi server" dismissible></alert-box>
<alert-box type="warning" message="Sesi akan berakhir dalam 5 menit"></alert-box>
<alert-box type="info" message="Versi baru tersedia, silakan refresh"></alert-box>
Named Slots & Default Slot
// ====== Modal Component dengan Named Slots ======
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.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 16px;
padding: 0;
min-width: 400px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
box-shadow: 0 24px 48px rgba(0,0,0,0.4);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #313244;
}
.modal-header h2 {
margin: 0;
color: #cdd6f4;
font-size: 18px;
}
.close-btn {
background: none;
border: none;
color: #6c7086;
font-size: 24px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
}
.close-btn:hover { background: #313244; color: #cdd6f4; }
.modal-body {
padding: 24px;
overflow-y: auto;
max-height: 50vh;
color: #bac2de;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #313244;
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Slot styling */
::slotted([slot="header"]) {
margin: 0;
color: #cdd6f4;
}
::slotted([slot="footer"]) {
margin: 0;
}
</style>
<div class="overlay">
<div class="modal">
<div class="modal-header">
<!-- Named slot: "header" -->
<slot name="header"><h2>Modal Title</h2></slot>
<button class="close-btn">β</button>
</div>
<div class="modal-body">
<!-- Default slot: konten tanpa nama -->
<slot>Konten modal di sini.</slot>
</div>
<div class="modal-footer">
<!-- Named slot: "footer" -->
<slot name="footer"></slot>
</div>
</div>
</div>
`;
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => {
this.close();
});
this.shadowRoot.querySelector('.overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) this.close();
});
}
open() { this.setAttribute('open', ''); }
close() { this.removeAttribute('open'); }
}
customElements.define('modal-dialog', ModalDialog);
// Penggunaan:
// <modal-dialog id="myModal">
// <h2 slot="header">Konfirmasi Hapus</h2>
// <p>Apakah Anda yakin ingin menghapus item ini?</p>
// <p>Tindakan ini tidak bisa dibatalkan.</p>
// <div slot="footer">
// <button onclick="myModal.close()">Batal</button>
// <button onclick="hapusItem()">Hapus</button>
// </div>
// </modal-dialog>
5. Lifecycle Callbacks
Custom Elements memiliki serangkaian lifecycle callbacks yang dipanggil secara otomatis oleh browser pada titik-titik penting dalam kehidupan elemen. Memahami lifecycle ini sangat penting untuk membuat komponen yang berperilaku benar.
// ====== Semua Lifecycle Callbacks ======
class LifecycleDemo extends HTMLElement {
// 1. Constructor β saat elemen dibuat (new LifecycleDemo() atau parsing HTML)
constructor() {
super();
console.log('1οΈβ£ Constructor: Elemen dibuat');
// β
Boleh: setup shadow DOM, bind methods, inisialisasi state
this.attachShadow({ mode: 'open' });
this._state = { count: 0 };
// β Jangan: akses attributes, children, atau parent di sini
// this.getAttribute('data'); // Kemungkinan null karena belum dipasang
}
// 2. connectedCallback β saat elemen ditambahkan ke DOM
connectedCallback() {
console.log('2οΈβ£ Connected: Elemen ditambahkan ke DOM');
// β
Boleh: baca attributes, setup event listeners, fetch data
const nama = this.getAttribute('nama') || 'World';
this.render(nama);
// Setup event listener
this._handleClick = () => {
this._state.count++;
this.updateCounter();
};
this.shadowRoot.querySelector('button')
?.addEventListener('click', this._handleClick);
// Fetch data
this.loadData();
}
// 3. disconnectedCallback β saat elemen dihapus dari DOM
disconnectedCallback() {
console.log('3οΈβ£ Disconnected: Elemen dihapus dari DOM');
// β
Cleanup: hapus event listeners, stop timers, abort fetch
this.shadowRoot.querySelector('button')
?.removeEventListener('click', this._handleClick);
if (this._timer) clearInterval(this._timer);
if (this._abortController) this._abortController.abort();
}
// 4. adoptedCallback β saat elemen dipindahkan ke dokumen baru
adoptedCallback() {
console.log('4οΈβ£ Adopted: Elemen dipindahkan ke dokumen baru');
// Jarang digunakan, tapi berguna untuk iframe atau document.adoptNode()
const newDoc = document;
console.log('Dipindahkan ke dokumen baru:', newDoc.title);
}
// 5. attributeChangedCallback β saat attribute diamati berubah
static get observedAttributes() {
return ['nama', 'count', 'disabled'];
}
attributeChangedCallback(name, oldVal, newVal) {
console.log(`5οΈβ£ Attribute "${name}" berubah: "${oldVal}" β "${newVal}"`);
// β
Update UI berdasarkan attribute baru
if (name === 'nama' && this.isConnected) {
this.render(newVal);
}
if (name === 'disabled') {
const btn = this.shadowRoot.querySelector('button');
if (btn) btn.disabled = newVal !== null;
}
}
// Helper methods
render(nama) {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; padding: 16px; font-family: system-ui; }
p { color: #cdd6f4; }
button { padding: 8px 16px; border-radius: 6px; cursor: pointer; }
</style>
<p>Halo, ${nama}!</p>
<p>Klik: <strong id="count">${this._state.count}</strong> kali</p>
<button>Klik Saya</button>
`;
}
updateCounter() {
const el = this.shadowRoot.getElementById('count');
if (el) el.textContent = this._state.count;
}
async loadData() {
this._abortController = new AbortController();
try {
// const res = await fetch('/api/data', { signal: this._abortController.signal });
} catch (e) {
if (e.name !== 'AbortError') console.error(e);
}
}
}
customElements.define('lifecycle-demo', LifecycleDemo);
// Urutan lifecycle saat parsing HTML:
// 1. constructor()
// 2. attributeChangedCallback() (jika ada attribute)
// 3. connectedCallback()
// Urutan saat update attribute:
// 1. attributeChangedCallback()
// Urutan saat remove:
// 1. disconnectedCallback()
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β CUSTOM ELEMENT LIFECYCLE β β β β Elemen Dibuat β β β β β βΌ β β βββββββββββββββ β β β constructor()β β Setup awal: shadow DOM, state β β ββββββββ¬βββββββ β β βΌ β β βββββββββββββββββββββββββ β β β attributeChangedCall. β β Attribute berubah β β ββββββββ¬βββββββββββββββββ β β βΌ β β βββββββββββββββββββββ β β β connectedCallback β β Masuk ke DOM: render, events β β ββββββββ¬βββββββββββββ β β β β β β βββββββββββββββββββββββββββ β β ββββββ attributeChangedCallbackβ β Update β β β βββββββββββββββββββββββββββ β β β β β β βββββββββββββββββββββ β β ββββββ adoptedCallback β β Pindah dokumen β β β βββββββββββββββββββββ β β β β β βΌ β β ββββββββββββββββββββββββ β β β disconnectedCallback β β Keluar dari DOM: cleanup β β ββββββββββββββββββββββββ β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
6. Observed Attributes & Properties
Custom Elements mendukung dua cara untuk mengkonfigurasi: attributes (HTML attributes, string) dan properties (JavaScript properties, tipe apapun). Keduanya perlu disinkronkan untuk pengalaman pengembang yang baik.
// ====== Sinkronisasi Attribute β Property ======
class RatingStars extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._value = 0;
this._max = 5;
this._readonly = false;
}
// Daftar attribute yang diamati
static get observedAttributes() {
return ['value', 'max', 'readonly'];
}
// Getter & Setter untuk properties (JavaScript API)
get value() { return this._value; }
set value(val) {
this._value = Math.max(0, Math.min(Number(val), this._max));
// Sinkronkan ke attribute
this.setAttribute('value', this._value);
this._render();
}
get max() { return this._max; }
set max(val) {
this._max = Number(val) || 5;
this.setAttribute('max', this._max);
this._render();
}
get readonly() { return this._readonly; }
set readonly(val) {
this._readonly = val !== null && val !== false;
if (this._readonly) {
this.setAttribute('readonly', '');
} else {
this.removeAttribute('readonly');
}
this._render();
}
// Callback saat attribute berubah (dari HTML atau setAttribute)
attributeChangedCallback(name, oldVal, newVal) {
switch (name) {
case 'value':
this._value = Number(newVal) || 0;
break;
case 'max':
this._max = Number(newVal) || 5;
break;
case 'readonly':
this._readonly = newVal !== null;
break;
}
if (this.isConnected) this._render();
}
connectedCallback() {
this._render();
}
_render() {
let stars = '';
for (let i = 1; i <= this._max; i++) {
const filled = i <= this._value ? 'β
' : 'β';
const color = i <= this._value ? '#f9e2af' : '#585b70';
stars += `<span class="star" data-value="${i}"
style="color:${color};cursor:${this._readonly?'default':'pointer'};font-size:24px">
${filled}
</span>`;
}
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-flex; gap: 4px; align-items: center; }
.star { transition: transform 0.1s; user-select: none; }
.star:hover { transform: scale(1.2); }
</style>
${stars}
<span style="margin-left:8px;color:#a6adc8;font-size:14px">
${this._value}/${this._max}
</span>
`;
if (!this._readonly) {
this.shadowRoot.querySelectorAll('.star').forEach(star => {
star.addEventListener('click', (e) => {
this.value = Number(e.target.dataset.value);
this.dispatchEvent(new CustomEvent('rating-change', {
detail: { value: this._value },
bubbles: true,
composed: true,
}));
});
});
}
}
}
customElements.define('rating-stars', RatingStars);
// ====== Penggunaan ======
// Via HTML (attributes β semua string):
// <rating-stars value="4" max="5"></rating-stars>
// <rating-stars value="3" max="10" readonly></rating-stars>
// Via JavaScript (properties β tipe apapun):
// const stars = document.querySelector('rating-stars');
// stars.value = 4; // number
// stars.max = 5; // number
// stars.readonly = true; // boolean
7. Custom Events & Communication
Web Components berkomunikasi dengan komponen lain melalui Custom Events. Ini adalah pola yang sama dengan event DOM native β dan memastikan komponen tetap loosely coupled.
// ====== Component yang mengirim event ======
class SearchInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host { display: block; }
input {
width: 100%;
padding: 12px 16px;
border: 2px solid #313244;
border-radius: 8px;
background: #181825;
color: #cdd6f4;
font-size: 16px;
outline: none;
box-sizing: border-box;
}
input:focus { border-color: #6366f1; }
</style>
<input type="search" placeholder="Cari...">
`;
}
connectedCallback() {
const input = this.shadowRoot.querySelector('input');
let debounceTimer;
input.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
// Dispatch custom event dengan data
this.dispatchEvent(new CustomEvent('search', {
detail: {
query: e.target.value,
timestamp: Date.now(),
},
bubbles: true, // Event naik ke parent
composed: true, // Event melewati shadow boundary
}));
}, 300); // Debounce 300ms
});
}
}
customElements.define('search-input', SearchInput);
// ====== Component yang mendengarkan event ======
class SearchResults extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<div class="results"></div>`;
}
connectedCallback() {
// Dengarkan event dari search-input
document.addEventListener('search', (e) => {
this.handleSearch(e.detail.query);
});
}
async handleSearch(query) {
if (!query.trim()) {
this.shadowRoot.querySelector('.results').innerHTML = '<p>Ketik untuk mulai mencari</p>';
return;
}
// Simulasi pencarian
const results = [
'React.js Tutorial', 'Vue.js Guide', 'Svelte Basics',
'Web Components', 'Angular Framework'
].filter(item => item.toLowerCase().includes(query.toLowerCase()));
this.shadowRoot.querySelector('.results').innerHTML = results.length > 0
? results.map(r => `<div class="result-item">${r}</div>`).join('')
: '<p>Tidak ditemukan</p>';
}
}
customElements.define('search-results', SearchResults);
// ====== Penggunaan ======
// <search-input></search-input>
// <search-results></search-results>
//
// Event "search" dari search-input bubbles up dan di-dengar
// oleh search-results melalui document listener
8. Styling Web Components
Styling Web Components membutuhkan pemahaman khusus karena Shadow DOM menyediakan enkapsulasi. Ada beberapa cara untuk mengontrol styling dari dalam maupun dari luar komponen.
// ====== CSS Custom Properties (CSS Variables) β tembus Shadow DOM ======
// CSS Custom Properties bisa menembus Shadow DOM!
class ThemedCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 20px;
border-radius: 12px;
/* Gunakan CSS custom properties dari luar */
background: var(--card-bg, #1e1e2e);
color: var(--card-text, #cdd6f4);
border: 1px solid var(--card-border, #313244);
font-family: var(--card-font, system-ui, sans-serif);
}
h3 {
color: var(--card-title, #89b4fa);
font-size: var(--card-title-size, 20px);
}
p { color: var(--card-text, #a6adc8); }
</style>
<h3><slot name="title">Judul</slot></h3>
<p><slot>Konten...</slot></p>
`;
}
}
customElements.define('themed-card', ThemedCard);
// ====== Penggunaan dengan CSS Variables dari luar ======
/*
<style>
/* Override tema untuk semua themed-card */
themed-card {
--card-bg: #11111b;
--card-title: #f9e2af;
--card-text: #bac2de;
--card-border: #45475a;
}
/* Override khusus untuk satu card */
.featured-card {
--card-bg: #1e1e3e;
--card-title: #f5c2e7;
--card-border: #6366f1;
}
</style>
<themed-card>
<span slot="title">Tutorial Web</span>
Belajar Web Components dengan mudah.
</themed-card>
<themed-card class="featured-card">
<span slot="title">β Featured</span>
Komponen dengan tema kustom!
</themed-card>
*/
// ====== ::part() β Mengekspos bagian spesifik ======
class PartDemo extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.wrapper { padding: 20px; background: #1e1e2e; border-radius: 12px; }
.header { color: #cdd6f4; }
.content { color: #a6adc8; }
.footer { color: #6c7086; }
</style>
<div class="wrapper">
<h2 part="header" class="header">Judul</h2>
<div part="content" class="content"><slot></slot></div>
<footer part="footer" class="footer">Footer</footer>
</div>
`;
}
}
customElements.define('part-demo', PartDemo);
// Dari luar, bisa style bagian yang di-ekspos via ::part():
/*
part-demo::part(header) {
color: #f9e2af;
font-size: 28px;
}
part-demo::part(content) {
padding: 16px;
background: #313244;
border-radius: 8px;
}
part-demo::part(footer) {
border-top: 1px solid #45475a;
padding-top: 12px;
}
*/
9. Teknik Lanjutan
Berikut beberapa teknik lanjutan untuk membuat Web Components yang lebih powerful dan profesional.
// ====== 1. Form-Associated Custom Elements ======
// Komponen bisa berpartisipasi dalam form submission
class MyCheckbox extends HTMLElement {
static formAssociated = true; // Aktifkan form association
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Akses internals untuk form integration
this.internals = this.attachInternals();
this._checked = false;
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; }
.box {
width: 20px; height: 20px; border: 2px solid #6366f1;
border-radius: 4px; display: flex; align-items: center;
justify-content: center; transition: background 0.2s;
}
.box.checked { background: #6366f1; }
label { color: #cdd6f4; user-select: none; }
</style>
<div class="box"></div>
<label><slot>Checkbox</slot></label>
`;
this.addEventListener('click', () => this.toggle());
this._render();
}
toggle() {
this._checked = !this._checked;
// Update form value
this.internals.setFormValue(this._checked ? 'on' : null);
this.internals.setValidity({});
this._render();
this.dispatchEvent(new Event('change', { bubbles: true }));
}
get checked() { return this._checked; }
set checked(val) { this._checked = val; this._render(); }
// Form reset callback
formResetCallback() {
this._checked = false;
this._render();
}
_render() {
const box = this.shadowRoot.querySelector('.box');
if (box) box.classList.toggle('checked', this._checked);
}
}
customElements.define('my-checkbox', MyCheckbox);
// <form>
// <my-checkbox name="agree">Saya setuju</my-checkbox>
// <button type="submit">Submit</button>
// </form>
// ====== 2. Autonomous mixin pattern ======
const ResizeMixin = (superclass) => class extends superclass {
connectedCallback() {
super.connectedCallback?.();
this._resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
this.onResize?.(entry.contentRect);
}
});
this._resizeObserver.observe(this);
}
disconnectedCallback() {
super.disconnectedCallback?.();
this._resizeObserver?.disconnect();
}
};
const IntersectionMixin = (superclass) => class extends superclass {
connectedCallback() {
super.connectedCallback?.();
this._intersectionObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
if (entry.isIntersecting) this.onVisible?.();
else this.onHidden?.();
}
});
this._intersectionObserver.observe(this);
}
disconnectedCallback() {
super.disconnectedCallback?.();
this._intersectionObserver?.disconnect();
}
};
// Gunakan mixin untuk komponen yang responsif & lazy
class ResponsiveChart extends ResizeMixin(IntersectionMixin(HTMLElement)) {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._isVisible = false;
}
onResize(rect) {
console.log(`Chart resized: ${rect.width}x${rect.height}`);
// Re-render chart sesuai ukuran baru
}
onVisible() {
this._isVisible = true;
console.log('Chart visible β mulai animasi');
}
onHidden() {
this._isVisible = false;
console.log('Chart hidden β pause animasi');
}
}
customElements.define('responsive-chart', ResponsiveChart);
10. Quiz: Uji Pemahamanmu!
Setelah membaca tutorial di atas, jawablah 5 pertanyaan berikut untuk menguji pemahamanmu tentang Web Components: