ElementInternals: Jurus Rahasia Membuat Web Component Anda Jadi Elemen Form Sejati
1. Pendahuluan
Sebagai developer web, kita sering membangun komponen UI kustom untuk memenuhi kebutuhan desain atau fungsionalitas yang tidak tersedia di elemen HTML standar. Bayangkan Anda sedang membangun Design System yang punya komponen rating-input atau toggle-switch yang unik. Komponen-komponen ini terlihat cantik dan berfungsi dengan baik, namun ada satu masalah besar: bagaimana jika Anda ingin menggunakannya di dalam sebuah <form> HTML dan membuatnya berinteraksi layaknya input atau select biasa?
Secara default, Web Component yang Anda buat, meskipun terlihat seperti elemen form, tidak akan ikut ter-submit ketika form disubmit, tidak bisa divalidasi oleh browser, dan tidak akan direset bersama elemen form lainnya. Ini adalah pain point umum yang memaksa developer untuk menggunakan trik seperti input tersembunyi atau JavaScript kustom yang rumit.
Untungnya, standar web modern punya solusinya: Form-Associated Custom Elements yang didukung oleh ElementInternals API. Jurus rahasia ini memungkinkan Web Component Anda untuk “mengasosiasikan diri” dengan form induknya, berpartisipasi dalam proses submit, validasi, dan reset form secara native. Ini adalah game-changer untuk membangun UI form yang kompleks, aksesibel, dan terintegrasi sempurna.
Artikel ini akan membawa Anda menyelami ElementInternals dan menunjukkan bagaimana Anda bisa mengubah Web Component kustom Anda menjadi elemen form sejati.
2. Memahami Tantangan Custom Element dalam Form
Sebelum kita masuk ke solusi, mari kita pahami mengapa Web Component kustom tidak secara otomatis berperilaku seperti elemen form:
- Tidak Ikut di-Submit: Ketika Anda menekan tombol submit pada
<form>, browser akan mengumpulkannamedanvaluedari semua elemen form native (<input>,<textarea>,<select>) di dalamnya. Web Component kustom Anda, jika tidak diatur secara khusus, akan diabaikan. ❌ Anda harus menambahkaninputtersembunyi dan menyinkronkan nilainya secara manual. - Tidak Bisa Divalidasi: Validasi HTML5 seperti
required,min,max,patterntidak akan bekerja pada Web Component Anda. Anda juga tidak bisa menggunakanelement.checkValidity()atauelement.reportValidity()secara native. ❌ Anda harus mengimplementasikan logika validasi kustom dan menampilkan pesan error secara manual. - Tidak Bisa Direset: Ketika form di-reset, Web Component Anda tidak akan kembali ke nilai awalnya. ❌ Anda harus menambahkan logika reset kustom.
- Aksesibilitas yang Buruk: Elemen form native memiliki peran ARIA implisit (misalnya,
role="textbox",role="checkbox"). Web Component kustom yang tidak diatur akan kehilangan ini, menyulitkan pengguna assistive technology. ❌ Membutuhkan banyak atribut ARIA manual yang kompleks.
Singkatnya, tanpa ElementInternals, Web Component kustom Anda hanyalah div yang terlihat seperti input, bukan input yang sebenarnya.
3. Mengenal Form-Associated Custom Elements dan ElementInternals
Form-Associated Custom Elements adalah Web Component yang secara eksplisit memberitahu browser bahwa mereka ingin berpartisipasi dalam interaksi form. Mereka bisa memiliki name, value, disabled state, dan bahkan divalidasi.
Untuk mencapai ini, kita menggunakan ElementInternals API. Ini adalah objek yang memberikan akses ke “internal” browser untuk elemen kustom Anda, memungkinkan Anda untuk:
- Mengatur nilai form yang akan disubmit.
- Mengatur status validitas dan pesan validasi.
- Mengakses properti form induk (misalnya,
form.elements). - Menyesuaikan perilaku elemen saat form di-reset atau di-disable.
📌 Intinya: ElementInternals adalah jembatan antara Web Component kustom Anda dan perilaku native browser dalam sebuah form.
4. Langkah-langkah Implementasi ElementInternals
Mari kita lihat bagaimana mengimplementasikan ElementInternals dalam Web Component Anda.
A. Deklarasikan static formAssociated = true;
Langkah pertama adalah memberitahu browser bahwa Web Component Anda ingin menjadi form-associated. Anda melakukannya dengan menambahkan properti static formAssociated = true; di dalam definisi class komponen Anda.
class MyCustomInput extends HTMLElement {
static formAssociated = true; // ✅ Penting!
constructor() {
super();
// ...
}
// ...
}
B. Dapatkan Akses ke ElementInternals dengan this.attachInternals()
Di dalam constructor komponen Anda, panggil this.attachInternals() untuk mendapatkan objek ElementInternals. Objek ini kemudian bisa Anda simpan untuk digunakan nanti.
class MyCustomInput extends HTMLElement {
static formAssociated = true;
/** @type {ElementInternals} */
internals; // Deklarasi properti untuk menyimpan ElementInternals
constructor() {
super();
this.internals = this.attachInternals(); // ✅ Dapatkan ElementInternals
// ...
}
// ...
}
C. Mengatur Nilai Form dengan this.internals.setFormValue()
Ketika nilai komponen kustom Anda berubah, Anda harus memberitahu form induk tentang nilai barunya menggunakan this.internals.setFormValue(value). Nilai ini akan menjadi bagian dari data form yang disubmit.
class MyCustomInput extends HTMLElement {
// ... (static formAssociated dan constructor)
_value = '';
set value(newValue) {
this._value = newValue;
this.internals.setFormValue(newValue); // ✅ Set nilai yang akan disubmit
// Perbarui UI komponen Anda di sini
}
get value() {
return this._value;
}
// Contoh event listener yang mengubah nilai
handleInput(event) {
this.value = event.target.value;
}
}
D. Mengelola Validitas dengan this.internals.setValidity()
Ini adalah fitur paling powerful dari ElementInternals. Anda bisa mengatur status validitas komponen Anda dan bahkan pesan validasinya.
class MyCustomInput extends HTMLElement {
// ... (static formAssociated, constructor, value getter/setter)
checkValidityState() {
const isValid = this.value !== ''; // Contoh validasi sederhana
if (isValid) {
this.internals.setValidity({}); // ✅ Komponen valid
} else {
this.internals.setValidity(
{ customError: true }, // Objek state validitas
'Input ini tidak boleh kosong!', // Pesan validasi
this.shadowRoot.querySelector('input') // Elemen anchor untuk pesan
);
}
}
connectedCallback() {
// Panggil saat komponen terhubung dan setiap kali nilai berubah
this.shadowRoot.addEventListener('input', () => this.checkValidityState());
this.checkValidityState(); // Panggil pertama kali
}
}
Penjelasan setValidity:
validityStateObject: Objek yang mirip denganelement.validitypada elemen native (misalnya{ valueMissing: true },{ customError: true }). Jika kosong{}, berarti valid.validationMessage: String pesan error yang akan ditampilkan browser (jika ada).anchorElement: (Opsional) Elemen di dalam Shadow DOM yang akan digunakan browser untuk menampilkan pesan validasi (misalnya, di mana tooltip error akan muncul).
Setelah memanggil setValidity(), browser akan:
- Menerapkan pseudo-class CSS seperti
:valid,:invalid,:user-valid,:user-invalidke Web Component Anda. - Memungkinkan form induk untuk mengetahui status validitas komponen.
- Memungkinkan Anda memanggil
this.internals.checkValidity()danthis.internals.reportValidity()pada komponen Anda.
E. Lifecycle Callback untuk Form-Associated Elements
Ada beberapa callback khusus yang bisa Anda implementasikan di Web Component Anda untuk bereaksi terhadap peristiwa form:
formAssociatedCallback(form): Dipanggil ketika komponen terhubung ke form (atau terputus).formDisabledCallback(disabled): Dipanggil ketika form atau elemen itu sendiri di-disable.formResetCallback(): Dipanggil ketika form di-reset.formStateRestoreCallback(state, mode): Dipanggil ketika browser mengembalikan state form (misalnya, setelah navigasi maju/mundur).
class MyCustomInput extends HTMLElement {
// ... (properti dan metode sebelumnya)
formAssociatedCallback(form) {
console.log('Komponen terhubung ke form:', form);
}
formDisabledCallback(disabled) {
this.shadowRoot.querySelector('input').disabled = disabled; // ✅ Sinkronkan state disabled
}
formResetCallback() {
this.value = this.getAttribute('value') || ''; // ✅ Reset ke nilai awal atau kosong
this.checkValidityState();
}
formStateRestoreCallback(state, mode) {
this.value = state; // ✅ Pulihkan state
this.checkValidityState();
}
}
5. Contoh Praktis: Custom Rating Input
Mari kita buat contoh konkret: sebuah komponen star-rating yang bisa digunakan di dalam form.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Custom Rating Input dengan ElementInternals</title>
<style>
body { font-family: sans-serif; padding: 20px; }
form { margin-top: 20px; border: 1px solid #eee; padding: 20px; border-radius: 8px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; font-weight: bold; }
input[type="submit"] { padding: 10px 20px; background-color: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer; }
input[type="submit"]:hover { background-color: #0056b3; }
button[type="reset"] { padding: 10px 20px; background-color: #6c757d; color: white; border: none; border-radius: 5px; cursor: pointer; margin-left: 10px; }
button[type="reset"]:hover { background-color: #5a6268; }
/* Styling untuk custom-rating */
custom-rating {
display: inline-flex;
font-size: 2em;
cursor: pointer;
}
custom-rating .star {
color: #ccc; /* Default abu-abu */
transition: color 0.2s;
}
custom-rating .star.filled {
color: gold; /* Bintang terisi */
}
custom-rating:focus {
outline: 2px solid blue;
outline-offset: 2px;
}
custom-rating:invalid .star {
color: red; /* Indikasi error */
}
custom-rating:user-invalid .star {
animation: shake 0.5s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
50% { transform: translateX(5px); }
75% { transform: translateX(-5px); }
}
</style>
</head>
<body>
<h1>Feedback Form</h1>
<form>
<div class="form-group">
<label for="rating">Berikan Rating:</label>
<custom-rating id="rating" name="user_rating" required min="1" max="5"></custom-rating>
</div>
<div class="form-group">
<label for="comment">Komentar:</label>
<textarea id="comment" name="comment" rows="4" cols="50"></textarea>
</div>
<input type="submit" value="Submit Feedback">
<button type="reset">Reset</button>
</form>
<script>
class CustomRating extends HTMLElement {
static formAssociated = true;
static get observedAttributes() { return ['value', 'required', 'disabled', 'min', 'max']; }
/** @type {ElementInternals} */
internals;
_value = 0;
_required = false;
_disabled = false;
_min = 0;
_max = 5;
constructor() {
super();
this.internals = this.attachInternals();
this.attachShadow({ mode: 'open' });
this.setupShadowDOM();
this.addEventListener('click', this.handleClick);
this.addEventListener('keydown', this.handleKeyDown);
this.setAttribute('tabindex', '0'); // Agar bisa di-focus
this.setAttribute('role', 'slider'); // Peran ARIA
this.setAttribute('aria-valuemin', this.min);
this.setAttribute('aria-valuemax', this.max);
}
setupShadowDOM() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-flex;
font-size: 2em;
cursor: pointer;
}
.star {
color: #ccc;
transition: color 0.2s;
}
.star.filled {
color: gold;
}
/* Pseudo-class styling */
:host(:focus) {
outline: 2px solid blue;
outline-offset: 2px;
}
:host(:invalid) .star {
color: red;
}
:host(:user-invalid) .star {
animation: shake 0.5s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
50% { transform: translateX(5px); }
75% { transform: translateX(-5px); }
}
</style>
${Array(this.max).fill(0).map((_, i) => `<span class="star" data-value="${i + 1}">★</span>`).join('')}
`;
this.stars = this.shadowRoot.querySelectorAll('.star');
}
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case 'value':
this.value = parseInt(newValue) || 0;
break;
case 'required':
this.required = newValue !== null;
break;
case 'disabled':
this.disabled = newValue !== null;
break;
case 'min':
this.min = parseInt(newValue) || 0;
break;
case 'max':
this.max = parseInt(newValue) || 5;
this.setupShadowDOM(); // Re-render stars if max changes
break;
}
}
connectedCallback() {
this.updateStars();
this.checkValidityState();
}
set value(newValue) {
const oldValue = this._value;
if (newValue === oldValue) return;
this._value = Math.max(this.min, Math.min(this.max, newValue));
this.setAttribute('value', this._value);
this.internals.setFormValue(this._value);
this.setAttribute('aria-valuenow', this._value);
this.updateStars();
this.checkValidityState();
this.dispatchEvent(new Event('change', { bubbles: true }));
}
get value() { return this._value; }
set required(newValue) {
if (newValue === this._required) return;
this._required = newValue;
this.checkValidityState();
}
get required() { return this._required; }
set disabled(newValue) {
if (newValue === this._disabled) return;
this._disabled = newValue;
this.setAttribute('aria-disabled', newValue);
// Sinkronkan state disabled ke elemen internal jika ada
if (this.stars) {
this.stars.forEach(star => star.style.pointerEvents = newValue ? 'none' : 'auto');
}
}
get disabled() { return this._disabled; }
set min(newValue) {
this._min = newValue;
this.setAttribute('aria-valuemin', newValue);
this.checkValidityState();
}
get min() { return this._min; }
set max(newValue) {
this._max = newValue;
this.setAttribute('aria-valuemax', newValue);
this.checkValidityState();
}
get max() { return this._max; }
updateStars() {
this.stars.forEach((star, index) => {
if (index < this.value) {
star.classList.add('filled');
} else {
star.classList.remove('filled');
}
});
}
handleClick(event) {
if (this.disabled) return;
const starValue = parseInt(event.target.dataset.value);
if (!isNaN(starValue)) {
this.value = starValue;
}
}
handleKeyDown(event) {
if (this.disabled) return;
let newValue = this.value;
switch (event.key) {
case 'ArrowLeft':
case 'ArrowDown':
newValue = Math.max(this.min, this.value - 1);
break;
case 'ArrowRight':
case 'ArrowUp':
newValue = Math.min(this.max, this.value + 1);
break;
case 'Home':
newValue = this.min;
break;
case 'End':
newValue = this.max;
break;
default:
return; // Jangan cegah default untuk tombol lain
}
if (newValue !== this.value) {
this.value = newValue;
event.preventDefault(); // Cegah scrolling halaman
}
}
checkValidityState() {
let message = '';
let validity = {};
if (this.required && this.value === 0) {
validity.valueMissing = true;
message = 'Rating wajib diisi.';
} else if (this.value < this.min) {
validity.rangeUnderflow = true;
message = `Rating minimal adalah ${this.min}.`;
} else if (this.value > this.max) {
validity.rangeOverflow = true;
message = `Rating maksimal adalah ${this.max}.`;
}
this.internals.setValidity(
validity,
message,
this.stars[0] // Anchor message to the first star
);
}
// Metode untuk validasi yang bisa dipanggil dari luar
checkValidity() { return this.internals.checkValidity(); }
reportValidity() { return this.internals.reportValidity(); }
// Form callbacks
formDisabledCallback(disabled) { this.disabled = disabled; }
formResetCallback() { this.value = parseInt(this.getAttribute('value')) || 0; }
formStateRestoreCallback(state, mode) { this.value = state; }
}
customElements.define('custom-rating', CustomRating);
// Contoh bagaimana mengambil data form
document.querySelector('form').addEventListener('submit', function(event) {
event.preventDefault();
const formData = new FormData(this);
const data = {};
for (const [key, value] of formData.entries()) {
data[key] = value;
}
console.log('Form Data Disubmit:', data);
alert('Form disubmit! Cek console untuk datanya.');
});
</script>
</body>
</html>
Dalam contoh di atas, custom-rating sekarang:
- Mempunyai
namedanvalueyang akan disubmit. - Bisa divalidasi dengan atribut
required. - Akan menunjukkan pesan validasi native browser.
- Bisa di-disable oleh form.
- Bisa di-reset oleh form.
- Memiliki atribut ARIA yang relevan untuk aksesibilitas.
6. Tips dan Best Practices
- Aksesibilitas (A11y): Selalu sertakan atribut ARIA yang relevan (misalnya
role,aria-label,aria-valuemin,aria-valuemax,aria-valuenow). Pastikan juga komponen Anda bisa diakses dan dioperasikan dengan keyboard. - Styling Pseudo-class: Manfaatkan pseudo-class seperti
:host(:valid),:host(:invalid),:host(:user-valid),:host(:user-invalid)di CSS Shadow DOM Anda untuk memberikan feedback visual yang konsisten dengan elemen form native. - Default Value: Pastikan
formResetCallbackmengembalikan komponen ke nilai default yang masuk akal, biasanya dari atributvalueawal atau nilai kosong. - Progressive Enhancement: Untuk browser yang belum mendukung
ElementInternals(meskipun dukungan sudah sangat luas), pertimbangkan fallback atau pastikan form masih bisa disubmit meskipun tanpa validasi kustom yang canggih. nameAtribut: Pastikan konsumen komponen Anda selalu menyertakan atributnamepada Web Component Anda agar nilainya bisa disubmit.disabledAtribut: ImplementasikanformDisabledCallbackuntuk menyinkronkan statedisabledke elemen internal Shadow DOM Anda, sehingga interaksi pengguna diblokir.
Kesimpulan
ElementInternals adalah API yang sangat powerful dan sering diabaikan dalam ekosistem Web Components. Dengan memanfaatkannya, Anda dapat membangun elemen form kustom yang tidak hanya terlihat dan terasa seperti bagian dari web, tetapi juga berfungsi seperti itu secara native. Ini meningkatkan aksesibilitas, konsistensi, dan mengurangi kompleksitas JavaScript yang harus Anda tulis.
Menguasai Form-Associated Custom Elements akan memberdayakan Anda untuk membuat Design System atau komponen UI yang lebih robust, fleksibel, dan terintegrasi penuh dengan standar web. Jadi, lain kali Anda membuat input kustom, jangan hanya membuatnya terlihat seperti form, buatlah ia menjadi form sejati!