Membangun Custom Form Elements yang Robust dan Aksesibel dengan Web Components
1. Pendahuluan
Pernahkah Anda merasa terbatas dengan elemen form HTML standar seperti <input>, <select>, atau <textarea>? Desain yang kaku, fungsionalitas yang kurang, atau kesulitan dalam mengintegrasikannya dengan UI yang kompleks seringkali membuat developer frustrasi. Akibatnya, kita seringkali membuat elemen kustom dari awal, namun tanpa disadari kehilangan banyak manfaat native dari form HTML, seperti validasi bawaan, pengiriman data otomatis, dan yang paling penting, aksesibilitas.
Masalahnya, saat kita membuat komponen UI yang terlihat seperti form input (misalnya, komponen rating bintang, multi-select tag, atau date picker yang canggih), browser tidak “tahu” bahwa komponen tersebut seharusnya menjadi bagian dari form. Ini berarti validasi required tidak berfungsi, nilai tidak terkirim saat form disubmit, dan pengguna dengan teknologi bantu (seperti screen reader) mungkin kesulitan menggunakannya.
💡 Kabar baiknya, Web Components hadir dengan solusi elegan: formAssociated API. Fitur ini memungkinkan Anda membangun elemen form kustom yang sepenuhnya berinteraksi dengan form HTML induk, layaknya elemen native. Dalam artikel ini, kita akan menyelami bagaimana Anda bisa menciptakan custom form elements yang tidak hanya cantik secara visual dan kaya fitur, tetapi juga robust, aksesibel, dan terintegrasi mulus dengan ekosistem form HTML.
Mari kita bebaskan form kita dari batasan default tanpa mengorbankan fungsionalitas inti!
2. Mengapa Membangun Custom Form Elements?
Sebelum kita masuk ke teknisnya, mari kita pahami mengapa kebutuhan akan custom form elements ini begitu mendesak di aplikasi web modern.
- Keterbatasan Estetika dan Fungsionalitas HTML Standar: Elemen form bawaan browser seringkali sulit di-styling secara konsisten di berbagai browser, dan opsi kustomisasinya terbatas. Bayangkan Anda ingin membuat komponen pemilihan tanggal yang interaktif dengan kalender mini, atau input rating bintang yang visualnya menarik. Mencapainya dengan
<input type="date">atau serangkaian radio button standar akan sangat menantang dan seringkali menghasilkan pengalaman pengguna yang kurang optimal. - Kompleksitas UI yang Unik: Banyak aplikasi membutuhkan input yang lebih kompleks dari sekadar teks atau angka. Misalnya, komponen untuk memilih tag dari daftar yang bisa dicari, input untuk mengunggah banyak file dengan progress bar, atau bahkan editor teks kaya (WYSIWYG). Membuat ini dari nol tanpa integrasi form yang tepat adalah resep untuk masalah.
- Konsistensi Desain Sistem: Dalam proyek berskala besar yang menggunakan Design System, penting untuk memiliki komponen UI yang konsisten, termasuk elemen form. Custom form elements memungkinkan Anda menerapkan brand guidelines dan interaksi yang seragam di seluruh aplikasi, tanpa terikat pada rendering default browser.
- User Experience (UX) yang Lebih Baik: Dengan custom elements, Anda bisa merancang interaksi yang lebih intuitif dan responsif. Misalnya, input yang memberikan feedback visual real-time saat validasi, atau input yang menyesuaikan diri dengan konteks pengguna.
❌ Tantangan utamanya adalah bagaimana memastikan komponen kustom ini tetap “berperilaku” seperti elemen form native, bukan sekadar div yang terlihat seperti input. Di sinilah formAssociated API berperan sebagai jembatan antara custom element Anda dan form HTML yang lebih besar.
3. Fondasi Custom Element untuk Form: formAssociated dan ElementInternals
Untuk membuat Web Component Anda menjadi “anggota resmi” dari sebuah form, ada dua kunci utama: properti statis formAssociated dan objek ElementInternals.
📌 static formAssociated = true;
Ini adalah properti statis yang Anda deklarasikan di dalam kelas Web Component Anda. Dengan menyetelnya ke true, Anda memberi tahu browser bahwa custom element ini ingin berpartisipasi dalam form. Browser kemudian akan memperlakukan custom element Anda sebagai “form-associated custom element”.
class MyCustomInput extends HTMLElement {
static formAssociated = true; // Ini kuncinya!
// ...
}
Analogi: Anggap saja static formAssociated = true; adalah seperti Anda mengajukan permohonan untuk menjadi warga negara di sebuah negara. Setelah permohonan disetujui, Anda mendapatkan hak dan kewajiban yang sama dengan warga negara lainnya.
📌 ElementInternals API
Setelah Web Component Anda diakui sebagai form-associated, Anda mendapatkan akses ke objek ElementInternals melalui metode this.attachInternals(). Objek ini adalah “jalur komunikasi rahasia” antara custom element Anda dan form induk. Melalui ElementInternals, Anda bisa:
setFormValue(value, state): Mengatur nilai yang akan dikirimkan bersama form. Ini adalah penggantiinput.value = '...'untuk custom elements. Anda bisa mengirimkan nilai sederhana (string) atau objek kompleks (FormData).setValidity(validity, validationMessage, anchor): Memberi tahu form tentang status validasi custom element Anda. Ini memungkinkan form untuk mengetahui apakah input Anda valid atau tidak, dan menampilkan pesan validasi jika diperlukan.form: Properti yang mengembalikan referensi ke form HTML induk.willValidate: Properti boolean yang menunjukkan apakah elemen akan divalidasi saat form disubmit.validationMessage: Pesan validasi saat ini.checkValidity()/reportValidity(): Metode yang bisa dipanggil untuk memicu validasi dan melaporkan hasilnya.
class MyCustomInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals(); // Mendapatkan jalur komunikasi
// ...
}
// ... di mana nilai berubah
_updateValue(newValue) {
this._value = newValue;
this.internals.setFormValue(newValue); // Mengirim nilai ke form
this._validate(); // Memanggil validasi kustom
}
_validate() {
// Logika validasi kustom
const isValid = /* ... cek apakah nilai valid ... */;
this.internals.setValidity(
{ customError: !isValid }, // Objek ValidityState
isValid ? '' : 'Input ini tidak valid!', // Pesan validasi
this // Elemen yang akan menampilkan pesan validasi
);
}
}
Dengan formAssociated dan ElementInternals, custom element Anda tidak lagi menjadi “orang asing” di dalam form, tetapi menjadi bagian integral yang bisa divalidasi dan nilainya disubmit seperti elemen native lainnya.
4. Langkah Demi Langkah: Membuat Custom Rating Input
Mari kita terapkan konsep ini dengan membuat komponen <rating-input> yang memungkinkan pengguna memilih rating bintang.
<!-- Penggunaan di HTML -->
<form onsubmit="event.preventDefault(); console.log(new FormData(event.target))">
<label for="my-rating">Beri Rating:</label>
<rating-input id="my-rating" name="product_rating" required min="1" max="5"></rating-input>
<p>Status Validasi: <span id="rating-status"></span></p>
<button type="submit">Submit Rating</button>
<button type="reset">Reset Form</button>
</form>
<script>
// Script untuk menampilkan status validasi (opsional)
const ratingInput = document.getElementById('my-rating');
const ratingStatus = document.getElementById('rating-status');
ratingInput.addEventListener('input', () => {
ratingStatus.textContent = ratingInput.checkValidity() ? 'Valid' : 'Invalid';
if (!ratingInput.checkValidity()) {
ratingStatus.textContent += ` (${ratingInput.validationMessage})`;
}
});
</script>
Sekarang, mari kita buat kelas RatingInput di JavaScript:
class RatingInput extends HTMLElement {
static formAssociated = true; // ✅ Penting: Memberi tahu browser ini adalah form-associated element
constructor() {
super();
this.internals = this.attachInternals(); // 🎯 Dapatkan ElementInternals
this._value = ''; // Nilai internal komponen
this._disabled = false;
this._readOnly = false;
this._required = false;
this._min = 0;
this._max = 5;
this.attachShadow({ mode: 'open' });
this.render();
}
static get observedAttributes() {
return ['value', 'disabled', 'readonly', 'required', 'min', 'max'];
}
attributeChangedCallback(name, oldValue, newValue) {
switch (name) {
case 'value':
this._value = newValue;
this.updateFormValue();
break;
case 'disabled':
this._disabled = this.hasAttribute('disabled');
this.toggleAttribute('aria-disabled', this._disabled);
this.updateStars();
break;
case 'readonly':
this._readOnly = this.hasAttribute('readonly');
this.updateStars();
break;
case 'required':
this._required = this.hasAttribute('required');
this.updateFormValue(); // Panggil update untuk trigger validasi
break;
case 'min':
this._min = parseInt(newValue) || 0;
this.updateFormValue();
break;
case 'max':
this._max = parseInt(newValue) || 5;
this.updateFormValue();
break;
}
}
connectedCallback() {
this.updateFormValue(); // Set nilai awal dan validasi
this.shadowRoot.addEventListener('click', this._handleClick.bind(this));
this.shadowRoot.addEventListener('keydown', this._handleKeydown.bind(this));
this.shadowRoot.addEventListener('focusin', this._handleFocusIn.bind(this));
this.shadowRoot.addEventListener('focusout', this._handleFocusOut.bind(this));
}
disconnectedCallback() {
this.shadowRoot.removeEventListener('click', this._handleClick.bind(this));
this.shadowRoot.removeEventListener('keydown', this._handleKeydown.bind(this));
this.shadowRoot.removeEventListener('focusin', this._handleFocusIn.bind(this));
this.shadowRoot.removeEventListener('focusout', this._handleFocusOut.bind(this));
}
// Metode form-associated callbacks
formResetCallback() {
this.value = ''; // Reset nilai saat form di-reset
}
formDisabledCallback(disabled) {
this.disabled = disabled; // Sinkronkan state disabled dari form
}
formStateRestoreCallback(state, mode) {
// Mengembalikan state jika browser melakukan restore (misal: back button)
this.value = state;
}
// Getter/Setter untuk 'value' agar sinkron dengan atribut
get value() {
return this._value;
}
set value(newValue) {
if (newValue === this._value) return;
this._value = newValue;
this.setAttribute('value', newValue);
this.updateFormValue();
this.updateStars();
this.dispatchEvent(new Event('input', { bubbles: true })); // Trigger event input
this.dispatchEvent(new Event('change', { bubbles: true })); // Trigger event change
}
get disabled() { return this._disabled; }
set disabled(val) {
this._disabled = Boolean(val);
this.toggleAttribute('disabled', this._disabled);
this.updateStars();
}
get readOnly() { return this._readOnly; }
set readOnly(val) {
this._readOnly = Boolean(val);
this.toggleAttribute('readonly', this._readOnly);
this.updateStars();
}
get required() { return this._required; }
set required(val) {
this._required = Boolean(val);
this.toggleAttribute('required', this._required);
this.updateFormValue();
}
get min() { return this._min; }
set min(val) {
this._min = parseInt(val) || 0;
this.setAttribute('min', this._min);
this.updateFormValue();
}
get max() { return this._max; }
set max(val) {
this._max = parseInt(val) || 5;
this.setAttribute('max', this._max);
this.updateFormValue();
}
// Metode internal untuk mengelola nilai form dan validasi
updateFormValue() {
const valueNum = parseInt(this._value);
const isValid = !this._required || (this._required && valueNum >= this._min && valueNum <= this._max);
this.internals.setFormValue(this._value); // 📌 Mengatur nilai yang akan disubmit
this.internals.setValidity(
{
valueMissing: this._required && !this._value,
rangeUnderflow: valueNum < this._min,
rangeOverflow: valueNum > this._max,
badInput: isNaN(valueNum) && this._value !== ''
},
this._required && !this._value ? 'Mohon isi rating ini.' :
valueNum < this._min ? `Rating minimal ${this._min}.` :
valueNum > this._max ? `Rating maksimal ${this._max}.` :
'Input tidak valid.', // Pesan validasi kustom
this.shadowRoot.querySelector('.stars') // Elemen yang akan menampilkan pesan validasi
);
// Update atribut aria-invalid
this.internals.ariaInvalid = !isValid;
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
font-size: 24px;
line-height: 1;
cursor: pointer;
--star-color: #ccc;
--star-selected-color: gold;
outline: none; /* Dikelola secara manual */
}
:host([disabled]) {
cursor: not-allowed;
opacity: 0.6;
}
:host([readonly]) {
cursor: default;
}
.stars {
display: flex;
gap: 2px;
}
.star {
color: var(--star-color);
transition: color 0.2s;
cursor: inherit;
}
.star.selected {
color: var(--star-selected-color);
}
.star:hover:not(.selected) {
color: var(--star-selected-color);
}
:host(:focus-within) .stars {
outline: 2px solid blue; /* Indikator fokus yang jelas */
outline-offset: 2px;
}
</style>
<div class="stars" role="radiogroup" aria-label="Rating">
${Array.from({ length: this._max }).map((_, i) => `
<span
class="star"
data-value="${i + 1}"
role="radio"
tabindex="${this._disabled || this._readOnly ? -1 : (i + 1 === parseInt(this._value) || (this._value === '' && i === 0) ? 0 : -1)}"
aria-checked="${i + 1 === parseInt(this._value) ? 'true' : 'false'}"
aria-label="${i + 1} dari ${this._max} bintang"
>★</span>
`).join('')}
</div>
`;
this.updateStars();
}
updateStars() {
const stars = this.shadowRoot.querySelectorAll('.star');
stars.forEach(star => {
const starValue = parseInt(star.dataset.value);
if (starValue <= parseInt(this._value)) {
star.classList.add('selected');
} else {
star.classList.remove('selected');
}
star.tabIndex = (this._disabled || this._readOnly) ? -1 : (starValue === parseInt(this._value) || (this._value === '' && starValue === 1) ? 0 : -1);
});
}
_handleClick(event) {
if (this._disabled || this._readOnly) return;
const star = event.target.closest('.star');
if (star) {
this.value = star.dataset.value;
this.focus(); // Pastikan fokus tetap di komponen setelah klik
}
}
_handleKeydown(event) {
if (this._disabled || this._readOnly) return;
let newValue = parseInt(this._value || '0');
switch (event.key) {
case 'ArrowLeft':
case 'ArrowDown':
newValue = Math.max(this._min, newValue - 1);
event.preventDefault();
break;
case 'ArrowRight':
case 'ArrowUp':
newValue = Math.min(this._max, newValue + 1);
event.preventDefault();
break;
case 'Home':
newValue = this._min;
event.preventDefault();
break;
case 'End':
newValue = this._max;
event.preventDefault();
break;
case 'Enter':
case ' ':
// Jika belum ada nilai, set ke min, jika sudah ada, tidak ada aksi default
if (this._value === '') {
newValue = this._min;
} else {
return;
}
event.preventDefault();
break;
default:
return;
}
this.value = String(newValue);
}
_handleFocusIn(event) {
// Pastikan ada bintang yang bisa di-tab
const focusedStar = this.shadowRoot.querySelector('.star[tabindex="0"]');
if (!focusedStar) {
const firstStar = this.shadowRoot.querySelector('.star');
if (firstStar) firstStar.tabIndex = 0;
}
}
_handleFocusOut(event) {
// Tidak perlu reset tabindex di focusOut, karena fokus bisa berpindah antar bintang
}
}
customElements.define('rating-input', RatingInput);
⚠️ Penting: Pastikan Anda mendaftarkan custom element Anda dengan customElements.define('rating-input', RatingInput);.
Dengan kode di atas, <rating-input> Anda sekarang:
- Bisa diletakkan di dalam
<form>. - Nilainya akan terkirim saat form disubmit.
- Akan divalidasi jika atribut
required,min, ataumaxdigunakan. - Bisa di-reset dengan
form.reset(). - Aksesibel menggunakan keyboard dan screen reader berkat atribut ARIA.
5. Validasi dan Aksesibilitas yang Optimal
Memastikan custom form element Anda bekerja dengan baik bukan hanya tentang mengirimkan nilai, tetapi juga tentang validasi dan aksesibilitas.
Validasi yang Terintegrasi
ElementInternals memungkinkan Anda mengontrol status validasi komponen secara penuh:
-
internals.setValidity(validityState, validationMessage, anchorElement): Ini adalah inti validasi.validityState: Objek yang mirip denganValidityStatenative (misalnya{ valueMissing: true },{ rangeUnderflow: true }). Anda bisa membuat custom state seperti{ customError: true }.validationMessage: String yang akan ditampilkan browser saatreportValidity()dipanggil jika elemen tidak valid.anchorElement: Elemen di Shadow DOM yang akan digunakan untuk menampilkan pesan validasi (misalnya, tooltip browser). Jika tidak ada, pesan akan muncul di elemen host.
-
form.checkValidity()danform.reportValidity(): Custom element Anda akan merespons metode ini layaknya input native. Jikarating-inputAnda tidak valid,form.checkValidity()akan mengembalikanfalsedanform.reportValidity()akan menampilkan pesan validasi. -
State Pseudo-Class CSS: Browser akan secara otomatis menerapkan pseudo-class seperti
:valid,:invalid,:required,:disabled,:read-onlypada host custom element Anda, memungkinkan Anda untuk styling berdasarkan status validasi.
Aksesibilitas (A11y) yang Tak Terlupakan
Membangun custom element yang aksesibel adalah prioritas. Beberapa tips:
- Gunakan
<label>HTML Standar: Selalu pasangkan custom element Anda dengan<label>HTML standar menggunakan atributfordanid. Ini adalah cara terbaik untuk memastikan screen reader mengumumkan label dengan benar.<label for="my-rating">Beri Rating:</label> <rating-input id="my-rating" name="product_rating"></rating-input> - Peran ARIA (Accessible Rich Internet Applications): Di dalam Shadow DOM, gunakan atribut
roledanaria-*yang sesuai. Untukrating-input,role="radiogroup"pada kontainer bintang danrole="radio"pada setiap bintangnya adalah pilihan yang tepat.aria-label: Berikan label deskriptif untuk setiap bintang.aria-checked: Tunjukkan bintang mana yang saat ini dipilih.aria-disabled: Tunjukkan jika komponen disabled.
- Navigasi Keyboard: Pastikan pengguna dapat berinteraksi dengan komponen menggunakan keyboard.
- Gunakan
tabindex="0"untuk membuat komponen dapat difokuskan, dantabindex="-1"untuk elemen yang tidak langsung bisa difokuskan. - Tangani event
keydown(misalnya,ArrowLeft,ArrowRight,Home,End) untuk mengubah nilai.
- Gunakan
- Indikator Fokus Visual: Pastikan ada indikator fokus yang jelas (misalnya,
outline) saat komponen difokuskan agar pengguna keyboard tahu di mana mereka berada. Gunakan:host(:focus-within)atau:focusdi Shadow DOM.
✅ Dengan perhatian pada validasi dan aksesibilitas, custom form element Anda akan menjadi bagian yang kuat dan inklusif dari aplikasi web Anda.
6. Tips dan Best Practices
- Minimalisme Library: Untuk Web Components, seringkali Anda tidak membutuhkan framework berat. Jika perlu, gunakan library ringan seperti Lit yang mempermudah boilerplate dan reactive rendering.
- Atribut
name: Pastikan custom element Anda mendukung atributnamekarena ini adalah cara browser mengidentifikasi data form saat disubmit. formResetCallback(): Implementasikan metode ini untuk mereset komponen Anda ke nilai awal saat form induk di-reset. Ini sangat penting untuk pengalaman pengguna yang konsisten.formDisabledCallback(): Pastikan komponen Anda merespons atributdisabledpada form induk.- Fallback Content: Untuk browser yang sangat lama yang mungkin tidak mendukung Web Components, Anda bisa menyediakan fallback content di antara tag custom element Anda.
- Testing Komponen:
- Unit Testing: Uji logika internal, seperti bagaimana nilai berubah atau validasi dipicu.
- Integration Testing: