12k
All articles

Web Componentsで再利用可能なフォームコントロールを構築する

Web ComponentsとElementInternalsで再利用可能なフォームコントロールを作成。送信、検証、ラベル、リセット、復元をネイティブ対応にします。

OpenReplay Team
OpenReplay Team
Web Componentsで再利用可能なフォームコントロールを構築する

Shadow DOM内に<input>をラップするカスタム要素は、親の<form>からは不可視です。その値はFormDataに含まれず、ネイティブバリデーションはスキップされ、リセットしても変化しません。修正方法は2行だけです。コンストラクタにstatic formAssociated = truethis.attachInternals()を追加するだけで、ElementInternals APIが利用可能になります。本記事では、プラットフォームのネイティブAPIのみを使用して、ネイティブinputと同様に送信・バリデーション・ラベリング・リセット・状態復元が可能な完全な<text-field>コントロールを一から構築します。

2023年3月のSafari 16.4のリリースにより、Web ComponentsはElementInternals APIを使用してフォーム要素と連携する能力において重要なマイルストーンに達しました。それ以前は、Shadow DOM内に配置されたinput要素はフォームから検出できず、送信時のバリデーションが機能せず、データがFormDataオブジェクトに含まれませんでした。このブラウザサポートの問題は現在解決済みであるため、本記事では「まだ使えるのか」という懸念には触れず、従来のチュートリアルでは網羅されていなかった完全な実装レシピに焦点を当てます。

重要なポイント

  • 任意の自律型カスタム要素をフォームコントロールに変えるには2行で十分です。static formAssociated = trueで意図を宣言し、this.attachInternals()で要素を親フォームに接続するElementInternalsオブジェクトを取得します。
  • 2025年9月時点で、フォーム関連カスタム要素はBaseline Widely Availableとなっており、Chrome 77以降、Edge 79以降、Firefox 98以降、Safari 16.4以降でサポートされています。「まだサポートされているか」という懸念はもはや不要です。
  • コントロールの値がFormDataに含まれるのは、internals.setFormValue(value)を呼び出したときのみです。nullを渡すと、その要素は送信対象から完全に除外されます。
  • setValidity()は送信をブロックし、:invalidを適用しますが、アクセシブルなバリデーションには、aria-describedbyで紐付けられた視覚的なエラーメッセージも必要です。これがないと、フォームがサイレントに送信を拒否します。
  • 仕様で定義されている2つの状態復元モードのうち、実際のブラウザで確実に発火するのは"restore"のみです。"autocomplete"は仕様に定義されていますが、信頼できる実装はされていません。

Shadow DOMがネイティブフォーム参加を妨げる理由

ネイティブの<input>がフォームに参加できるのは、同一のDOMツリー内で<form>の子孫として存在する場合のみです。Shadow DOMは独立したツリーであるため、カスタム要素のshadow root内にレンダリングされたinputは、フォームから構造的に不可視となります。ブラウザはそれをform.elementsに追加せず、FormDataに値を収集せず、制約バリデーションも実行しません。これはカプセル化のトレードオフが設計通りに機能している結果であり、フォーム関連カスタム要素(FACE)が解決するために存在する問題です。

プラットフォームの解答はElementInternalsです。HTMLElement.attachInternals()メソッドはElementInternalsオブジェクトを返します。このメソッドにより、カスタム要素はHTMLフォームに参加できるようになり、このインターフェースは標準のHTMLフォーム要素と同様の方法でこれらの要素を操作するためのユーティリティを提供しながら、Accessibility Object Modelも要素に公開します。FACEはもはや先端的な機能ではありません。フォーム関連カスタム要素は2025年9月27日よりBaseline Widely Availableとなっており、Chrome 77(2019年)、Edge 79(2020年)、Firefox 98(2022年)、Safari 16.4(2023年3月27日リリース)でサポートされています。コミュニティによるelement-internals-polyfillはレガシーエンジン向けに引き続き存在しますが、エバーグリーンブラウザ全体でBaselineサポートが得られている現在、ほとんどのプロジェクトでは不要です。

重要な制約が1つあります。static formAssociatedプロパティにtrue値を追加すると、自律型カスタム要素がフォーム関連カスタム要素になります。要素はHTMLElementを直接継承する必要があり、組み込みサブクラスを継承することはできません。また、要素がカスタム要素でない場合、attachInternals()NotSupportedErrorをスローします。

最小限のフォーム関連カスタム要素

最小限の実用的なコントロールは、formAssociatedを宣言し、internalsを取得し、setterを通じてフォームに値をプッシュします。他のすべての機能はこのスケルトンの上に構築されます。

class TextField extends HTMLElement {
  static formAssociated = true;
  static observedAttributes = ['value', 'required'];

