WEB-COMPONENTS HTML-FORMS FORM-VALIDATION ACCESSIBILITY CUSTOM-ELEMENTS WEB-STANDARDS FRONTEND-DEVELOPMENT UI-UX JAVASCRIPT BROWSER-API DESIGN-SYSTEM DEVELOPER-EXPERIENCE

ElementInternals: Jurus Rahasia Membuat Web Component Anda Jadi Elemen Form Sejati

⏱️ 31 menit baca
👨‍💻

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:

  1. Tidak Ikut di-Submit: Ketika Anda menekan tombol submit pada <form>, browser akan mengumpulkan name dan value dari semua elemen form native (<input>, <textarea>, <select>) di dalamnya. Web Component kustom Anda, jika tidak diatur secara khusus, akan diabaikan. ❌ Anda harus menambahkan input tersembunyi dan menyinkronkan nilainya secara manual.
  2. Tidak Bisa Divalidasi: Validasi HTML5 seperti required, min, max, pattern tidak akan bekerja pada Web Component Anda. Anda juga tidak bisa menggunakan element.checkValidity() atau element.reportValidity() secara native. ❌ Anda harus mengimplementasikan logika validasi kustom dan menampilkan pesan error secara manual.
  3. Tidak Bisa Direset: Ketika form di-reset, Web Component Anda tidak akan kembali ke nilai awalnya. ❌ Anda harus menambahkan logika reset kustom.
  4. 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:

📌 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:

Setelah memanggil setValidity(), browser akan:

E. Lifecycle Callback untuk Form-Associated Elements

Ada beberapa callback khusus yang bisa Anda implementasikan di Web Component Anda untuk bereaksi terhadap peristiwa form:

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}">&#9733;</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:

6. Tips dan Best Practices

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!

🔗