概要
Shadow DOMを使っているWeb Componentでは、Shadow DOM内の<input>がLight DOMに見えない課題があります。その課題を解決する方法を紹介します。
問題
Web ComponentsにShadow DOMを付けると、その中身がLight DOMに見えなくなります。ほとんどの場合、これは望ましいものですが、<inputなど、フォーム部品をWeb Component化し、Shadow DOMでスタイルとidなどを独立させたくなったら、問題になります。
解決法
Web ComponentのShadow DOMの<input>の値と連動する、Light DOMの<input>を作ります。
例
フォームに入れたいWeb Componentのタグを最初からHTMLのテンプレートに入れておきます。
src/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<style>
form {
display: flex;
flex-direction: column;
width: 90%;
margin: 1rem auto;
}
</style>
<h1>Hello World! From Template</h1>
<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 name="mailmag" label="メールマガジンを希望する"></wc-true-false-radio>
<button type="submit">送信</button>
</form>
</body>
</html>
customElements.defineでWeb Componentを登録しておく。また、フォームのデータがどうなるかを見るためにEventListenerを追加しておきます。
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);
});
src/wc-true-false-radio/index.ts
import template from "./template.html";
import styles from "./styles.css";
export default class WcTrueFalseRadio extends HTMLElement {
#value = true;
#trueRadio: HTMLInputElement;
#falseRadio: HTMLInputElement;
#label: HTMLSpanElement;
#hiddenInput = document.createElement("input");
constructor() {
super();
this.attachShadow({ mode: "open" });
if (!this.shadowRoot) throw Error("Shadow root not supported.");
this.shadowRoot.innerHTML = template;
const styleElement = document.createElement("style");
styleElement.innerHTML = styles;
this.shadowRoot.append(styleElement);
this.#trueRadio = this.shadowRoot.querySelector<HTMLInputElement>("input#true")!;
this.#falseRadio = this.shadowRoot.querySelector<HTMLInputElement>("input#false")!;
this.#label = this.shadowRoot.querySelector("span")!;
this.#trueRadio.addEventListener("click", this.#handleRadioInput);
this.#falseRadio.addEventListener("click", this.#handleRadioInput);
}
get value(): boolean {
return this.#value;
}
set value(newValue: any) {
this.#value = Boolean(newValue); // ラジオ入力の値に変化が生じたら、DOMにもその結果を自動的に反映させる
this.#trueRadio.toggleAttribute("checked", this.#value);
this.#falseRadio.toggleAttribute("checked", !this.#value);
this.#hiddenInput.value = String(Number(this.#value));
}
connectedCallback() {
const labelText = this.getAttribute("label"); // index.htmlで指定したlabelの値を取得する
if (!labelText) throw TypeError("label attribute must be defined.");
this.#label.textContent = labelText; // Shadow DOMに入っているラベルに上記のAttributeの値を代入する
const inputName = this.getAttribute("name"); // Light DOMのフォームに入れる\<input name="">で使います。
if (!inputName) throw TypeError("name attribute must be defined.");
this.#hiddenInput.setAttribute("name", inputName);
this.#hiddenInput.setAttribute("type", "hidden");
this.#hiddenInput.setAttribute("slot", "hidden-input"); // \<slot>にnameを指定すると、このようにどの\<slot>にエレメントが入るかをコントロールできる
this.#hiddenInput.value = String(Number(this.value));
this.append(this.#hiddenInput); // Light DOMに入ります。
}
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));
};
}
src/wc-true-false-radio/template.html
<span id="label"></span>
<div id="input-group">
<label for="true">はい</label>
<input type="radio" name="radio" id="true" checked value="1" aria-describedby="label">
<label for="false">いいえ</label>
<input type="radio" name="radio" id="false" value="0" aria-describedby="label">
<slot name="hidden-input"></slot>
</div>
src/wc-true-false-radio/styles.css
:host {
display: flex;
flex-direction: column;
}
#input-group {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}