ElementInternalsでWeb Componentの<input>をフォームと相性よく付き合わせる方法

はいさい！ちゅらデータぬオースティンやいびーん！

# 概要
ElementInternalsを使い、Shadow DOMのWeb Componentsを\<form>で使えるようにする方法を紹介します！

## 背景
以前、Web Componentsの\<slot>にLight DOMの非表示\<input>を入れることで、Web Componentsをフォームで使う方法を紹介しましたが、実は、それと違った最新鋭の解決法があります。

それは、ElementInternalsをWeb Componentにつけて、Web Componentを丸ごと擬似\<input>にする方法です。

以前の記事で作ったラジオインプットのコードを元にご紹介します。
https://qiita.com/tronicboy/items/b0008bcf43f3fd46d8c9

# コード
Light DOMのエレメントを削除し、ElementInternalsを付けます。
```typescript:src/wc-true-false-radio/index.ts
import styles from "./styles.css";

import { html, render } from "lit-html";

export default class WcTrueFalseRadio extends HTMLElement {
  #value;
  #labelText;
  #trueRadio!: HTMLInputElement;
  #falseRadio!: HTMLInputElement;
  #internals: ElementInternals;

  static formAssociated = true; // これでフォームで使えるエレメントだと指定している

  constructor() {
    super();
    this.#value = true;
    this.#labelText = "";
    this.#internals = this.attachInternals(); // これでフォームで使えるエレメントだと指定している
    this.attachShadow({ mode: "open" });
    if (!this.shadowRoot) throw Error("Shadow root not supported.");
    const styleElement = document.createElement("style");
    styleElement.innerHTML = styles;
    this.shadowRoot.append(styleElement);
  }

  get value(): boolean {
    return this.#value;
  }
  set value(newValue: any) {
    this.#value = Boolean(newValue); // ラジオ入力の値に変化が生じたら、DOMにもその結果を自動的に反映させる
    this.#render();
    this.#internals.setFormValue(String(Number(this.value)));
  }

  connectedCallback() {
    const labelText = this.getAttribute("label"); // index.htmlで指定したlabelの値を取得する
    if (!labelText) throw TypeError("label attribute must be defined.");
    this.#labelText = labelText;
    this.#render();
    this.value = true;
    this.#trueRadio = this.shadowRoot!.querySelector<HTMLInputElement>("input#true")!;
    this.#falseRadio = this.shadowRoot!.querySelector<HTMLInputElement>("input#false")!;
  }

  disconnectedCallback() {
    this.#falseRadio!.removeEventListener("click", this.#handleRadioInput); // EventListenerを外すことは基本的に不要ですが、メモリリークを意識することがGood Practiceです。
    this.#trueRadio!.removeEventListener("click", this.#handleRadioInput);
  }

  #handleRadioInput: EventListener = (event) => {
    const target = event.currentTarget;
    if (!(target instanceof HTMLInputElement)) throw Error("This listener must be used with an Input Element.");
    this.value = Boolean(Number(target.value));
  };

  #render() {
    if (!this.shadowRoot) throw Error("Cannot render Shadow DOM if not attached.");
    const shadowDOMTemplate = html`
      <span id="label">${this.#labelText}</span>
      <div id="input-group">
        <label for="true">はい</label>
        <input
          @click=${this.#handleRadioInput}
          type="radio"
          name="radio"
          id="true"
          .checked=${this.value}
          value="1"
          aria-describedby="label"
        />
        <label for="false">いいえ</label>
        <input
          @click=${this.#handleRadioInput}
          type="radio"
          name="radio"
          id="false"
          value="0"
          .checked=${!this.value}
          aria-describedby="label"
        />
        <slot name="hidden-input"></slot>
      </div>
    `;
    render(shadowDOMTemplate, this.shadowRoot); // なければ、Shadow DOMに追加、あれば、変わった箇所を変更する
  }
}
```

## FormData（つまり、普通のフォーム）と相性よく組み合わせる秘密
フォームに見える値を明示的に指定する必要があります。ElementInternals（this.#internals）のsetFormValueでFormDataに渡したい値を入れます。
```typescript
get value(): boolean {
  return this.#value;
}
set value(newValue: any) {
  this.#value = Boolean(newValue); // ラジオ入力の値に変化が生じたら、DOMにもその結果を自動的に反映させる
  this.#render();
  this.#internals.setFormValue(String(Number(this.value))); // ここが重要！
}
```
今回はBooleanを渡すので、数字にしてから文字列に変換します。

## HTMLでWeb Componentにname属性を指定する必要があります。
\<input name="last-name">と同様に、name属性を指定しないとFormDataに含まれません。
```html
<form>
  <label for="first-name">名</label>
  <input id="first-name" type="text" name="first-name" maxlength="32" minlength="1" required>
  <label for="last-name">姓</label>
  <input id="last-name" type="text" name="last-name" maxlength="32" minlength="1" required>
  <!-- ここにWeb Componentの部品を入れたい -->
  <wc-true-false-radio id="mailmag" name="mailmag" label="メールマガジンを希望する"></wc-true-false-radio>
  <button type="submit">送信</button>
</form>
```

## 結果
index.tsで以下のようにフォームを送信したときに、どのような情報が含まれているのかをみてみましょう。
```typescript:src/index.ts
import WcTrueFalseRadio from "./wc-true-false-radio";

customElements.define("wc-true-false-radio", WcTrueFalseRadio);

const form = document.querySelector("form")!;

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const formData = new FormData(form);
  const data = Object.fromEntries(formData);
  console.log(data);
})
```
そしたら、このようにmailmagに"1"か"0"の値が出てきます！
![スクリーンショット 2022-06-28 15.26.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/98123963-e1f2-c237-c68a-4b834d5c8901.png)
ラジオを「いいえ」にすると、
![スクリーンショット 2022-06-28 15.27.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/bfead413-90b5-1c76-fcd4-5d2ebb14970e.png)
mailmagが正しく'0'になっています！

# フォームをリセットした時にWeb Componentも初期化する方法
HTMLFormElement.reset()を使い、フォームを初期化することがありますが、Web Componentを上記のように使っていると、リセットされません。

しかし、ElementInternalsを付けた上で、Web ComponentにformResetCallbackという名前のクラスメソッドを追加すると、\<form>エレメントをリセットした時に、そのメソッドが実行されます！

これで、ネイティブDOMのリセットに対してWeb Componentも初期化できるのです！
```typescript:src/wc-true-false-radio/index.ts
import "element-internals-polyfill";
import styles from "./styles.css";

import { html, render } from "lit-html";

export default class WcTrueFalseRadio extends HTMLElement {
  #value;
  #labelText;
  #trueRadio!: HTMLInputElement;
  #falseRadio!: HTMLInputElement;
  #internals: ElementInternals;

  static formAssociated = true; // これでフォームで使えるエレメントだと指定している

  constructor() {
    super();
    this.#value = true;
    this.#labelText = "";
    this.#internals = this.attachInternals(); // これでフォームで使えるエレメントだと指定している
    this.attachShadow({ mode: "open" });
    if (!this.shadowRoot) throw Error("Shadow root not supported.");
    const styleElement = document.createElement("style");
    styleElement.innerHTML = styles;
    this.shadowRoot.append(styleElement);
  }

  get value(): boolean {
    return this.#value;
  }
  set value(newValue: any) {
    this.#value = Boolean(newValue); // ラジオ入力の値に変化が生じたら、DOMにもその結果を自動的に反映させる
    this.#render();
    this.#internals.setFormValue(String(Number(this.value)));
  }

  formResetCallback() {
    console.log("WC: form reset");
    this.value = true; // ここでWCを初期化します。
  }

  connectedCallback() {
    const labelText = this.getAttribute("label"); // index.htmlで指定したlabelの値を取得する
    if (!labelText) throw TypeError("label attribute must be defined.");
    this.#labelText = labelText;
    this.#render();
    this.value = true;
    this.#trueRadio = this.shadowRoot!.querySelector<HTMLInputElement>("input#true")!;
    this.#falseRadio = this.shadowRoot!.querySelector<HTMLInputElement>("input#false")!;
  }

  disconnectedCallback() {
    this.#falseRadio!.removeEventListener("click", this.#handleRadioInput); // EventListenerを外すことは基本的に不要ですが、メモリリークを意識することがGood Practiceです。
    this.#trueRadio!.removeEventListener("click", this.#handleRadioInput);
  }

  #handleRadioInput: EventListener = (event) => {
    const target = event.currentTarget;
    if (!(target instanceof HTMLInputElement)) throw Error("This listener must be used with an Input Element.");
    this.value = Boolean(Number(target.value));
  };

  #render() {
    if (!this.shadowRoot) throw Error("Cannot render Shadow DOM if not attached.");
    const shadowDOMTemplate = html`
      <span id="label">${this.#labelText}</span>
      <div id="input-group">
        <label for="true">はい</label>
        <input
          @click=${this.#handleRadioInput}
          type="radio"
          name="radio"
          id="true"
          .checked=${this.value}
          value="1"
          aria-describedby="label"
        />
        <label for="false">いいえ</label>
        <input
          @click=${this.#handleRadioInput}
          type="radio"
          name="radio"
          id="false"
          value="0"
          .checked=${!this.value}
          aria-describedby="label"
        />
        <slot name="hidden-input"></slot>
      </div>
    `;
    render(shadowDOMTemplate, this.shadowRoot); // なければ、Shadow DOMに追加、あれば、変わった箇所を変更する
  }
}
```
試しにフォームをリセットするボタンを追加して、リセットしてみました！
![ezgif.com-gif-maker (4).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/8730fbba-a8b0-2604-3b81-4529ef16c7fe.gif)

他にもこのような面白いメソッドがありますので、興味があったら、以下のGoogleがChromeで出しているドキュメントをご覧ください！

https://docs.google.com/document/d/1JO8puctCSpW-ZYGU8lF-h4FWRIDQNDVexzHoOQ2iQmY/edit?pli=1#heading=h.2izmbauztyja

# 【重要注意】
Safari（Webkit）はまだHTMLElement.attachInternalsをサポートしていません！

開発中でそろそろリリースされるようですが、現状エラーになります。

SafariでもHTMLElement.attachInternalsを使うためには、Polyfillという、ブラウザに足りていない機能を補助してくれるパッケージをインストールしてユーザーのブラウザで実行させる必要があります。

やり方は非常に簡単です。<a href="https://www.npmjs.com/package/element-internals-polyfill">こちらのnpmパッケージを使います<a/>。
```
yarn add element-internals-polyfill
```

そして、index.tsでimportをします。
```typescript:src/index.ts
import "element-internals-polyfill";
import WcTrueFalseRadio from "./wc-true-false-radio";

customElements.define("wc-true-false-radio", WcTrueFalseRadio);

const form = document.querySelector("form")!;

form.addEventListener("submit", (event) => {
  event.preventDefault();
  const formData = new FormData(form);
  const data = Object.fromEntries(formData);
  console.log(data);
})

const resetButton = document.querySelector("button#reset")!;
resetButton.addEventListener("click", () => {
  form.reset();
})
```
こうするだけでSafariでも動きます！

# まとめ
これで難点だったフォームでWeb Componentが使えない問題を解決できました！Web Componentsは難易度が高いが、ブラウザAPIで今後もサポートされること、軽くてパフォーマンスがいいこと、魅力溢れた技術です。