🌠

Shadow DOMを使った親画面のCSSに影響されにくいモーダル画面を試してみた話

2024/06/08に公開

はじめに

この記事では、筆者がShadow DOM を用いてフォームの入力データをモーダルでプレビュー表示する方法の学習記録です。
ChatGPTによる文章が少し入ってます。あらかじめご了承ください。

背景

モーダル画面のプレビュー(背景が暗くなって出てくるやつ)でモーダルの外部で定義したCSSの影響を受けないようにhtmlをプレビュー要素として組み込みたいという場面に実務で遭遇しました。下のようなイメージ。

<!DOCTYPE html>
<html lang="ja">
<head>
  <!-- 中略 -->
  <!-- ↓このスタイルは.modal内の要素に付与したくない -->
  <link rel="stylesheet" href="style.css">
  <style>
    /* 親画面のCSSが書いてある */
  </style>
  <!-- ↑このスタイルは.modal内の要素に付与したくない -->
</head>
<body>
  <div class="parent">
    <!-- 親画面の要素。フォームとかがある。 -->
  </div>
  <div class="modal">
    <!-- フォームで入力した値を差し込んだプレビュー要素が入る。
        それなりの規模のHTMLを動的に挿入する必要がある。多分親画面のCSSで表示が崩れる -->
  </div>
</body>
</html>

私の中では

  • 頑張ってCSS打ち消しながら作る
  • iframeタグを使う

くらいしか思い浮かばずもう少しイケてる案はないのかと思い、ChatGPTに聞いてみたところShadow DOMを使う方法をおすすめされました。
↑の記事を読んでいると意外と今回の実務に限らず使いどころがありそうに思えましたので、検証してみることにしました。

1. 基本的なHTMLの準備

まずは基本的なHTMLとCSSを用意します。フォームとモーダル要素を含むシンプルなHTMLを作成しました。

index.html
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="style.css" />
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" />
    <title>Shadow DOM Modal PoC</title>
</head>

<body>
    <div class="container mt-3">
        <form action="">
            <label class="user-name" for="">ユーザー名: </label>
            <input type="text" class="form-control m-3" name="text1" id="text1" />
            <label>アップロードファイル: </label>
            <input type="file" class="btn btn-light m-3" name="file1" id="file1" accept=".png,.jpg,.jpeg,.gif" />
            <br />
            <input type="button" class="btn btn-info m-3" id="プレビュー" value="Preview" />
        </form>

        <shadow-modal id="shadowModal">
            <!-- Shadow Root -->
        </shadow-modal>
    </div>

    <script src="script.js"></script>
    <script src="shadowModal.js"></script>
</body>

</html>
style.css
/* Shadow DOMが通常のスタイル設定の影響を受けないことを確認する */
.user-name {
    color: blue;
}

2. 外部HTMLとCSSファイルの準備

モーダルのコンテンツを外部HTMLファイルとCSSファイルに分離して管理します。

previewTarget.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <link rel="stylesheet" href="preview_target.css">
</head>
<body>
  <h3>ユーザー名:</h3>
  <p class="user-name"></p>
  <label for="uploadedImage">アップロード画像:</label>
  <img id="uploadedImage" alt="アップロード画像">
</body>
</html>
preview_target.css
#uploadedImage {
    max-width: 100%;
}

/* Shadow DOM側のCSSがLight DOMには適用されないことを確認する */
.user-name {
    font-weight: bold;
    font-family: "Lucida Sans", "Lucida Sans Regular", "Lucida Grande",
        "Lucida Sans Unicode", Geneva, Verdana, sans-serif;
}

3. Shadow DOM コンポーネントの実装

次に、Shadow DOM を利用したカスタムコンポーネントを作成します。このコンポーネントは、外部HTMLとCSSを読み込み、フォームの入力値とBlob URLを表示します。

