iT邦幫忙

2025 iThome 鐵人賽

DAY 16
3
Modern Web

原生元件養成計畫:Web Component系列 第 16

Day 16: Web Component 應用-建立基本表單元件

  • 分享至 

  • xImage
  •  

昨天我們已經讓元件能夠與表單產生關聯:

  • 透過 static formAssociated = true 告訴瀏覽器這是一個表單元件。
  • 透過 this._internals = this.attachInternals() 建立 internals 物件來跟表單互動。
  • 透過 this._internals.setFormValue() 把值寫入表單,並且可以用 formData.get() 拿到內容。

到這裡,元件已經能參與表單的提交流程了 (*´∀)~♥ !

但我們如果仔細想想,原生的 <input> 並不是這麼簡單,一個小小的欄位就包含了許多的方法、屬性。

看看我們日常使用的 <input>,這個元件不僅僅只是一個文字框,還有很多的方法與功能:

  • 可以觸發驗證能力,像是 必填 required、最小值 min ...。
  • 可以透過 form API 操作,例如 .focus()、.checkValidity()。
  • 可以透過表單生命週期控制元件狀態,例如 reset 或 disable。

但是!自訂元件沒有這些內建功能,所以我們要自己加入。
接下來我們就透過實作一個 輸入框 ,一步一步學習建立自訂表單元件吧!


今天我們也把之前學過的概念也帶入:

  • attribute(屬性)
  • getter / setter(取值/設值)
  • customEvent(狀態通知)

來說說自訂表單元件的


  1. 元件中的
    • 在原生元件中,使用者在文字框裡輸入什麼,value 就是什麼。
    • 自訂元件中,我們必須自己定義 getter、setter 來讓外部可以取得欄位的值,並且透過 this._internals.setFormValue(),讓瀏覽器知道值已經改變。
  2. 元件中的預設值
    • 在原生元件中,通常是透過 value='值' 在一開始就給予預設值。
    • 在自訂元件中,我們必須在元件初始化時讀取 HTML 的 value 屬性,並且保存成 _defaultValue
  3. 元件中的 placeholder
    • 在原生元件中,通常是透過 placeholder='值' 在一開始就給予 placeholder。
    • 在自訂元件中,我們必須在元件初始化時讀取 HTML 的 placeholder 屬性,並且保存成 _placeholder
  4. 元件中的 name
    • 不管是原生元件或是自訂元件,只要有 name 屬性,瀏覽器就會自動將對應的加入 FormData。如果沒有寫 name 的話,就不會被加入。

完善自訂 Input

其實在上一篇文章已經有先偷跑了,我們呼叫了 this._internals.setFormValue() 給予元件值。現在我們要加入預設值以及 getter、setter,讓外部也可以取得欄位的值。
先回憶一下前面 attribute 的那篇文章,在裡面,我們有提到一個方法 getAttribute('屬性'),在當時有提到,使用這個屬性是一次性的,無法監聽屬性的變化。
但是當要設定 預設值 或是 placeholder 的時候,這個方法就派上用場了!

我們直接看程式碼吧!

input.js

class CustomInput extends HTMLElement {
  // 宣告與表單產生關聯
  static formAssociated = true;

  // 監聽的屬性
  static get observedAttributes() {
    return ['value', 'disabled'];
  }

  constructor() {
    super();
    this._internals = this.attachInternals();
    const shadowRoot = this.attachShadow({ mode: 'open' });
    const cloneNode = this.render().cloneNode(true);
    shadowRoot.appendChild(cloneNode);

    this._input = this.shadowRoot.querySelector('.custom-input');
  }

  connectedCallback() {
    // 使用 getAttribute 取得預設值
    this._defaultValue = this.getAttribute('value');
    this._placeholder = this.getAttribute('placeholder') || '請輸入文字...';

    // 初始化 input value
    this.initInputValue();

    // 加入 input 事件
    this.handleValueChange();
  }

  // 提供外部可取值/設值
  get value() {
    return this._value;
  }

  set value(value) {
    this._value = value.trim();
    this._input.textContent = this._value;
    this._internals.setFormValue(this._value);
  }

  render() {
    const template = document.createElement('template');
    template.innerHTML = `
      <style>
        div {
           border: 1px solid #999999;
           border-radius: 4px;
           padding: 2px 4px;
        }
      </style>
      <!--  使用 contenteditable 來做一個假的 input   -->
      <div class="custom-input" contenteditable="true"></div>
    `

    return template.content;
  }

  // 監聽 value, disabled 屬性變更
  attributeChangedCallback(name, oldValue, newValue) {
    if (oldValue === newValue) {
      return;
    }

    if (name === 'value') {
      this.value = newValue; // 透過 setter 同步
    }

    if (name === 'disabled') {
      this._input.setAttribute('contenteditable', newValue === null ? 'true' : 'false');
    }
  }


  // 初始化欄位的值
  initInputValue() {
    if (this._defaultValue) {
      this._value = this._defaultValue;
      this._input.innerText = this._value;
      this._internals.setFormValue(this._value); // 記得呼叫,不然無法寫入 formData
    } else {
      this._input.innerText = this._placeholder;
      this._input.classList.add('placeholder');
    }
  }

  handleValueChange() {
    // 因為我們有加入 `attachInternals` 所以可以呼叫表單元件的方法
    this._input.addEventListener('input', e => {
      // 利用表單方法 `setFormValue` 寫入欄位的值,將值同步到 form internals
      const value = e.target.innerText.trim() || '';
      this._value = value;
      this._internals.setFormValue(value);

      // 發送事件給外部
      this.dispatchEvent(new CustomEvent('value-changed', {
        detail: { value: this._value },
        bubbles: true,
        composed: true
      }));
    });

    // 當 focus 時,清空 placeholder
    this._input.addEventListener('focus', () => {
      if (this._input.classList.contains('placeholder')) {
        this._input.innerText = '';
        this._input.classList.remove('placeholder');
      }
    });

    // 當 blur 時,如果沒有輸入,回填 placeholder
    this._input.addEventListener('blur', () => {
      if (!this._value) {
        this._input.innerText = this._placeholder;
        this._input.classList.add('placeholder');
      }
    });
  }
}

customElements.define('custom-input', CustomInput);

外部使用

index.html

<body>
    <form>
      <custom-input name="content" value="defaultValue"></custom-input>
      <button type="submit">Submit</button>
    </form>

    <script src="input.js"></script>
    <script>
      const form = document.querySelector('form');
      const customInput = document.querySelector('custom-input');

      customInput.addEventListener('value-changed', e => {
        console.log('事件取得的值:', e.detail.value);
      });

      form.addEventListener('submit', e => {
        e.preventDefault();
        const formData = new FormData(form);
        const valueFromFormData = formData.get('content');
        const valueFromGetter = customInput.value;

        console.log(valueFromGetter, valueFromFormData);
      })
    </script>
  </body>

完整程式碼請看這:https://codepen.io/unlinun/pen/MYKazeK


上一篇
Day 15: Web Component 的 Form-associated
下一篇
Day 17: Web Component 應用-表單元件的驗證方法
系列文
原生元件養成計畫:Web Component17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言