Web ComponentのShadow DOMのEventをLight DOMに配信する方法を紹介する

# 概要
本記事は、Web Component APIで作ったWeb ComponentのShadow DOMで配信されたEventをLight DOMで受信する方法を紹介するものです。

本記事で使うコードは、<a href="https://qiita.com/tronicboy/items/e6d7a9b6c5130b1e0457">前回の記事の成果物</a>を使うものです。

# 目次
1. Web Component内で配信されたEventをWeb ComponentからCustom Eventとして配信する
1. Custom EventのComposedとBubblesの設定でShadow DOMからLight DOMへ
1. CustomEventから情報をLight DOMに配信する方法

# Web Component内で配信されたEventをWeb ComponentからCustom Eventとして配信する
投稿者は、Web Component内で発生したEventをLight DOMで受信する常套手段として、Web Componentから<a href="https://developer.mozilla.org/ja/docs/Web/API/EventTarget/dispatchEvent">EventTarget.dispatchEvent()</a>を使います。

Light DOMのWeb ComponentエレメントにEventListenerを追加します。
```typescript:src/index.ts
import MyComponent from "./my-component";

customElements.define("my-component", MyComponent);

const body = document.querySelector("body")!;

const myComponent = document.createElement("my-component");
myComponent.addEventListener("tile-click", (event) => {
  console.log("Event Received in Light DOM", event);
});

body.append(myComponent);
```

そして、Web Componentではこのように、Shadow DOMのEventを受信した時に、EventをWeb Component元で配信するようにします。

```typescript:src/my-component/index.ts
import template from "./template.html";
import styles from "./styles.css";

export default class MyComponent extends HTMLElement {
  #listData: string[] = [];
  #ul: HTMLUListElement;

  constructor() {
    console.log("Element Created.");
    super();
    this.attachShadow({ mode: "open" });
    if (!this.shadowRoot) throw Error("Browser does not support Shadow DOM");
    this.shadowRoot.innerHTML = template;
    const styleElement = document.createElement("style");
    styleElement.innerHTML = styles;
    this.shadowRoot.append(styleElement);
    this.#ul = this.shadowRoot.querySelector("ul")!;
  }

  get listData(): string[] {
    return this.#listData;
  }
  set listData(value: string[]) {
    this.#listData = value;
    this.#ul.childNodes.forEach((li) => li.removeEventListener("click", this.#handleLIClick));
    this.#ul.innerHTML = "";
    this.#listData.forEach((item) => {
      const li = document.createElement("li");
      li.textContent = item;
      li.addEventListener("click", this.#handleLIClick);
      this.#ul.append(li);
    });
    console.log("Data rendered to DOM");
  }

  #handleLIClick: EventListener = (event) => {
    console.log("Event inside Shadow DOM");
    const customEvent = new Event("tile-click");
    this.dispatchEvent(customEvent); // thisは\<my-component>を指しています。
  };

  connectedCallback() {
    console.log("Element Added to DOM");
    fetch("https://jsonplaceholder.typicode.com/posts?_limit=10")
      .then((result) => result.json())
      .then((data) => {
        console.log("Data Retrieved.");
        this.listData = data.map((post) => post.title);
      });
  }
}
```

### クリックしてみた結果
![スクリーンショット 2022-06-13 15.05.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/c119771b-34be-7a84-8e15-9608f30334b8.png)

このように、Shadow DOM内の\<li>エレメントで発生したEventをコントロールした形でLight DOMで受信できます。

この方法が一番お勧めです。なぜなら、開発者がどのようなイベントをこのComponentから配信したいのかを考えさせるからです。ただ、以前ご指摘をいただきましたが、昔のJQueryの悩みと同様に、Custom Event名で混乱が起きかねます。

# Custom EventのComposedとBubblesの設定でShadow DOMからLight DOMへ
次に紹介する方法は、Shadow DOMで配信されたEventがそのままLight DOMのEventListenerまで到達するように書くものです。

```typescript:src/index.ts
import MyComponent from "./my-component";

customElements.define("my-component", MyComponent);

const body = document.querySelector("body")!;

const myComponent = document.createElement("my-component");
myComponent.addEventListener("tile-click", (event) => {
  console.log("Event Received in Light DOM", event);
});

body.append(myComponent);

```

