アンケート用カスタムタグライブラリ

次の様なアンケート用のカスタムタグライブラリを作成してみました。
ラジオボタンを適当に選択して「集計」ボタンをクリックするとカテゴリごとの合計と全体の合計を求めます。左端のラジオボタンが0、右端が4になっています。
初期の選択位置がバラバラなのはプロパティのテストのためです。

カテゴリ1

質問1です

質問2です

カテゴリ2

質問1です

質問2です

ソースコード

カスタムタグライブラリのソースコードは次の通りです。
結構長いです。

<template id="QuestionBox">
	<div id="question-str" class="question-cell">
		<slot></slot>
	</div>
	<div class="question-cell">
		<input type="radio" name="rd-question" value="0">
		<input type="radio" name="rd-question" value="1">
		<input type="radio" name="rd-question" value="2">
		<input type="radio" name="rd-question" value="3">
		<input type="radio" name="rd-question" value="4">
	</div>
	<style>
	.question-cell {
		display: table-cell;
	}
	</style>
</template>
<script>
(function(){
	class QuestionBox extends HTMLElement {
		// write once
		// customElement.define()の前にセットする
		static set template(val){
			if(!this.tmpl){ this.tmpl = val; }
		}

		// cloneを返す
		static get template(){
			return document.importNode(this.tmpl.content, true);;
		}

		constructor(){
			super();
			const sr = this.attachShadow({mode: "open"});
			sr.appendChild(QuestionBox.template);
		}

		connectedCallback(){
			this.style.display = "table-row";

			// コンストラクタではvalueのsetterが未呼び出しなので失敗するためこのタイミングで行う
			this.defaultCheck();
		}

		// 初期状態で選択するラジオボタンを選ぶ
		defaultCheck(){
			let ipts = this.shadowRoot.querySelectorAll("input");
			let val = this.getAttribute("value");
			if(val == null){ val = 2; }
			ipts[val].checked = true;
		}

		// 選択されているラジオボタンの値を返す
		get value(){
			const ipts = this.shadowRoot.querySelectorAll("input");
			for(let i=0; i<ipts.length; i++){
				if(ipts[i].checked){
					return parseInt(ipts[i].value);
				}
			}
		}

		// 選択するラジオボタンを設定する
		set value(val){
			let ipts = this.shadowRoot.querySelectorAll("input");
			this.setAttribute("value", val);
			for(let i=0; i<ipts.length; i++){
				if(parseInt(ipts[i].value) == val){
					ipts[i].checked = true;
				}
			}
		}
	}

	// テンプレートを追加
	QuestionBox.template = document.currentScript.ownerDocument.getElementById("QuestionBox");
	customElements.define('question-box', QuestionBox);
})();
</script>

<template id="CategoryBox">
	<div class="title">
		<slot name="title"></slot>
	</div>
	<div class="questions">
		<slot></slot>
	</div>
	<style>
	.questions {
		display: table;
	}
	</style>
</template>
<script>
(function(){
	class QuestionCategory extends HTMLElement {
		// write once
		// customElement.define()の前にセットする
		static set template(val){
			if(!this.tmpl){ this.tmpl = val; }
		}

		// cloneを返す
		static get template(){
			return document.importNode(this.tmpl.content, true);;
		}

		constructor(){
			super();
			const sr = this.attachShadow({mode: "open"});
			sr.appendChild(QuestionCategory.template);
		}

		// 各質問の点数を合計する
		get total(){
			// slotに入っている要素はshadowRoot配下ではない!
			const qs = this.querySelectorAll("question-box");
			let t = 0;
			for(let i=0; i<qs.length; i++){
				t += qs[i].value;
			}
			return t;
		}

		// カテゴリ名を返す
		// <slot name="title">に設定された要素の文字列
		get catName(){
			return this.querySelector("[slot='title']").textContent.trim();
		}
	}

	// テンプレートを追加
	QuestionCategory.template = document.currentScript.ownerDocument.getElementById("CategoryBox");
	customElements.define("question-category", QuestionCategory);
})();
</script>