shadowModal.js
class ShadowModal extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: "open" });
    }

    async connectedCallback() {
        // NOTE: 外部のhtml(ここではpreviewTarget.html)をプレビューに組み込むことを想定
        const html = await fetch(`${location.origin}/previewTarget.html`).then(
            async (response) => await response.text()
        );
        const css = `
            .modal {
                display: none;
                position: fixed;
                z-index: 1;
                left: 0;
                top: 0;
                width: 100%;
                height: 100%;
                overflow: auto;
                background-color: rgb(0, 0, 0);
                background-color: rgba(0, 0, 0, 0.4);
            }
            .modal-content {
                background-color: #fefefe;
                margin: 15% auto;
                padding: 20px;
                border: 1px solid #888;
                width: 80%;
            }
            .close {
                color: #aaa;
                float: right;
                font-size: 28px;
                font-weight: bold;
            }
            .close:hover,
            .close:focus {
                color: black;
                text-decoration: none;
                cursor: pointer;
            }
        `;

        // 要素をShadow DOM に添付
        this.shadowRoot.innerHTML = `
            <style>${css}</style>
            <div class="modal">
                <div class="modal-content">
                <span class="close">&times;</span>
                    <section id="preview">
                        ${html}
                    </section>
                </div>
            </div>
        `;

        this.modal = this.shadowRoot.querySelector(".modal");
        this.closeButton = this.shadowRoot.querySelector(".close");
        this.preview = this.shadowRoot.querySelector("#preview");
        this.userName = this.shadowRoot.querySelector(".user-name");
        this.uploadedImage = this.shadowRoot.querySelector("#uploadedImage");
        this.closeButton.addEventListener("click", () => this.hide());
    }

    show(content, imageUrl) {
        this.userName.textContent = content;
        if (imageUrl) {
            this.uploadedImage.src = imageUrl;
        }
        this.modal.style.display = "block";
    }

    hide() {
        this.modal.style.display = "none";
    }
}

customElements.define("shadow-modal", ShadowModal);
script.js
document.addEventListener("DOMContentLoaded", () => {
    const modal = document.getElementById("shadowModal");

    const text1 = document.getElementById("text1");
    const file1 = document.getElementById("file1");

    document.getElementById("preview").addEventListener("click", (e) => {
        const text = text1.value;
        const src =
            file1.files.length > 0 ? URL.createObjectURL(file1.files[0]) : "";
        modal.show(text, src);
        modal.shadowRoot.onlodad = () => URL.revokeObjectURL(src);
    });
});

4. 動作確認とカスタマイズ

以上で、基本的な設定は完了です。フォームに値を入力し、ファイルを選択して「プレビュー」ボタンを押すと、モーダルのプレビュー画面が表示されるはずです。Shadow DOMのおかげで、モーダル内の要素は外部のスタイルの影響を受けません。

動作確認

適当にテキストと画像ファイルを入力して...
フォーム入力画面

「プレビュー」ボタンをクリックすると

背景が暗くなってユーザー名と画像が表示されました!

「ユーザー名」のテキストから、Shadow DOMに含まれる要素はLight DOMのCSS

.user-name {
    color: blue;
}

が適用されず、
逆にLight DOMに含まれる要素はShadow DOMのCSS

.user-name {
    font-weight: bold;
    font-family: "Lucida Sans", "Lucida Sans Regular", "Lucida Grande",
        "Lucida Sans Unicode", Geneva, Verdana, sans-serif;
}

が適用されないことが確認できます。

カスタマイズのポイント

  1. 外部HTMLとCSS:

    • 外部ファイルを変更することで、モーダルのコンテンツやスタイルを簡単にカスタマイズできます。
  2. JavaScriptの拡張:

    • show メソッドや connectedCallback メソッドを拡張して、より複雑なデータ表示やインタラクションを実装できます。
  3. 追加機能:

    • モーダルにフォームを追加し、さらに入力データを取得するなどの機能を追加できます。

このガイドを参考に、Shadow DOM を活用して外部スタイルの影響を受けない独立したコンポーネントを作成し、柔軟なモーダルプレビューを実装してみてください。

おわりに

実際はfetch APIを使うためにわざわざDockerコンテナ作ってソース置いたりしています。この記事に需要がありそうならそのあたりも加筆しようと思ってます。
コードと記事、両方書いていて力尽きてしまったのでいったんここまで。

Discussion