Bab 6: Web Components
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
- Custom Elements — Bikin tag HTML baru
- Shadow DOM — Enkapsulasi style (CSS tidak bocor keluar/masuk)
- 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
// 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);<!-- Sekarang bisa dipakai di HTML! -->
<my-counter></my-counter>
<my-counter></my-counter> <!-- instance terpisah -->Lifecycle Callbacks
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
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);<user-card name="Yazid" role="Developer" avatar="photo.jpg"></user-card>Customized Built-in Elements
// 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' });<!-- Pakai dengan is="" -->
<button is="fancy-button">Klik Saya!</button>- Nama HARUS pakai dash:
my-element✅,myelement❌ - Jangan akses DOM di constructor: DOM belum ready. Pakai
connectedCallback innerHTMLhapus event listeners: Setelah re-render, pasang ulang listeners- 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
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);<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
// 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 → nullContoh: Toggle Switch Component
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);<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".
<template id="cardTemplate">
<div class="card">
<h3 class="title"></h3>
<p class="body"></p>
<button class="action">Detail</button>
</div>
</template>// 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
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);<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
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);<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
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
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!)
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);<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
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);/* 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.
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);// 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
// 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
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">×</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);<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>- Event retargeting:
event.targetdari luar selalu host element, bukan internal element composed: falsedefault: Custom event tidak keluar shadow DOM kecuali di-setcomposed: true- Focus events:
focusin/focusoutcomposed, tapifocus/blurTIDAK - Form participation: Element di shadow DOM tidak otomatis ikut form. Perlu
ElementInternalsAPI
🏆 Challenge
Buat <star-rating> Web Component:
- Tampilkan 5 bintang (★)
- Hover → bintang menyala sampai posisi cursor
- Click → set rating (tetap menyala)
- Attribute
valueuntuk set/get rating - Dispatch event
rating-changesaat user klik - Pakai Shadow DOM (style terisolasi)
- CSS variable
--star-coloruntuk customize warna dari luar
// 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.