<template id="AllQuestions">
	<slot></slot>
	<div>
		<button id="TotalButton">集計</button>
	</div>
	<div id="TotalBox">
		<table>
			<thead>
				<tr>
					<th>カテゴリ</th>
					<th>合計</th>
				</tr>
			</thead>
			<tbody>
			</tbody>
		</table>
	</div>
	<style>
	#TotalBox {
		display: none;
	}
	</style>
</template>

<script>
(function(){
	class AllQuestions extends HTMLElement {
		// write once
		// customElement.define()の前にセットする
		static set template(val){
			if(!this.tmpl){ this.tmpl = val; }
		}

		// cloneを返す
		static get template(){
			return document.importNode(this.tmpl.content, true);;
		}

		constructor(){
			super();
			const sr = this.attachShadow({mode: "open"});
			sr.appendChild(AllQuestions.template);
		}

		connectedCallback(){
			// 作成時の結果テーブル作成
			this.makeResultTable();

			// 集計ボタンのイベントハンドラ登録
			const btn = this.shadowRoot.querySelector("#TotalButton");
			btn.addEventListener('click', function(){
				this.setResultTotal();
				this.shadowRoot.querySelector("#TotalBox").style.display = "block";
			}.bind(this));

			// 子要素追加時に結果テーブル再構築のため監視
			const mo = new MutationObserver(function(){this.makeResultTable()}.bind(this));
			mo.observe(this, {childList: true});
		}

		// 結果テーブル作成
		// 項目ごとに行を追加するが値は入らない
		makeResultTable(){
			const cats = this.querySelectorAll("question-category");
			let str = "";
			for(let i=0; i<cats.length; i++){
				str += `<tr><td>${cats[i].catName}</td><td id="val_${cats[i].catName}"></td></tr>`;
			}

			str += `<tr><td>合計</td><td id="val_total"></td></tr>`;
			this.shadowRoot.querySelector("tbody").innerHTML = str;
		}

		// 結果テーブルに集計を記入
		setResultTotal(){
			const cats = this.querySelectorAll("question-category");
			let t = 0;
			for(let i=0; i<cats.length; i++){
				const out = this.shadowRoot.querySelector(`#val_${cats[i].catName}`);
				out.textContent = cats[i].total;
				t += cats[i].total;
			}

			const total = this.shadowRoot.querySelector("#val_total");
			total.textContent = t;
		}
	}

	// テンプレートを追加
	AllQuestions.template = document.currentScript.ownerDocument.getElementById("AllQuestions");
	customElements.define("all-questions", AllQuestions);
})();
</script>

これを使うHTML部分は次の通りです。
非常にスッキリしていて良い感じです。
<script>内はJSによるカスタムタグの動的追加のテストのためです。

<all-questions>
	<question-category>
		<p slot="title">カテゴリ1</p>
		<question-box value="2"><p>質問1です</p></question-box>
		<question-box value="0"><p>質問2です</p></question-box>
	</question-category>
	<question-category>
		<p slot="title">カテゴリ2</p>
		<question-box value="2"><p>質問1です</p></question-box>
		<question-box value="2"><p>質問2です</p></question-box>
	</question-category>
</all-questions>
<script>
const qc = document.createElement("question-category");
const p = document.createElement("p");
p.slot = "title";
p.textContent = "カテゴリ3";
qc.appendChild(p);

for(let i=0; i<3; i++){
	const q = document.createElement('question-box');
	q.value = 1;
	q.innerHTML = `<p>質問${i}です。</p>`;
	qc.appendChild(q);
}

document.querySelector("all-questions").appendChild(qc);
</script>

説明

このカスタムタグライブラリは3つのカスタムタグを定義しています。

