Shadow DOMを使ったWeb ComponentsでLight DOMのフォームでも使える<input>部品を作る方法

# 概要
Shadow DOMを使っているWeb Componentでは、Shadow DOM内の\<input>がLight DOMに見えない課題があります。その課題を解決する方法を紹介します。

<a href="https://qiita.com/tronicboy/items/52a3fcb6a03eba2cd439">この記事は先日投稿したものを元にしています。</a>

## 問題
Web ComponentsにShadow DOMを付けると、その中身がLight DOMに見えなくなります。ほとんどの場合、これは望ましいものですが、\<inputなど、フォーム部品をWeb Component化し、Shadow DOMでスタイルとidなどを独立させたくなったら、問題になります。

## 解決法
Web ComponentのShadow DOMの\<input>の値と連動する、Light DOMの\<input>を作ります。

## 例
フォームに入れたいWeb Componentのタグを最初からHTMLのテンプレートに入れておきます。

```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を追加しておきます。
```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);
});
```

```typescript: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));
  };
}
```

```html: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>
```

```css: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;
}
```

## 結果

### DOMツリー
![スクリーンショット 2022-06-14 16.28.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/4bbdea7a-9396-a5ee-41f7-cb884012acdf.png)

### 見た目
![スクリーンショット 2022-06-14 16.29.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/aa67fd2e-ed91-21f4-eb4b-00fc14b6001c.png)

### サブミットをした時のログ

![スクリーンショット 2022-06-14 16.32.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/f305cfd7-7fae-cc79-5030-aed556ef0293.png)