```typescript:src/my-component/index.ts
import template from "./template.html";
import styles from "./styles.css";

export default class MyComponent extends HTMLElement {
  #listData: string[] = [];
  #ul: HTMLUListElement;

  constructor() {
    console.log("Element Created.");
    super();
    this.attachShadow({ mode: "open" });
    if (!this.shadowRoot) throw Error("Browser does not support Shadow DOM");
    this.shadowRoot.innerHTML = template;
    const styleElement = document.createElement("style");
    styleElement.innerHTML = styles;
    this.shadowRoot.append(styleElement);
    this.#ul = this.shadowRoot.querySelector("ul")!;
  }

  get listData(): string[] {
    return this.#listData;
  }
  set listData(value: string[]) {
    this.#listData = value;
    this.#ul.childNodes.forEach((li) => li.removeEventListener("click", this.#handleLIClick));
    this.#ul.innerHTML = "";
    this.#listData.forEach((item) => {
      const li = document.createElement("li");
      li.textContent = item;
      li.addEventListener("click", this.#handleLIClick);
      this.#ul.append(li);
    });
    console.log("Data rendered to DOM");
  }

  #handleLIClick: EventListener = (event) => {
    console.log("Event inside Shadow DOM");
    const target = event.target;
    if (!(target instanceof HTMLElement)) return;
    const customEvent = new Event("tile-click", { bubbles: true, composed: true });
    target.dispatchEvent(customEvent); // \<li>のエレメントもしくはその子エレメントから配信する
  };

  connectedCallback() {
    console.log("Element Added to DOM");
    fetch("https://jsonplaceholder.typicode.com/posts?_limit=10")
      .then((result) => result.json())
      .then((data) => {
        console.log("Data Retrieved.");
        this.listData = data.map((post) => post.title);
      });
  }
}
```

このようにbubbles: trueとcomposed: trueを使うと、thisじゃなくても、Shadow DOM内のエレメントから配信されたEventをLight DOMで受信することができます。

| 設定キー | 意味 |
| --- | --- |
| composed| Shadow DOMとLight DOMの境界を超えてもいいEventであることを示す。基本的にShadow DOM内のEventはこの境界を超えないようになっている。|
| bubbles | EventがEventListenerに到達するまで、続いて親エレメントに配信してもらう設定 |

# CustomEventから情報をLight DOMに配信する方法
1番目の方法の延長線ですが、このようにできます。

```typescript:src/index.ts
import MyComponent from "./my-component";

customElements.define("my-component", MyComponent);

const body = document.querySelector("body")!;

const myComponent = document.createElement("my-component");
myComponent.addEventListener("tile-click", (event) => {
  if (!(event instanceof CustomEvent)) throw Error("Event not Custom Event.");
  const data = event.detail;
  console.log("Event Received in Light DOM. Data: ", data);
});

body.append(myComponent);
```

```typescript:src/my-component/index.ts
import template from "./template.html";
import styles from "./styles.css";

export default class MyComponent extends HTMLElement {
  #listData: string[] = [];
  #ul: HTMLUListElement;

  constructor() {
    console.log("Element Created.");
    super();
    this.attachShadow({ mode: "open" });
    if (!this.shadowRoot) throw Error("Browser does not support Shadow DOM");
    this.shadowRoot.innerHTML = template;
    const styleElement = document.createElement("style");
    styleElement.innerHTML = styles;
    this.shadowRoot.append(styleElement);
    this.#ul = this.shadowRoot.querySelector("ul")!;
  }

  get listData(): string[] {
    return this.#listData;
  }
  set listData(value: string[]) {
    this.#listData = value;
    this.#ul.childNodes.forEach((li) => li.removeEventListener("click", this.#handleLIClick));
    this.#ul.innerHTML = "";
    this.#listData.forEach((item) => {
      const li = document.createElement("li");
      li.textContent = item;
      li.addEventListener("click", this.#handleLIClick);
      this.#ul.append(li);
    });
    console.log("Data rendered to DOM");
  }

  #handleLIClick: EventListener = (event) => {
    console.log("Event inside Shadow DOM");
    const target = event.target;
    if (!(target instanceof HTMLElement)) return;
    const customEvent = new CustomEvent("tile-click", { bubbles: true, composed: true, detail: 48 });
    target.dispatchEvent(customEvent); // \<li>のエレメントもしくはその子エレメントから配信する
  };

  connectedCallback() {
    console.log("Element Added to DOM");
    fetch("https://jsonplaceholder.typicode.com/posts?_limit=10")
      .then((result) => result.json())
      .then((data) => {
        console.log("Data Retrieved.");
        this.listData = data.map((post) => post.title);
      });
  }
}
```

detailのキーに渡したい情報を入れます。文字列、数字、関数、Object、なんでもOK。

### 結果
![スクリーンショット 2022-06-13 15.48.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/e410b4c0-540f-a7ed-1d9f-88f57474ee4b.png)


# まとめ
以上、Web ComponentのShadow DOM内からEventをLight DOMに配信する方法を紹介しました。
筆者は、Web Componentsの勉強を始めた頃、Shadow DOM内のEventの仕組みでかなり混乱しましたが、こちらで記載している情報がわかれば、大体大丈夫です。