タグ概要
question-box 一つの質問を表します。
質問の文章とラジオボタン5個を持ちます。
質問の文章は<slot>になっていて子要素として記述できます。
また、プロパティvalueを持っており、valueを設定すると設定した値のラジオボタンが選択されますし、valueを取得すると選択されている値を取得できます。
question-category 一つのカテゴリに含まれる1以上の質問とカテゴリ名を持ちます。
カテゴリ名は「slot="title"」を指定する名前付きスロットです。
質問は<question-box>を子要素として記述します。こちらは名前なしスロットなのでスロットの指定は不要です。
readonlyのプロパティとして次の2つを持ちます。
catNameはカテゴリ名を返します。カテゴリ名は「slot="title"」の子要素のテキストコンテンツをtrim()したものです。
totalは子要素の<question-box>のvalueの合計です。
all-questions 1以上の<question-category>を持ちます。
「集計」ボタンをクリックすると集計表を下部に表示します。

ほとんどの部分は今までの記事で説明してきたことなので、ソースの細かい部分は説明しません。
これを作るにあたって悩んだ部分、今までの説明(他のサイトをで説明されていたのとは違うことをしなければならなかった部分)を説明します。

シャドウルートの構築

これまでの記事でシャドウルートの構築、つまり次の様なコードはconnectedCallback()でやっていました。

const sr = this.attachShadow({mode: "open"});
const clone = document.importNode(テンプレート.content, true);
sr.appendChild(clone);

これはおそらく間違いで、constructorでやるべきです。
他の点もなのですが、HTMLにタグを記述した時はどちらにあっても動作します。しかし、JSで動的生成しようとした場合、問題になります。
私はJSでタグを生成する場合、子要素を全部入れてから親要素をdocumentのすでにあるタグに追加する、という手順にしています。順番を逆にする(親要素を生成し、documentに挿入してから子要素を追加する)と子要素の追加の度に再レンダリングになるのが良くないと思っているからです。
次の様な感じですね。

const h = document.createElement("hoge-tag");
const f = document.createElement("fuga-tag");
h.appendChild(f);
document.getElementById("ToAdd").appendChild(h);

この様にすることを考えた場合、シャドウルートの構築はconstructorでないとまずいことになります。
上のコードの3行目、「h.appendChild(f)」の時点ではdocumentへの挿入が行われていないのでconstructorでシャドウルートを構築しておかないと何もない状態で子要素の挿入をしてしまうことになります。

と、思ったけど実は問題ないのかもしれない。
シャドウルートの<slot>は子要素への参照しか持たないので、documentへの挿入時点まで遅延できるかも。
ただ、documentへの挿入前にシャドウルート以下の要素の初期化が必要とかのことを考えるとコンストラクタでやるのが安心な気もする。

テンプレートの取得

これが今回一番困った問題です。
今までconnectedCallbackで次の様にしていました。

const sr = this.attachShadow({mode: "open"});
const doc = document.currentScript.ownerDocument;
const tmpl = doc.getElementsById("some-template");
const clone = document.importNode(tmpl.content, true);
sr.appendChild(clone);

HTMLにタグを記述した時はこれで上手く行きます。
JSで動的に追加しようとするとエラーになります。

まずいのは「currentScript」です。
HTMLインポートを解決する時点ではcurrentScriptはテンプレートのHTMLファイル内の<script>です。
しかし、JSで動的追加しようとする場合、currentScriptは動的追加の処理を書いている<script>です。

具体的にいうと、この記事のファイル「Sample7.php」はアンケート用カスタムタグライブラリ「Questions/Questions.html」をimportしています。
ソースを見てもらうとわかりますがページの先頭にある動作確認用のアンケートの「カテゴリ3」はJSで動的に追加しています。
この動的追加時のcurrentScript.ownerDocumentは「Sample7.php」になってしまいます。
動的追加のためにカスタムタグのconstructorやconnectedCallbackが呼ばれた時のcurrentScript.ownerDocumentも「Sample7.php」になるためテンプレートの取得に失敗し、エラーになります。

これを解決しているのがソースコード中の次の部分です。

class AllQuestions extends HTMLElement {
	static set template(val){
		if(!this.tmpl){ this.tmpl = val; }
	}

