LoginSignup
0
0

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-07-08

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

概要

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

背景

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

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

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

ラジオ入力をLitElementで書く

以前ご紹介したラジオ入力をLitElementで書くと、以下のように簡潔でさっぱりしたWeb Componentになります。

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

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

Light DOMに<input>をレンダーする

まずは、renderという関数をlitからインポートするようにします。

import { css, LitElement, html, render } from "lit";

これは、以前ご紹介した、lit-htmlの単独テンプレートエンジンのrenderと一緒です。

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

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

...

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

...

ここで、変数名と属性名が同一のものなので、わざわざ{ attribute: "name" }で指定する必要はないです。

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にレンダーされた時
  2. valueに変化があった時

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

最初に部品がDOMにレンダーされた時にLight DOMに初期レンダーする方法

お馴染みのconnectedCallbackで実行するようにします。

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の他に、LitElementのLife Cycleもあります。

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

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

super.connectedCallbackを実行せずにconnectedCallbackを上書きすると、LitElementから何も出ないので、どうして部品が消えた??とお困りでしたら、これが原因です(ご想像の通り、筆者はこれで引っかかりましたね)。
スクリーンショット 2022-07-08 12.39.31.png
これで、FormDataに部品の初期状態の値が出るようになりましたが、いいえに変更してもfalseにはならない、という問題が残っています。

valueに変化があった時:LitElementの再レンダーを手動で起こす

次の問題は上記に比べて、上級者向けです。

Litの@stateのDecoratorは、実はこのようなコードになっているのです。

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の再レンダーも指示します。

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にもその結果が反映されるようになる、はずです!

試してみよう!

部品のコードは、こうなりました。

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
うまくいきました!

まとめ

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

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

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

0
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
0
0