iT邦幫忙

2025 iThome 鐵人賽

DAY 17
2
Modern Web

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

Day 17: Web Component 應用-表單元件的驗證方法

  • 分享至 

  • xImage
  •  

在上一篇,我們已經完成了自訂表單元件 的處理。

  1. attribute 與 observedAttributes:讓外部設定可以即時反應到元件。
  2. getter / setter:讓元件內外同步欄位值。
  3. customEvent:將輸入變化回傳給外部。
  4. setFormValue:寫入表單的值。

接下來我們就會進入到表單元件的 驗證機制

想想你平時在填表單時,可能會因為欄位沒有填寫而跳出警告,告訴你這個欄位為 必填長度必須小於 10 個字 ...。
而在原生文字輸入框中,你可以透過輸入 <input type="text" required> 告訴瀏覽器,這是一個必填欄位,當你沒有填寫時,會顯示錯誤訊息。

而自訂元件沒有這些功能,我們該如何替自訂元件加上驗證機制 呢?

這就需要 ElementInternals 的驗證 API。

在元件內部加入表單驗證


setValidity

當今天有一個欄位為必填時,需要判斷欄位有沒有值,如果沒有值的話,要知道這個欄位是有錯誤的,這時候就需要在自訂表單元件中加入驗證狀態

我們需要使用到以下方法:

this._internals.setValidity(flags, message, anchor);
this._internals.setValidity(
  { customError: true },  // 錯誤型別
  "此欄位為必填",           // 錯誤訊息
  this._input             // 要顯示錯誤的元素
);
  • flags:一個物件,用來代表錯誤的類型,第一個參數只能接受標準的ValidityStateFlags,不能自己隨便創立新的 flag。
    • 如果想要是自訂的錯誤就使用 { customError: true },然後可以再依據第二個參數 message 做細分。
    • 參數可以詳見參考資料:https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/setValidity
  • message:錯誤訊息
  • anchor:哪個元素要顯示錯誤提示(代表錯誤是在這個元件)

替元件加入驗證狀態

我們先定義一個驗證器方法 inputValidator
並在前一篇文章中的 handleValueChange() 方法,調用 inputValidator() 方法,當欄位值有變動時同時觸發驗證器。

先替這個元件加入兩個驗證狀態:

  • required:此欄位為必填。
this._internals.setValidity(
  { valueMissing: true }, // 會回傳給 form 的物件(代表錯誤類型)
  "此欄位為必填!", // 錯誤訊息
  this._input // 顯示錯誤的元素
);
  • max-length:欄位內容長度必須小於設定的數字。
this._internals.setValidity(
  { tooLong: true },
  "此欄位字數超過限制!",
  this._input
);

試試看,在昨天的架構中加入驗證狀態

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

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

  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');
    }

    if (name === 'required') {
      // 只要屬性存在就設為 true,不存在就是 false
      this.required = newValue !== null;

      if (newValue !== oldValue) {
        this.inputValidator(); // 加上時馬上驗證
      }
    }

    // 監聽外部長度限制
    if (name === 'max-length') {
      // 試試先轉成數字(要確保填入的是數字)
      const parsed = parseInt(newValue, 10);
      this.maxLength = !isNaN(parsed) && parsed > 0 ? parsed : 0;

      if (newValue !== oldValue) {
        this.inputValidator(); // 加上時馬上驗證
      }
    }
  }


  // 初始化欄位的值
  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.inputValidator();

      // 發送事件給外部
      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');
      }
    });
  }

  // 對外公開 checkValidity()
  checkValidity() {
    return this._internals.checkValidity();
  }

  // 對外公開 reportValidity()
  reportValidity() {
    return this._internals.reportValidity();
  }

  // 驗證方法
  inputValidator() {
    // required 必填檢查
    if (this.required && !this.value) {
      this._internals.setValidity(
        { valueMissing: true },
        '此欄位為必填!',
        this._input
      );
      console.log('此欄位為必填!');
      return false;
    }

    // max-length 最大長度檢查
    if (this.maxLength && this.value.length > this.maxLength) {
      this._internals.setValidity(
        { tooLong: true },
        `此欄位不能超過 ${this.maxLength} 個字!`,
        this._input
      );
      console.log(`此欄位不能超過 ${this.maxLength} 個字!`);
      return false;
    }

    // 通過驗證
    this._internals.setValidity({});
    return true;
  }
}

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

當我們完成以上的元件後:這個驗證邏輯只會更新元件的驗證狀態,不會主動觸發錯誤訊息,所以我們需要在外面檢查驗證結果。

要驗證元件中的內容,常常會需要用到以下的兩個方法:

  • checkValidity():回傳 true 或 false,代表此欄位值目前是否合法,但是不會顯示錯誤訊息。
  • reportValidity():跟 checkValidity() 一樣會檢查欄位的值,但如果驗證不通過,會直接在畫面上顯示錯誤提示(如果是用整個表單觸發的話,會顯示第一個不合法的欄位的錯誤訊息)

在外部檢查欄位的驗證結果前,我們要將這些方法從元件內部公開,這樣外部才可以獨立拿到欄位元件的驗證狀態。

// 對外公開 checkValidity()
checkValidity() {
  return this._internals.checkValidity();
}

// 對外公開 reportValidity()
reportValidity() {
  return this._internals.reportValidity();
}

接下來我們就看看程式碼吧!

<body>
  <form style="display: flex; flex-direction: column; gap: 8px">
    <!--  這是我們的自訂表單元件    -->
    <custom-input 
      name="content" 
      value="defaultValue" 
      required 
      max-length="5"
    >
    </custom-input>
    <!--  建立一個原生的 Input,看看他的驗證吧!    -->
    <input type="text" required>
    <button type="submit">Submit</button>
  </form>

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

    // 在 value change 的同時檢查自訂元件的驗證狀態
    customInput.addEventListener('value-changed', e => {
      console.log(customInput.checkValidity(), 'value changed');
      customInput.reportValidity();
    });

    form.addEventListener('submit', e => {
      e.preventDefault();
      // 當按下 submit 後,要觸發整個表單的驗證
      form.checkValidity();
      form.reportValidity(); // 顯示第一個不合法的欄位的錯誤訊息
    })
  </script>
</body>

看看結果:
validity

今天我們完成了自訂表單元件的驗證機制,透過以上的驗證設計,我們的自訂表單元件已經不只可以傳遞值給表單,還能像原生 input 一樣進行表單驗證了!

Web Component 的程式碼真的很多 ヾ(;゚;Д;゚;)ノ゙。
原始碼看這裡:https://codepen.io/unlinun/pen/KwVVmgB


上一篇
Day 16: Web Component 應用-建立基本表單元件
系列文
原生元件養成計畫:Web Component17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言