LitElementでShadow DOMにも、Light DOMにもエレメントをレンダーする方法

はいさい！ちゅらデータぬオースティンやいびーん！

# 概要
LitElementで通常通り、Shadow DOMにレンダーしながらも、Light DOMにもエレメントをレンダーする方法を紹介します。

## 背景
以前の記事で、Web Componentsの一つの課題である「Light DOMのフォームにShadow DOMの\<input>を入れても、FormDataに含まれない問題」と二つの解決法を紹介しました。

https://qiita.com/tronicboy/items/4ba393e3d79afbab1406

https://qiita.com/tronicboy/items/7cb8571dfe7f38e7e9d8

これらの例ではLitElementを使っておらず、Vanilla Web Componentで紹介しましたが、実は同じことがLitElementでもできます。

今回は、Light DOMに\<input>をレンダーして、Light DOMの\<form>と使えるようにする方法を紹介します。

# ラジオ入力をLitElementで書く
以前ご紹介したラジオ入力をLitElementで書くと、以下のように簡潔でさっぱりしたWeb Componentになります。
```typescript:src/wc-true-false-radio.ts
import { css, LitElement, html } from "lit";
import { state, property } from "lit/decorators.js";

export default class WcTrueFalseRadio extends LitElement {
  @state()
  value = true;
  @property({ attribute: "label" })
  labelText;

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

  static styles = css`
    :host {
      display: flex;
      flex-direction: column;
    }

    #input-group {
      display: flex;
      flex-direction: row;
      align-items: center;
      justify-content: space-between;
    }
  `;

  render() {
    return 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>
    `;
  }
}
```
（Web Componentsを使うなら、Litはほぼ必須かと思います）

これだと、全てがShadow DOMにレンダーされるので、以前と同じ問題にぶつかります。

Light DOMの\<form>にこのラジオ入力が含まれなくなってしまうのです。
![スクリーンショット 2022-07-08 12.22.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/dc4a4bb3-d207-cfe4-164f-bbeb437a017b.png)

これは困るので、次にLight DOMにこのラジオ入力の状態を反映した\<input type="hidden">をレンダーする方法を紹介します。

# Light DOMに\<input>をレンダーする
まずは、renderという関数をlitからインポートするようにします。
```typescript
import { css, LitElement, html, render } from "lit";
```
これは、以前ご紹介した、lit-htmlの単独テンプレートエンジンのrenderと一緒です。

これを使ってLight DOMにレンダーします。

nameの属性も欲しいので、propertyを追加します。
```typescript
...

export default class WcTrueFalseRadio extends LitElement {
  @state()
  value = true;
  @property({ attribute: "label" })
  labelText;
  @property()
  name;

...
```
ここで、変数名と属性名が同一のものなので、わざわざ`{ attribute: "name" }`で指定する必要はないです。
```typescript
import { css, LitElement, html, render } from "lit";
import { state, property } from "lit/decorators.js";

export default class WcTrueFalseRadio extends LitElement {
  @state()
  value = true;
  @property({ attribute: "label" })
  labelText;
  @property()
  name;

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

  static styles = css`
    :host {
      display: flex;
      flex-direction: column;
    }

    #input-group {
      display: flex;
      flex-direction: row;
      align-items: center;
      justify-content: space-between;
    }
  `;

  #renderToLightDOM() {
    const root = this;
    const template = html`<input type="hidden" value=${String(Number(this.value))} name=${this.name} />`;
    render(template, root);
  }

