WebComponents でコンテキスト的なことをしたい
WebComponents で作ったコンポーネントで現在のコンテキストを受け取りたいことがあります
コンテキストというのは React のコンテキストみたいなもので 親で持っているデータのことです

WebComponents は DOM なので親をたどるのが簡単です
connectedCallback で接続されたときに

const shadow_root = this.getRootNode()
const host = shadow_root.host

のようにすれば ShadowRoot や その ShadowRoot を保持するホスト要素にアクセスできます
これを繰り返し期待のコンテキストを保持する要素を見つけて 見つかったらそこからデータを受け取るといいです

イメージ

const wmap = new WeakMap()

// コンテキストを識別するためのオブジェクトを作る
// WeakMap のキーにする
// 使わないけどデバッグ時のわかりやすさのために name を持たせておく
const createCtx = (name) => {
return { name }
}

// 要素にコンテキストデータをセットする
// CustomElements のコンストラクタで実行を想定
const setCtxData = (elem, ctx, data) => {
if (!wmap.has(elem)) {
wmap.set(elem, new WeakMap())
}
const ctxs = wmap.get(elem)
if (!ctxs.has(ctx)) {
ctxs.set(ctx, { value: null })
}
const container = ctxs.get(ctx)
container.value = data
}

// 一致するコンテキストを持つ祖先を探して
// 見つかればコンテキストのデータを取得する
const findCtxData = (elem, ctx) => {
const host = elem.getRootNode().host
if (!host) return null
const container = wmap.get(elem)?.get(ctx)
if (container) return container.value
return findCtxData(host)
}

これで良さそうかなと思ったのですが slot を使う場合に問題がありました

<foo-elem>
<bar-elem></bar-elem>
</foo-elem>

こういう構造の場合 bar-elem は foo-elem の ShadowDOM の中のどこかで slot を使って表示されます
この場合に foo-elem がコンテキストデータを持っていれば bar-elem は foo-elem からコンテキストデータを受け取りたいです
しかし getRootNode を使ってたどると bar-elem が最初に見るホスト要素は foo-elem と bar-elem の両方のホストです
foo-elem のように同じ ShadowDOM に属する祖先要素はスキップされてしまいます

これに対処するいい方法はないかなと探してると Lit ではイベントを使って親にリクエストを送り 親がコールバック関数を使って値を返す方法を使ってるようでした
その方針にしてみます

const setCtxData = (elem, ctx, data) => {
elem.addEventListener("ctx", event => {
if (event.detail.ctx === ctx) {
event.stopImmediatePropagation()
event.detail.callback(data)
}
})
}

const findCtxData = (elem, ctx) => {
let data
const event = new CustomEvent("ctx", {
detail: {
ctx,
callback: (d) => {
data = d
},
},
bubbles: true,
composed: true,
})
this.dispatchEvent(event)
return data
}

これで良さそうですが 親でコンテキストを更新したことに気づけません
コールバックで渡す値を EventTarget オブジェクトにして 受け取ったらそこにリスナをつけるなどでしょうか

class Context extends EventTarget {
_value = null
get value() { return this._value }
set value(v) {
this._value = v
this.dispatchEvent(new Event("change"))
}
constructor(value) {
super()
this.value = value
}
}

const setCtxData = (elem, ctx, data) => {
const ctx_data = new ContextData(data)
elem.addEventListener("ctx", event => {
if (event.detail.ctx === ctx) {
event.stopImmediatePropagation()
event.detail.callback(ctx_data)
}
})
return ctx_data
}

const findCtxData = (elem, ctx) => {
let ctx_data
const event = new CustomEvent("ctx", {
detail: {
ctx,
callback: (d) => {
ctx_data = d
},
},
bubbles: true,
composed: true,
})
elem.dispatchEvent(event)
return ctx_data
}

const subscribeExample = (elem, ctx) => {
const ctx_data = findCtxData(elem, ctx)
ctx_data.addEventListener("change", () => {
console.log("changed", ctx_data.value)
})
}

動く例
https://nexpr.gitlab.io/public-pages/webcomponents-context/example.html

foo-elem がコンテキストデータとして { num: 1 } を持っています
子孫の bar-elem は foo-elem のコンテキストデータを受け取り num を表示します
ボタンを押すと num の数値が 1 ずつ増えて bar-elem に反映されます
FAST ってライブラリ見つけた
Microsoft の Fluent UI ライブラリを見てると WebComponents 版もあった
https://github.com/microsoft/fluentui/blob/master/packages/web-components/README.md
https://docs.microsoft.com/en-us/fluent-ui/web-components/