	static get template(){
		return document.importNode(this.tmpl.content, true);;
	}

	// まだまだ続く...
}

// テンプレートを追加
AllQuestions.template = document.currentScript.ownerDocument.getElementById("AllQuestions");
customElements.define("all-questions", AllQuestions);

今回必要なのはJavaで言うクラス変数とそれに対するstatic initializerです。
クラス変数というかクラスのプロパティはstaticなgetter/setterで実現できます。なので、テンプレート用のgetter/setterを実装します。

setterはwrite onceであって欲しいので、コードの様にthis.tmplが値を持っていない時だけセットする様にします。

getterはクローンを返す様にしておけば手間が省けるのでdocument.importNode()を呼んで、その戻り値を返す様にしておきます。

templateへのテンプレート登録はclass定義が終わったら「customElements.define()」の前にやっておきます。
こうしておけば、カスタムタグが使われる前に必ずテンプレートの登録が完了していることになるので正しく動作します。

時点では次の様なコードを「よく無いと思うが仕方ない」として書いていました。

class AllQuestions extends HTMLElement { ... }

// 非常に非合法感漂う方法でテンプレートを追加
AllQuestions.template = document.currentScript.ownerDocument.getElementById("AllQuestions");
customElements.define("all-questions", AllQuestions);

つまり、JavaScriptではクラスもオブジェクトなのでクラスにプロパティを追加するという方法です。

多分、get template()はread onlyであるべきという先入観があってset template()を用意すれば良いということに気づかなかった様です。
getter/setterを用意すればクラスを使う側から見れば同じで、クラスを用意する側は構文に従った方法でできます。その上、setterはこのサンプルの様にwrite onceにできるのでgetter/setterを用意するのが適切です。

子要素追加の監視

カスタムタグのメソッドには次のライフサイクルコールバックがあります(MDNからの引用です)。

メソッド名概要
connectedCallback() 要素がドキュメントに挿入されたときに呼び出される、シャドウツリーへの呼び出し
disconnectedCallback() 要素がドキュメントから削除されたときに呼び出されます
attributeChangedCallback() 要素の属性が変更、追加、削除、または置換されたときに呼び出されます。 監視対象の属性に対してのみ呼び出されます。
adoptedCallback() 要素が新しい文書に採用されたときに呼び出されます

この中に「子要素が追加、削除、置換された時に呼び出される」と言うコールバックはありません。
なので、子要素に関する操作があった時に何かする必要がある場合はMutationObserverを使って監視します。
今回のサンプルだと<all-questions>に要素が追加されたら結果テーブルを作り直しています。この結果テーブルくらいなら集計ごとに作り直しても大したことはないのですが、表の形式は子要素の<question-category>で決まっているのでごく僅かな負荷軽減になると言う訳です。
実際のコードは次の部分です。

// 子要素追加時に結果テーブル再構築のため監視
const mo = new MutationObserver(function(){this.makeResultTable()}.bind(this));
mo.observe(this, {childList: true});

これで子要素に変更があった時にmakeResultTable()を読んで結果テーブルを作り直せます。
thisをbindしておかないと上手くゆかないのはクロージャでthisを使うときにはいつものことです。
ちなみに次のコードは上手くゆきません。

// 子要素追加時に結果テーブル再構築のため監視
const mo = new MutationObserver(this.makeResultTable);
mo.observe(this, {childList: true});

まとめ

<template>を使った、独自の機能を実装する必要のあるカスタムタグを作るときの注意点についてのまとめです。

シャドウルートの構築はconstructorでやったほうが良さそうです。
connectedCallbackでも上手く行きそうに思えますが、constructorの方が安心だと思います。

テンプレート用にstaticなgetter/setterを作ります。
setterはwrite onceにしておくと良いでしょう。
getterはテンプレートのcloneを返す様にしておくと使う側が楽です。

カスタムタグの子要素の変更はライフサイクルコールバックでは監視できません。
MutationObserverを使いましょう。