はいさい!ちゅらデータぬオースティンやいびーん!
概要
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を付けます。
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で以下のようにフォームを送信したときに、どのような情報が含まれているのかをみてみましょう。
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"の値が出てきます!
ラジオを「いいえ」にすると、
mailmagが正しく'0'になっています!
フォームをリセットした時にWeb Componentも初期化する方法
HTMLFormElement.reset()を使い、フォームを初期化することがありますが、Web Componentを上記のように使っていると、リセットされません。
しかし、ElementInternalsを付けた上で、Web ComponentにformResetCallbackという名前のクラスメソッドを追加すると、<form>エレメントをリセットした時に、そのメソッドが実行されます!
これで、ネイティブDOMのリセットに対してWeb Componentも初期化できるのです!
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に追加、あれば、変わった箇所を変更する
}
}
試しにフォームをリセットするボタンを追加して、リセットしてみました!
他にもこのような面白いメソッドがありますので、興味があったら、以下のGoogleがChromeで出しているドキュメントをご覧ください!
【重要注意】
Safari(Webkit)はまだHTMLElement.attachInternalsをサポートしていません!
開発中でそろそろリリースされるようですが、現状エラーになります。
SafariでもHTMLElement.attachInternalsを使うためには、Polyfillという、ブラウザに足りていない機能を補助してくれるパッケージをインストールしてユーザーのブラウザで実行させる必要があります。
やり方は非常に簡単です。こちらのnpmパッケージを使います。
yarn add element-internals-polyfill
そして、index.tsでimportをします。
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で今後もサポートされること、軽くてパフォーマンスがいいこと、魅力溢れた技術です。