説明を見ると中では FAST (@microsoft/fast-foundation) というものを使ってるらしい
https://www.fast.design/

Fluent UI のコンポーネントのソースコードを見ても @microsoft/fast-foundation からインポートしたクラスを継承していたりでコンポーネントの機能的には FAST の方がメインになっていて Fluent UI 的な見た目だけ適用してそう
FAST は URL 的にもデザインと書かれているし ドキュメント中にはデザインシステムというワードもあるし WebComponents で標準的なコンポーネントにデザインを簡単に適用できるライブラリってことでいいのかな

FAST 自体が提供しているコンポーネントのプレビューはここで見れて 結構種類が色々ある
https://explore.fast.design/
client router とコンポーネント
ルーターをコンポーネントで作るとき こんな構造になってるのが多いと思う

<app-router>
<app-route route="/">
<app-route route="/foo">
<app-route route="/bar">

ルート定義それぞれがコンポーネントになってて DOM に存在する
1 つ目のルートがアクティブなら

<app-router>
<app-route route="/">
<page-header>
<page-content>
<page-footer>
<app-route route="/foo">
<app-route route="/bar">

3 つ目のルートがアクティブなら

<app-router>
<app-route route="/">
<app-route route="/foo">
<app-route route="/bar">
<page-header>
<page-content>
<page-footer>

アクティブなルート定義コンポーネントの子要素としてページの DOM が作られる
これだと グローバルなヘッダーなど共通なパーツもページ(ルート)ごとに別になる
同じパーツなら見た目上は変わらないけど 再作成してるし無駄も多い
ヘッダーに検索用 input とかあったら再作成でリセットされるし

ShadowDOM を使って共通の場所に作るか app-route を DOM として作らないほうがいいかも

<app-router>
<app-route>
<app-route>
<app-route>
#shadow
<page-header>
<page-content>
<page-footer>
<app-router>
<page-header>
<page-content>
<page-footer>

page-header や page-footer が同じ要素なら使いまわして再作成しないようにする

app-route を作らないなら app-router のプロパティやメソッド呼び出しで DOM ではわからない形でルートを定義する

app_router.routes = [
"/": {},
"/foo": {},
"/bar": {}
]



基本的にはルート定義コンポーネントが直接ページ内の要素を管理しないので ルート定義コンポーネントではルートに対応するページコンポーネントの要素を作って そのコンポーネントがページ要素を管理する構造になるはず

<app-router>
<app-route route="/">
<page-top>
#shadow
<page-header>
<page-content>
<page-footer>
<app-route route="/foo">
<app-route route="/bar">

こうなるとアクティブなときに app-route の子要素に配置しないようにしても

<app-router>
<app-route route="/">
<app-route route="/foo">
<app-route route="/bar">
#shadow
<page-top>

とか

<app-router>
<app-route route="/">
<app-route route="/foo">
<app-route route="/bar">
#shadow
<page-foo>

になって結局ヘッダーなどの要素は共有できてない
ページのコンポーネントじゃなくて共通の部分とページごとの可変部分の親子コンポーネントにすれば良さそう

<app-router>
<default-layout>
<list-content>
<app-router>
<default-layout>
<detail-content>
<app-router>
<special-layout>
<account-content>

*-layout の ShadowDOM 内に *-content を slot で配置

<app-router>
<default-layout>
<list-content>
#shadow
<page-header>
<slot>
<list-content>
<page-footer>

同じ default-layout を使うルート間の移動なら default-layout 要素はそのままなので page-header/page-footer はそのまま維持される

だけど list-content のところで user-list-content と article-list-content があった場合
どっちもリスト系なのでリスト系共通の要素があるけど コンポーネントが分かれるのでここは DOM が異なってしまう
page-header みたいに 同じなら DOM を使いまわしたいなら list-content のところも共通と個別のところを分けて親子構造にする必要が出てくる

<app-router>
<default-layout>
<list-common>
<list-user-content>
#shadow
<page-header>
<slot>
<list-common>
<list-user-content>
#shadow
<list-header>
<slot>
<list-user-content>
<page-footer>

これも大変で面倒だし 実際の DOM にコンポーネントが挟まらない React とか lit-html とかのほうが良いのかも
それ以前に 別に重いとか見た目崩れるとかないなら 無理に DOM の使い回しとかせずページのルートコンポーネントから置き換わっても別に困らないとも思う
1 つの機能だけのツールだと全部のデータを最上位コンポーネントで管理することになる
ページが 1 つで機能も 1 つだけだとコンポーネントに分けても結局データは全部最上位になる
あるデータを管理(ローカルに登録して表示と編集)するツール