  render() {
    return 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></slot>
      </div>
    `;
  }
}
```
これでLight DOMにレンダーする方法を確保しましたが、さて、いつこの`#renderToLightDOM`を実行するべきでしょうか？

1. 最初に部品がDOMにレンダーされた時
1. valueに変化があった時

この二つに対応していきます。

## 最初に部品がDOMにレンダーされた時にLight DOMに初期レンダーする方法
お馴染みのconnectedCallbackで実行するようにします。
```typescript
import { css, LitElement, html, render } from "lit";
import { state, property } from "lit/decorators.js";

export default class WcTrueFalseRadio extends LitElement {
  @state()
  value = true;
  @property({ attribute: "label" })
  labelText;
  @property()
  name;

  connectedCallback() {
    super.connectedCallback();
    this.#renderToLightDOM();
  }

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

  static styles = css`
    :host {
      display: flex;
      flex-direction: column;
    }

    #input-group {
      display: flex;
      flex-direction: row;
      align-items: center;
      justify-content: space-between;
    }
  `;

  #renderToLightDOM() {
    const root = this;
    const template = html`<input type="hidden" value=${String(Number(this.value))} name=${this.name} />`;
    render(template, root);
  }

  render() {
    return 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></slot>
      </div>
    `;
  }
}
```
Web ComponentのLife Cycleの他に、<a href="https://lit.dev/docs/components/lifecycle/">LitElementのLife Cycle</a>もあります。

今回は、DOMに入った時に先に我ら愛するHidden inputをレンダーしておきますが、厳密にいうとこの時点ではまだLitElementのShadow DOMには\<style>のタグしか入っていません。

また、super.connectedCallbackを実行しなければならないのですが、これは、LitElementの必要な処理（Shadow DOMにレンダーする準備）をさせておかないといけないからです。

super.connectedCallbackを実行せずにconnectedCallbackを上書きすると、LitElementから何も出ないので、どうして部品が消えた？？とお困りでしたら、これが原因です（ご想像の通り、筆者はこれで引っかかりましたね）。
![スクリーンショット 2022-07-08 12.39.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/5a380ffd-8765-32b8-97df-8da3c2fe3808.png)
これで、FormDataに部品の初期状態の値が出るようになりましたが、いいえに変更してもfalseにはならない、という問題が残っています。

## valueに変化があった時：LitElementの再レンダーを手動で起こす
次の問題は上記に比べて、上級者向けです。

Litの`@state`のDecoratorは、実はこのようなコードになっているのです。
```typescript
get value(): boolean {
  return this.#value;
}
set value(newValue: boolean) {
  const oldValue = this.value;
  this.#value = newValue;
  this.requestUpdate("value", oldValue);
}
```
我々も、今回、`@state`を使わず、手動で`LitElement.requestUpdate`を実行します。

この時に、ついでにLight DOMの再レンダーも指示します。
```typescript
get value(): boolean {
  return this.#value;
}
set value(newValue: boolean) {
  const oldValue = this.value;
  this.#value = newValue;
  this.requestUpdate("value", oldValue);
  this.#renderToLightDOM();
}
```
こうすると、`value`が変わるたびに、Light DOMにもその結果が反映されるようになる、はずです！

## 試してみよう！

部品のコードは、こうなりました。
```typescript
import { css, LitElement, html, render } from "lit";
import { property } from "lit/decorators.js";

export default class WcTrueFalseRadio extends LitElement {
  #value = true;
  @property({ attribute: "label" })
  labelText;
  @property()
  name;

  connectedCallback() {
    super.connectedCallback();
    this.#renderToLightDOM();
  }

  get value(): boolean {
    return this.#value;
  }
  set value(newValue: boolean) {
    const oldValue = this.value;
    this.#value = newValue;
    this.requestUpdate("value", oldValue);
    this.#renderToLightDOM();
  }

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

  static styles = css`
    :host {
      display: flex;
      flex-direction: column;
    }

    #input-group {
      display: flex;
      flex-direction: row;
      align-items: center;
      justify-content: space-between;
    }
  `;

  #renderToLightDOM() {
    const root = this;
    const template = html`<input type="hidden" value=${String(Number(this.value))} name=${this.name} />`;
    render(template, root);
  }

  render() {
    return 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></slot>
      </div>
    `;
  }
}
```
ビルドしてみましょう！
![ezgif.com-gif-maker (5).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/3b97fb5e-0245-4482-3909-aba89063ef4f.gif)
うまくいきました！

# まとめ
これで、LitElementで ShadowDOMにレンダーしながらも、Light DOMにもレンダーする方法を紹介してきましたが、いかがでしょうか？

LitElementは所詮、Web Componentなので、Vanilla Web Componentでできることは全てできます。これがLitElementの長所です。
強い力を持ちながらも、冗長にならないようにするためのツールも抵抗してくれているので、とてもいいフレームワークです。
Googleに感謝します。

次の記事は、LitElementでElementInternalsを使う方法を紹介します！