Buttonカスタム要素でFormのImplicit Submissionを実現する

Form要素には、例えばInput要素にフォーカスが当たっている状態でEnterキーを押下すると、Form要素内のDOM Treeにおいて1番目のSubmit属性付きButton要素がクリックされたことになる「Implicit Submission」という仕様があります。

(厳密には、ユーザーエージェントが暗黙的なフォーム送信をサポートしている場合、その操作の一例が「Enterキーの押下」であるということになりますが、話を簡単にするために本記事では「Enterキーの押下」を暗黙的なフォーム送信のトリガーになる操作という前提にします。)

WebComponentsでButton要素をコンポーネントとして作成する場合、これを満たすにはどうすればよいのでしょうか。

Shadow DOMを利用しない

Shadow DOMを利用しなければ、Form要素とButton CustomElementは同一のDOM Tree上に存在することになるので、仕様通りにImplicit Submissionも機能します。

例えば以下のようにコーディングすると

class MyButtonWithLight extends HTMLElement {
  constructor() {
    super();
    const content = this.innerHTML;
    this.innerHTML = '';

    this._internals = this.attachInternals();
    
    const button = document.createElement('button');
    this.appendChild(button);

    button.innerHTML = content;
  }
}

customElements.define('my-button-with-light', MyButtonWithLight);

以下のように利用できるカスタム要素を作成できます。

<form action="/path/to/post">
  <input type="text" name="name" />
  <input type="text" name="address" />
  <my-button-with-light>送信</my-button-with-light>
</form>

サンプルをみる

ただし、この場合はスタイルや要素をコンポーネントの内側に閉じ込めることはできません。スタイルを適用する場合は別途CSSファイルを用意し、グローバルに(headタグ内などで)読み込んでおく必要があります。

Shadow DOMを利用する場合

Shadow DOMを利用する場合はForm要素とButton CustomElement内部のButton要素でDOM Treeが異なるため、そもそもボタンを押下してもFormが送信されません。

Shadow DOMを利用したコンポーネントがForm要素と連携するためのattachInternal() APIを利用すれば、ボタンを押下したときにFormを送信するようにできます。

class MyButtonWithShadow extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this._internals = this.attachInternal();
    
    const shadow = this.attachShadow();
    const button = document.createElement('button');
    shadow.appendChild(button);

    button.addEventListener('click', () => {
      // フォームを送信する
      this._internals.form.requestSubmit();
    })
  }
}

customElements.define('my-button-with-shadow', MyButtonWithShadow);
<form action="/path/to/post">
  <input type="text" name="name" />
  <input type="text" name="address" />
  <my-button-with-shadow>送信</my-button-with-light>
</form>

これでボタンをクリックすればフォームが送信されるようになりますが、依然としてForm要素とButton要素は異なるDOM Treeに所属しているままなので、Enterキーを押下してもフォームは送信されません。

サンプルを見る

ただし、テキストボックスを一つだけにすると、Enterキーの押下でフォームが送信されるようになります。

<form action="/path/to/post">
  <input type="text" name="name" />
  <my-button-with-shadow>送信</my-button-with-light>
</form>

サンプルを見る

Implicit Submissionの仕様によると、Form内にSubmit Buttonが存在しない場合でも(Shadow DOMの内部のButton要素はForm要素のDOM Treeから見たときは存在していないので)、Implicit Submissionをブロックするフィールドが複数ない場合はEnterキーの押下でFormが送信されます(この例では、Implicit SubmissionをブロックするフィールドであるTextが一つしかないので適用される)。

つまり、Implicit Submissionを再現させようとして以下のようにキーイベントのハンドリングを行った場合、先程の例のように、Form要素の中に input[type=text] が一つだけある場合は2回Submitイベントが発火してしまいます。

  class MyButtonWithShadow extends HTMLElement {
    // ... 省略
  
+   connectedCallback() {
+     this._internals.form.addEventListener('keydown', (event) => {
+       if (e.key === 'enter') {
+         this._internals.form.requestSubmit();
+       }
+     })
+   }
  }

サンプルを見る


Form要素と同一のDOM Treeにボタンがあればよいので、例えば以下のようにダミーのボタンをShadow DOMの外側に別途配置する方法も考えられます。 Shadow DOM内部のボタンがクリックされた場合はダミーボタンがクリックされたことにします。 Implicit Submissionはダミーボタンに対して作用することになります。

  class MyButton extends HTMLElement {
    static formAssociated = true;
  
    constructor() {
      // ... 省略
  
      const dummyButton = document.createElement('button');
      dummyButton.style.display = "none";
      this.parentNode.insertBefore(dummyButton, this);

      const shadow = this.attachShadow();
      const button = document.createElement('button');
      shadow.appendChild(button);

      button.addEventListener('click', () => {
        // ダミーボタンをクリックしたことにする
        dummyButton.click();
      })
    }
  }

このようにすればImplicit Submissionの挙動を完全に利用できそうですが、作用がコンポーネント内部で閉じていないことになるので、少々無理矢理感があるような気もします。

まとめ

Implicit Submissionを実現するには、スタイルや要素の隠蔽といったメリットは享受できなくなりますが、Shadow DOMを利用せずに実装するとよさそうかもしれません。 (もしShadow DOMでImplicit Submissionを完全に再現できる方法をご存知のかたがいらしたら、ぜひ教えてください)

ではでは。

このカウンタは @piyoppi/counter-tools を使っています。

クリックすると匿名でいいねできます。