機能を分けて 3 つのコンポーネント

● ルートコンポーネント
  (1) リスト表示
  (2) 選択中の項目のデータを編集
  (3) リストの全部を専用ライブラリでグラフィカル表示・選択中はマウス操作で編集可能

編集中データなどは (2) の編集用コンポーネントだけで持ちたいけど (3) のコンポーネントでも編集できる
ルートコンポーネントで編集中も持つと 保存ボタンやリセットボタンが (2) にあるけど 押したことをルートコンポーネントに伝えるだけ
ルートコンポーネントでデータのチェックや保存などが必要
ほとんどがルートコンポーネントの処理になって複雑になってくる
一応ページ全部で 1000 行は超える程度のコード量なので そのほとんどをルートコンポーネントに押し込むのはなんかイヤ

それに (1) ~ (3) はほとんどルートコンポーネントから受け取ったデータの表示と押されたボタンを親に伝えるだけになってる
WebComponents らしい機能は持ってないし WebComponents (lit-element) で書くと長くなるだけ
こういうタイプなら React のほうが楽にかけそう
HTML を使って CustomElement2
前回の続き
前は JavaScript だけ扱いを特別にしたけど HTML ファイル中にあったほうがいいかなと思ったので HTML ファイルに全部書く

使う側は一緒で foo-bar.js を読み込んで foo-bar タグを書いておく

[page.html]
<script type="module" src="foo-bar.js"></script>

<foo-bar></foo-bar>

[foo-bar.js]
import { importComponent } from "./base.js"

importComponent("foo-bar.html").then(component => {
customElements.define("foo-bar", component)
})

コンポーネントを import して customElements.define で定義

[base.js]
export const importComponent = async (htmlpath) => {
const res = await fetch(htmlpath)
const html = await res.text()
const doc = new DOMParser().parseFromString(html, "text/html")
const fragment = document.createDocumentFragment()
const scripts = [...doc.querySelectorAll("script")]
scripts.forEach(x => x.remove())
fragment.append(...doc.head.childNodes, ...doc.body.childNodes)

const module = {}
class Base extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: "open" })
this.shadowRoot.append(this.constructor.fragment.cloneNode(true))
this.elements = Object.fromEntries(
Array.from(
this.shadowRoot.querySelectorAll("[id]"),
elem => [elem.id, elem]
)
)
}
}
Base.fragment = fragment

for(const script of scripts) {
const source = script.textContent
const fn = Function("module", "Base", source)
fn(module, Base)
}

return module.exports
}

importComponent は HTML を fetch してパースしていろいろ
script を含んだコンポーネントの HTML

[foo-bar.html]
<style>
.foo { font-size: 20px; }
.bar { color: red; }
.active { font-weight: bold; }
</style>

<div>
<div class="foo">FOO</div>
<div class="bar">BAR</div>
<button id="btn">Button</button>
</div>

<script>

module.exports = class extends Base {
connectedCallback() {
this.elements.btn.addEventListener("click", (eve) => {
eve.target.classList.toggle("active")
})
}
}

</script>

script タグ中のエクスポートは Node.js 形式で module.exports に代入
Base を継承したクラスをエクスポートする
HTML を使って CustomElement
ふと HTMLImports を思い出したのでこんなことしてみた

[base.js]
const initCustomElement = async (template) => {
const res = await fetch(template)
const html = await res.text()
const doc = new DOMParser().parseFromString(html, "text/html")
const fragment = document.createDocumentFragment()
fragment.append(...doc.head.childNodes, ...doc.body.childNodes)
return fragment
}

export class Base extends HTMLElement {
constructor() {
super()
const C = this.constructor
if (!C.ready) {
C.ready = initCustomElement(C.template)
}

this.attachShadow({ mode: "open" })
C.ready.then((fragment) => {
this.shadowRoot.append(fragment.cloneNode(true))
})
}
}

[foo-bar.js]
import { Base } from "./base.js"

customElements.define("foo-bar", class extends Base {
static template = "foo-bar.html"
})

[foo-bar.html]
<style>
.foo { font-size: 20px; }
.bar { color: red; }
</style>

<div>
<div class="foo">FOO</div>
<div class="bar">BAR</div>
</div>

innerHTML やスタイルは別の HTML ファイルに書く
script タグは HTML に含めず import される JavaScript ファイル側に書いてる
CustomElement の constructor の処理で HTML を fetch して ShadowDOM に append

表示するためのページ

[page.html]
<script type="module" src="foo-bar.js"></script>

