Web ComponentsのShadow DOM内でBootstrap5を使う

# 概要

最近、自分が管理してる(ほぼ静的な)Webサイトのリニューアルを行う際にWeb Componentsを使ってみようと思い、いろいろ試したのでその中でのメモです。そこで、互換性の問題で、Web Components中で隔離してBootstrapを使ってみたいと思い、Web Components中のShadow DOMでBootstrapを使うことを試してみました。

ちょっとコツが必要ですが、意外と使えそうなのでメモしておきます。

## 対象 / 動作環境

この記事はWeb Componentsのカスタム要素の実装について最低限理解されている方を対象に書かれています。カスタム要素については[MDN カスタム要素の使用](https://developer.mozilla.org/ja/docs/Web/API/Web_components/Using_custom_elements)などを参照して下さい。なお、動作確認は以下の環境で行いました。

- Firefox 134.0.1 / Edge 132.0.2957.115 + Windows 10 Pro 22H2
- Bootstrap 5.3.3(ESM) + Popper 2.11.8(ESM)
- VSCode 1.96.4 + Live Server 5.7.9

## CSS

Shadow Rootに追加するテンプレート中で `<link>` タグで `bootstrap.min.css` を読み込みますが、この時、BootstrapのCSSカスタムプロパティ(CSS変数)が `:root` および `[data-bs-theme=light]` に対して設定されているので、値が反映されません。解決方法は2つあります。

1. bootstrap.cssの `:root` のセクションに `:host` を追加する
2. Shadow Rootに追加する一番外側に `<div>` などを追加し、`data-bs-theme="light"` をセットする(セレクタ `[data-bs-theme=light]` にマッチさせる)

どちらでも解決はできるようですが、1.は元のCSSに手を加える必要がある、2.はBootstrapのダークテーマが使えない、という問題があるので、状況により使い分けます。

## Components

Bootstrap Componentsを使う時も少し工夫が必要です。通常(Light DOM)の時のように自動でイベントを設定してくれないのでJavaScriptで自分で初期化する必要があります。具体的には、Shadow Root内の要素に対してコンポーネントおよびクリックイベントを設定します。

例えば、Dropdownのサンプルの場合以下のような実装になります。

```html
<!-- https://getbootstrap.com/docs/5.3/components/dropdowns/#single-button -->
<div class="dropdown">
  <button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
    Dropdown button
  </button>
  <ul class="dropdown-menu">
    ...
  </ul>
</div>
```

```javascript
// Dropdown
const el = this.shadowRoot.querySelector(".dropdown button");
const dropdown = new Dropdown(el);
el.addEventListener("click", () => dropdown.toggle());
```

Bootstrapサイトの例と変わりませんが、`.dropdown button` を探すときに `this.shadowRoot.querySelector()` を使っています。これはカスタム要素内で使用しているので、`this` はカスタム要素自身を指しています。

ここでは、CollapseおよびDropdownについてのみ確認しました。ですので、他のComponentも全て使えるかどうかはわかりません…(Bootstrap.jsのソースを眺めているとshadowRootが使われている部分があったので、もっと簡単な方法があるかもしれません。調べていませんが…)

## 実装
### ソース

CSSとComponentsを含めた実装が以下です。

```html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>WebComponents with Bootstrap 5.3</title>
  <script type="importmap">
  {
    "imports": {
      "bootstrap": "https://unpkg.com/bootstrap@5.3.3/dist/js/bootstrap.esm.min.js",
      "@popperjs/core": "https://unpkg.com/@popperjs/core@2.11.8/dist/esm/popper.js"
    }
  }
  </script>
  <template id="bs5components">
    <div data-bs-theme="light">
      <link rel="stylesheet" href="https://unpkg.com/bootstrap@5.3.3/dist/css/bootstrap.min.css">

      <!-- https://getbootstrap.com/docs/5.3/components/collapse/#example -->
      <p class="d-inline-flex gap-1">
        <a class="btn btn-primary" data-bs-toggle="collapse" href="#collapseExample" role="button" aria-expanded="false" aria-controls="collapseExample">
          Link with href
        </a>
        <button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#collapseExample" aria-expanded="false" aria-controls="collapseExample">
          Button with data-bs-target
        </button>
      </p>
      <div class="collapse" id="collapseExample">
        <div class="card card-body">
          Some placeholder content for the collapse component. This panel is hidden by default but revealed when the user activates the relevant trigger.
        </div>
      </div>

      <!-- https://getbootstrap.com/docs/5.3/components/dropdowns/#single-button -->
      <div class="dropdown">
        <button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
          Dropdown button
        </button>
        <ul class="dropdown-menu">
          <li><a class="dropdown-item" href="#">Action</a></li>
          <li><a class="dropdown-item" href="#">Another action</a></li>
          <li><a class="dropdown-item" href="#">Something else here</a></li>
        </ul>
      </div>

    </div>
  </template>
  <script type="module">
    import { Dropdown, Collapse } from "bootstrap";
    class Bs5Element extends HTMLElement {
      constructor() { super(); }
      connectedCallback() {
        const template = document.getElementById("bs5components");
        const templateContent = template.content;
        this.attachShadow({mode: "open"}).appendChild(templateContent.cloneNode(true));

        // Dropdown
        const el1 = this.shadowRoot.querySelector(".dropdown button");
        const dropdown = new Dropdown(el1);
        el1.addEventListener("click", () => dropdown.toggle());

        // Collapse
        const el2 = this.shadowRoot.querySelector(".collapse");
        const collapse = new Collapse(el2, {toggle: false});
        this.shadowRoot.querySelectorAll("[data-bs-toggle=collapse]").forEach(el => {
          el.addEventListener("click", evt =>{
            evt.preventDefault();
            collapse.toggle();
          });
        });
      }
    }
    customElements.define("bs5-components", Bs5Element);
  </script>
</head>
<body>
  <bs5-components></bs5-components>
</body>
</html>
```

### ポイント

- `<template>` 中で `<link>` によりBootstrapのCSSをロード
- `<template>` 直下の `<div>` に `data-bs-theme="light"` をセットすることでBootstrapのCSS変数が適用される
- `<template>` 中のCollapseおよびDropdownのHTMLはBootstrapサイトの例そのまま
- JavaScriptもカプセル化するため `<script type="module">` で実行
- `bootstrap` モジュールは `@popperjs/core` をインポートするので、importmapを設定
- カスタム要素は `<bs5-components>` で登録
- `<template>`、`<script>` は `<head>` 内でも `<body>` 内でもどちらでもよさそう(たぶん)

## まとめ

以上のようにいくつかのポイントを押さえることで、Web Components内に隔離された環境でBootstrap 5を使うことができました。ベースがBootstrap3で作成されているWebページに、以上の方法でBootstrap5を共存させることができました。