LoginSignup
1
0

More than 1 year has passed since last update.

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

Posted at

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

概要

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を付けます。

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に渡したい値を入れます。

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に含まれません。

<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で以下のようにフォームを送信したときに、どのような情報が含まれているのかをみてみましょう。

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
ラジオを「いいえ」にすると、
スクリーンショット 2022-06-28 15.27.01.png
mailmagが正しく'0'になっています!

フォームをリセットした時にWeb Componentも初期化する方法

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

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

これで、ネイティブDOMのリセットに対してWeb Componentも初期化できるのです!

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

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

【重要注意】

Safari(Webkit)はまだHTMLElement.attachInternalsをサポートしていません!

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

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

やり方は非常に簡単です。こちらのnpmパッケージを使います

yarn add element-internals-polyfill

そして、index.tsでimportをします。

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で今後もサポートされること、軽くてパフォーマンスがいいこと、魅力溢れた技術です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0