<foo-bar></foo-bar>
属性も slot みたいに使えたらいいのに
custom element の子要素は slot を使って shadow dom 内に配置できます
その場合 custom element の子要素を変更すると即時に shadow dom 内の要素に反映されます

これが便利なのですが 自由にタグを書けるのでテキストだけ受け取りたいときには向いていません
そういうときには custom element の属性を使いたいのですが そうなると slot みたいな便利機能はありません

自分で JavaScript の処理で shadow dom 内の要素に属性の文字列を入れる必要があります
さらに変更されたときに shadow dom 内にも反映したいなら 属性の変更を監視して 変更時に自分で shadow dom 内の要素の更新が必要です

Framework なしの WebComponents を使うと この辺りが面倒なのですよね
slot のように自動で反映してくれる仕組みがあるといいのですけど
Document の adoptedStyleSheets は全体に反映して欲しい
ShadowRoot のならその ShadowDOM の中で良いけど Document に設定したならすべての ShadowRoot にも反映して欲しい
ドキュメントなんだからコンポーネントも含めてグローバルでいいと思う
WebComponents 使ってると body 直下にルートコンポーネントを置いて ShadowRoot 外の要素なんてないことが普通
それだと Document に対する adoptedStyleSheets なんていらないし

一応こういう風に document.adoptedStyleSheets を継承させるベースクラスを作っておくことはできるけど毎回は面倒

class CustomHTMLElement extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: "open" }).adoptedStyleSheets = [
...document.adoptedStyleSheets,
this.cssstylesheet,
]
}

static css = ``

get cssstylesheet() {
if (!this.constructor.cssstylesheet) {
const cssss = new CSSStyleSheet()
cssss.replaceSync(this.constructor.css)
this.constructor.cssstylesheet = cssss
}
return this.constructor.cssstylesheet
}
}

使用例⇩

const cssss = new CSSStyleSheet()
cssss.replaceSync(`
button { background: black; color: white; border: 0; border-radius; 5px; padding: 3px 15px; }
`)
document.adoptedStyleSheets = [cssss]

customElements.define("ex-ample", class extends CustomHTMLElement {
static css = "button:hover { background: darkgray; }"

constructor() {
super()
this.shadowRoot.innerHTML = `<button>in component</button>`
}
})

document.body.innerHTML = `
<button>in document</button>
<ex-ample></ex-ample>
`
リスナ設定時の bind が面倒なので
this.querySelector(".elem").addEventListener("click", this.onClick.bind(this))

// とか

const tpl = html`<button on-click="${this.onClick.bind(this)}"></button>`

毎回 bind 書くのってすごく面倒
なので自動でするようにしました

class ExampleElement extends HTMLElement {
constructor() {
super()
for (const [key, fn] of Object.entries(this.listeners)) {
this[key] = fn.bind(this)
}
}

get listeners() {
return {
onClick() { console.log(this) }
}
}

example() {
this.querySelector("button").addEventListener("click", this.onClick)
}
}

listeners の getter でリスナ関数を返すようにする
コンストラクタで listeners で取得できるの全てに this を bind してプロパティに設定する
example メソッドのように 使うとき bind が不要になる
コンストラクタの処理は共通のベースクラスにでも書いておくといい感じ

コンストラクタで何もしない版

class ExampleElement extends HTMLElement {
get listeners() {
return {
onClick: () => { console.log(this) }
}
}

example() {
this.querySelector("button").addEventListener("click", this.listeners.onClick)
}
}

listeners を挟むけどわかりやすいといえばわかりやすい
アロー関数にしたらなにもしなくても this がクラスのインスンタンスに固定される

this.listeners = this.listeners

しておくほうがリスナ関数が同じ値になっていいかも
ユーザスクリプトと webcomponent
ページに <ex-elem></ex-elem> みたいなタグを入れておいて
ブラウザ拡張機能でカスタム要素を define しておけば 拡張機能入れてる人だけ特別な機能が使えるとかあっておもしろそう

自分のページだけなら 直接拡張機能でやりたいことすればいいけど 拡張機能は汎用的なもので個人サイトに依存させないで ページ作る側が拡張機能使ってる人向けの特別な機能を用意するとか

ただ拡張機能権限の機能使うと悪用される可能性もあるし 拡張機能ならではのことは難しいかな

WebComponent でリスナつけるときは ShadowRoot に
<!doctype html>

<template id=t>
<div>
<button id=a>a</button>
</div>
<div>
<button id=b>b</button>
</div>
</template>