  #internals;
  #value = '';

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  get value() { return this.#value; }
  set value(v) {
    this.#value = v ?? '';
    this.#internals.setFormValue(this.#value);
  }

  get form() { return this.#internals.form; }
  get name() { return this.getAttribute('name'); }
  get type() { return this.localName; }
}

customElements.define('text-field', TextField);

ここで重要な役割を果たすのがsetFormValue()です。コントロールの値がFormDataに含まれるのは、internals.setFormValue(value)を呼び出したときのみです。nullを渡すと、その要素は送信対象から完全に除外されます。setterパターンにより、スクリプト、属性、またはユーザー入力による.valueへの代入が、送信値を常に同期させることが保証されます。関連付けられると、要素は組み込み要素と同様に動作します。フォームのelementsコレクションには、フォームに関連付けられたすべての<button><fieldset><input><object><output><select><textarea>、およびフォーム関連カスタム要素が含まれます。

送信に任意のデータを注入するだけでよく、再利用可能なコントロールを構築する必要がない場合は、より軽量なformdataイベントで十分です。フォームでそのイベントをリッスンし、event.formData.append(...)を呼び出します。バリデーション、ラベリング、リセット、状態復元が必要なコントロールを構築する場合はElementInternalsを使用してください。

内部inputのレンダリングと同期

コントロールには、値がpublicなsetterを通じて外部に流れる内部<input>が必要です。また、<text-field>上のリスナーがネイティブフィールドと同様に動作するよう、input/changeイベントをホストから再ディスパッチする必要があります。shadow rootは一度しかアタッチできないため、切断後の再接続でattachShadow()が2回呼ばれてエラーが発生しないよう、セットアップをガードします。

connectedCallback() {
  if (!this.shadowRoot) {
    const root = this.attachShadow({ mode: 'open', delegatesFocus: true });
    root.innerHTML = `<input part="input" />`;
    this.input = root.querySelector('input');

    this.input.addEventListener('input', () => {
      this.value = this.input.value;        // setFormValueを再同期
      this.#validate();                      // リアルタイムバリデーション
    });
    this.input.addEventListener('change', (e) => {
      this.dispatchEvent(new e.constructor(e.type, e));
    });
  }

  this.value = this.getAttribute('value') ?? '';
  this.#validate();
}

ネイティブのchangeイベントはshadow境界を越えないため、new e.constructor(e.type, e)でイベントをクローンしてホストから再ディスパッチすることで、親コードがリッスンできるlight DOM上でイベントを再発行します。inputのinputリスナーからthis.valueを駆動することで、キーストロークごとに送信されるFormDataの値が最新の状態に保たれます。

ラベリング、フォーカス委譲、tabindex

ネイティブの<label for="...">は、クリック時にコントロールにフォーカスを移動させる必要があります。フォーム関連カスタム要素では、この連携はshadow rootのdelegatesFocus: trueによって実現されます。delegatesFocus: trueを設定することで、<label for>のクリックがネイティブフィールドと同様に内部inputにフォーカスを移動させます。これがないと、ラベルをクリックしても何も起こらず、ホストにフォーカスしても内部inputに届きません。FACEはラベル付け可能な要素であるため、ネイティブinputと全く同じマークアップで記述できます。

<form>
  <label for="email">Email</label>
  <text-field id="email" name="email" required></text-field>
</form>

カスタムコントロールのスクリーンリーダーによるラベル関連付けは、ブラウザと支援技術の組み合わせによって歴史的に一貫性がなかったため、対象の支援技術でテストし、幅広い環境をサポートする場合はshadow内の<label>フォールバックを用意してください。delegatesFocusを設定すれば、通常はtabindexを手動で管理する必要はありません。フォーカスは内部inputに委譲され、内部inputはすでにタブオーダーに含まれています。

setValidity()とValidityStateFlagsによるバリデーション

ElementInternals.setValidity()はネイティブの制約バリデーションシステムを反映しています。ValidityStateFlagsオブジェクト、オプションの人間が読めるメッセージ、オプションのアンカー要素を渡すと、ブラウザは:invalid疑似クラスを適用し、送信をブロックします。この設定により、フラグが設定されるたびに:invalid疑似クラスが自動的に要素に適用されます。空のオブジェクト{}を渡すとすべてのエラーがクリアされます。

フラグはネイティブのバリデーション理由と1対1で対応しています。

フラグネイティブのトリガーに相当するもの
valueMissing値がない状態でのrequired
typeMismatchtypeに対して不正な値(例:email
patternMismatchpatternに一致しない値
tooLong / tooShortmaxlength超過 / minlength未満
rangeUnderflow / rangeOverflowmin未満 / max超過
stepMismatchstepに揃っていない値
badInput解析不能な入力
customErrorカスタムメッセージによる作者定義エラー

リアルタイムフィードバックのためにinputイベントごとにバリデーションを実行し、標準メソッド(checkValidity()reportValidity()validityvalidationMessage)を公開することで、呼び出し元がコントロールをネイティブフィールドと同様に扱えるようにします。

#validate() {
  const flags = {};
  let message = '';
  if (this.hasAttribute('required') && !this.#value) {
    flags.valueMissing = true;
    message = 'This field is required.';
  }
  this.#internals.setValidity(flags, message, this.input);
  this.#renderError(message);
}

checkValidity() { return this.#internals.checkValidity(); }
reportValidity() { return this.#internals.reportValidity(); }
get validity() { return this.#internals.validity; }
get validationMessage() { return this.#internals.validationMessage; }

setValidity()はアクセシブルなバリデーションの半分に過ぎません。ブラウザは送信をブロックして:invalidを適用しますが、aria-describedbyで紐付けられた視覚的なエラーメッセージを表示しなければ、ユーザーはフォームがサイレントに送信を拒否するだけで理由がわかりません。#renderErrorのステップはアクセシビリティのセクションで説明します。

4つのフォームライフサイクルコールバック

フォーム関連カスタム要素は、標準のカスタム要素ライフサイクルに加えて、4つの追加リアクションコールバックを持ちます。これらは、ほとんどのチュートリアルが省略している、完全性を定義する重要な部分です。formAssociatedCallback(form)は関連するフォームが変わったときに呼ばれます。formResetCallback()はフォームがリセットされ、ユーザーが設定した値をクリアすべきときに呼ばれます。formDisabledCallback(isDisabled)は無効状態が変わったときに呼ばれます。formStateRestoreCallback(state, reason)はブラウザが要素の状態を復元するとき(reason: "restore")またはオートフィルを実行するとき(reason: "autocomplete")に呼ばれます。

コールバック発火タイミング引数典型的な処理内容
formAssociatedCallback要素がフォームオーナーを取得/失ったとき新しいform(またはnullフォーム依存の状態を設定する
formDisabledCallbackホストまたは祖先のfieldsetのdisabled状態が切り替わったときboolean内部inputに反映する
formResetCallbackフォームがリセットされたときなしデフォルト値に戻す
formStateRestoreCallback前後ナビゲーション、リロード、オートフィル時復元されたstate、reason保存された状態から再水和する
formDisabledCallback(disabled) {
  this.input.disabled = disabled;
}

formResetCallback() {
  this.value = this.getAttribute('value') ?? '';
  this.#internals.setValidity({});
  this.#renderError('');
}

formStateRestoreCallback(state, reason) {
  if (reason === 'restore') this.value = state;
}

disabled属性とreadonly属性には仕様上の意味があります。disabled属性はフォーム関連カスタム要素を非インタラクティブにし、その送信値が送信されるのを防ぎます。一方、readonly属性は要素が制約バリデーションの対象外であることを指定します。

仕様に準拠した状態復元

setFormValue()はオプションの第2引数を受け取ります。setFormValue(value, state)stateはサーバーに送信されないクライアント専用の表現であり、送信値だけでは復元できない複雑な内部状態を保存するのに役立ちます。仕様では、restoreコールバックが受け取る内容が明確に定義されています。ユーザーエージェントがユーザーの代わりに、またはナビゲーションの一部としてフォーム関連カスタム要素の値を更新する場合、formStateRestoreCallbackが呼ばれ、新しいstateと理由を示す文字列("autocomplete"または"restore")が渡されます。以前の仕様の不整合により実際の差異が生じていました。Chromiumはコールバックをvalueとともにコールしていたのに対し、WebKitはstateとともにコールしていましたが、stateを使用する方向で解決されました。古いChromium/Edgeビルドをサポートする場合は、引数を防御的に処理してください。

現時点で信頼できるのは"restore"のみです。FirefoxはformStateRestoreCallbackのサポートを実装しましたが、カスタム要素に対する'autocomplete'はサポートされていないと注記しています。エンジン間で信頼できるモードとして、"restore"(前後ナビゲーションとリロード)のみを前提として扱ってください。

アクセシビリティの正しい実装

バリデーション状態を設定することと、それをユーザーに伝えることは別物です。エラーを知覚可能にするには、shadow root内にメッセージをレンダリングし、aria-describedbyで内部inputに紐付け、支援技術がアナウンスできるようにライブリージョンとしてマークします。

#renderError(message) {
  let err = this.shadowRoot.querySelector('.error');
  if (!err) {
    err = document.createElement('div');
    err.className = 'error';
    err.id = 'err';
    err.setAttribute('role', 'alert');
    err.setAttribute('aria-live', 'assertive');
    this.shadowRoot.append(err);
    this.input.setAttribute('aria-describedby', 'err');
  }
  err.textContent = message;
}

inputとエラーメッセージが同じshadow root内に存在するため、aria-describedbyはそのツリー内で正しく解決されます。ホスト要素自身のARIAセマンティクスはElementInternalsプロパティを通じて設定できます。ElementInternals.rolearia*リフレクションプロパティを含め、このサーフェスは現在広くサポートされています。

よくある本番環境での失敗パターンは、見た目は完成しているのにサイレントに失敗するコントロールです。これらはコンソールエラーを出力しないため、セッションリプレイが表面化する典型的なバグです。リプレイでは次のような状況が明らかになります。valueMissingを報告しているのに視覚的なメッセージが表示されず、ユーザーが繰り返し送信ボタンをクリックしているケース。delegatesFocusが設定されていないためラベルのクリックが何も起こらないケース。setterがsetFormValueを呼び出していないためフィールドが欠落した送信ペイロード。レビュー時に注意すべきもう一つの落とし穴として、ネイティブのrequired inputをカスタム要素にスロットすると、light DOMのコントロールがホストと並行してフォームに参加し、二重参加が発生します。ホスト自体がフォーム関連の場合、スロットにネイティブコントロールを配置しないようにしてください。

フレームワーク内での使用

フォーム関連カスタム要素のフレームワーク統合は、ここで示したネイティブのElementInternals APIをそのままラップしています。Stencilはv4.5.0から@AttachInternalsデコレーターによるファーストクラスサポートを提供しており、そのformStateRestoreCallbackも復元理由を示す第2引数mode"restore"または"autocomplete")を受け取ります。LitはFACEを組み込んでいないため、小さなFormAssociatedミックスインを自分で追加するかコミュニティのものをインポートする必要がありますが、そのミックスインはここで示した同じformAssociatedフラグ、attachInternals()setFormValue()、ライフサイクルコールバックをラップしています。プラットフォームAPIを直接学ぶことで、これらのラッパーのいずれも読み解き、デバッグし、置き換えることができます。

これらの要素を組み合わせることで、valueのsetter、内部inputの同期、delegatesFocusrole="alert"メッセージと紐付けたsetValidity、そして4つのライフサイクルコールバックを備えた、ネイティブinputと区別がつかない形で送信・バリデーション・リセット・状態復元が可能な単一の<text-field>が完成します。<form>に配置し、<label for>を紐付けたら、次の具体的なステップはラウンドトリップの検証です。フォームを送信し、new FormData(form)から値を読み取り、空のrequiredフィールドで制約バリデーションが送信をブロックすることを確認してください。

よくある質問

フォームに値を追加する際のformdataイベントとElementInternalsの違いは何ですか?

formdataイベントは送信に任意のデータを注入します。フォームでそのイベントをリッスンし、event.formData.append()を呼び出してフィールドを追加します。ElementInternalsは、setFormValueとsetValidityを通じて制約バリデーション、ラベリング、リセット、状態復元を含むフォームへの完全な参加が可能な再利用可能なコントロールを構築します。素早い一回限りのデータ注入にはformdataイベントを使用し、ネイティブinputのように動作するコントロールが必要な場合はElementInternalsを使用してください。

attachInternals()がNotSupportedErrorをスローするのはなぜですか?

attachInternals()は、要素が自律型カスタム要素でない場合にNotSupportedErrorをスローします。フォーム関連カスタム要素はHTMLInputElementのような組み込みサブクラスではなく、HTMLElementを直接継承する必要があり、クラスはインスタンス化前にcustomElements.defineで登録されている必要があります。同じ要素でattachInternals()を複数回呼び出してもスローされます。エラーを避けるには、HTMLElementを継承するクラスのコンストラクタで一度だけ呼び出してください。

delegatesFocusはフォーム関連カスタム要素の手動tabindex管理に影響しますか?

shadow rootにdelegatesFocusをtrueに設定すると、通常はtabindexを手動で管理する必要はありません。フォーカスはshadow root内の最初のフォーカス可能な要素(すでにタブオーダーに含まれている内部input)に委譲されるため、ホスト要素は自動的にフォーカスを転送します。label-forのクリックとキーボードのタブ操作の両方が内部inputに届きます。ホストに手動でtabindexを追加すると重複したタブストップが生まれる可能性があるため、delegatesFocusがルーティングを処理している場合は省略してください。

setFormValueにnullを渡すとどうなりますか?

internals.setFormValue(null)を呼び出すと、その要素はフォーム送信から完全に除外されます。フォームが生成するFormDataオブジェクトから、その名前と値が除外されます。これは空文字列を渡した場合(フィールドが空の値で送信される)とは異なります。無効化されたフィールドや読み取り専用フィールドなど、コントロールを意図的に除外したい場合はnullを使用し、コントロールが送信に貢献すべき場合は実際の値の文字列またはFormDataを渡してください。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.