WEB-COMPONENTS HTML-FORMS ACCESSIBILITY CUSTOM-ELEMENTS FRONTEND WEB-DEVELOPMENT UI-UX BEST-PRACTICES BROWSER-API

Membangun Custom Form Elements yang Robust dan Aksesibel dengan Web Components

⏱️ 19 menit baca
👨‍💻

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.

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

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:

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:

Aksesibilitas (A11y) yang Tak Terlupakan

Membangun custom element yang aksesibel adalah prioritas. Beberapa tips:

✅ 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