<script>
customElements.define("ele-ment", class extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: "open" }).append(
document.querySelector("#t").content.cloneNode(true)
)
this.addEventListener("click", eve => {
console.log("this listener", eve.target)
})
this.shadowRoot.addEventListener("click", eve => {
console.log("shadow listener", eve.target)
})
}
})
</script>

<ele-ment></ele-ment>

ele-ment タグの this と shadowRoot にクリックつける
ele-ment 内のボタンを押すと

shadow listener  <button id=​"b">​b​</button>​
this listener <ele-ment>​…​</ele-ment>​

shadowRoot の方が内側にあるので先にキャプチャする
shadowDOM の内側だから shadow listener の方では button 要素が target
shadowDOM の外側になる this では自身がイベントの target
shadowDOM 内からでも on*** 属性中の要素の参照はグローバルのものになる
メインのドキュメントと shadowRoot の中に id "foo" をもつ要素を置く
shadowRoot の中のボタンの onclick 属性から "foo" を参照すると
同じ shadowRoot 内ではなくてメインのドキュメントの foo が参照される


[main.html]
<!doctype html>

<link rel="import" href="component.html">

<input id="foo" value="1">
<compo-nent></compo-nent>

[component.html]
<template>
<input id="foo" value="2">
<button onclick="alert(foo.value)">click me</button>
</template>
<script>
{
const doc = document.currentScript.ownerDocument
customElements.define("compo-nent", class extends HTMLElement {
constructor() {
super()
const template = doc.querySelector("template")
this.attachShadow({ mode: "open" }).append(template.content.cloneNode(true))
}
})
}
</script>

ボタンを押すと
1

main.html で input のほうが先にあるからというわけでもなく compo-nent を先に持ってきても一緒
input の id を "bar" に変えても 2 にはならず foo が見つからないエラー

※ HTML Imports が削除されたので今ではこのコードは動かない
HTMLImports の currentScript
[main.html]
<!doctype html>

<link rel="import" href="component.html">

<compo-nent></compo-nent>

<script>
console.log(1)
</script>

<compo-nent></compo-nent>

[component.html]
<template>
<p>p</p>
</template>
<script>
customElements.define("compo-nent", class extends HTMLElement {
constructor() {
console.log("start")
super()
const t = document.currentScript.ownerDocument.querySelector("template")
this.attachShadow({ mode: "open" }).append(t.content.cloneNode(true))
console.log("end")
}
})
</script>

main.html のページを開くと

start
end
1
start
Uncaught TypeError: Cannot read property 'ownerDocument' of null

script タグが実行されると document.currentScript が null になる
constructor のような後から実行されるところで取得しないほうがいい
script タグ実行時に変数に入れておくと大丈夫

<template>
<p>p</p>
</template>
<script>
{
const doc = document.currentScript.ownerDocument
customElements.define("compo-nent", class extends HTMLElement {
constructor() {
console.log("start")
super()
const t = doc.querySelector("template")
this.attachShadow({ mode: "open" }).append(t.content.cloneNode(true))
console.log("end")
}
})
}
</script>
CustomElement+ShadowDOM で input 系を継承できない
WebComponent を作る時 input 系を継承できない
仕様なのか Chrome (64) が未実装なのかは知らない
「extends HTMLInputElement」 で定義はできるけど要素作る時の super() で親コンストラクタ呼び出すときにエラーになる

仕方ないから HTMLElement を継承して shadowRoot の内側に input などを置いて継承じゃなくラッパーにする
でもこれだと value などのプロパティが input に対応してない

Proxy を使って全部のプロパティを内側の要素にプロキシさせられれば簡単だけど CustomElement では使えない
constructor で Proxy オブジェクトを return するとエラー

getter/setter を全部書いていくのは大変だからプロパティリストだけ指定して自動で生成するようにした


[main.html]
<!doctype html>
<link rel="import" href="a-b.html"/>

<a-b id="ab"></a-b>

<script>
document.querySelector("#ab").value = "text"
</script>

[a-b.html]
<template id="tpl">
<textarea id="main"></textarea>
</template>

<script>
customElements.define("a-b", class extends HTMLElement {
constructor(){
super()
const tpl = document.currentScript.ownerDocument.querySelector("#tpl").content
this.attachShadow({mode: "open"}).append(tpl.cloneNode(true))
const target = this.shadowRoot.querySelector("#main")
bridge(this, target, ["value", "disabled"])
}
})

function bridge(from, to, properties){
for(const key of properties){
Object.defineProperty(from, key, {
get(){
return to[key]
},
set(value){
to[key] = value
}
})
}
}
</script>

textarea を継承(ラップ)した a-b タグ
value と disabled は中の textarea を変更できる