1
0

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-06-14

概要

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;
}

結果

DOMツリー

スクリーンショット 2022-06-14 16.28.21.png

見た目

スクリーンショット 2022-06-14 16.29.05.png

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

スクリーンショット 2022-06-14 16.32.41.png

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