はいさい!ちゅらデータぬオースティンやいびーん!
概要
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になります。
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>にこのラジオ入力が含まれなくなってしまうのです。
これは困るので、次に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
を実行するべきでしょうか?
- 最初に部品がDOMにレンダーされた時
- 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から何も出ないので、どうして部品が消えた??とお困りでしたら、これが原因です(ご想像の通り、筆者はこれで引っかかりましたね)。
これで、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>
`;
}
}
まとめ
これで、LitElementで ShadowDOMにレンダーしながらも、Light DOMにもレンダーする方法を紹介してきましたが、いかがでしょうか?
LitElementは所詮、Web Componentなので、Vanilla Web Componentでできることは全てできます。これがLitElementの長所です。
強い力を持ちながらも、冗長にならないようにするためのツールも抵抗してくれているので、とてもいいフレームワークです。
Googleに感謝します。
次の記事は、LitElementでElementInternalsを使う方法を紹介します!