汎用 UI を Web Components(Lit)で実装する(モーダル編)

はじめに

汎用 UI を Web Components(Lit) で実装する(アコーディオン編)のモーダル編です。

要件整理

  • アクセシビリティに配慮したモーダルの制作
    • モーダル内にはコンテンツがコンポーネント外から渡される
    • 展開時は背景が固定され、背景要素にはアクセスできない
    • 個人的には YouTube を入れる使い方が多いので、モーダルを閉じた際は YouTube の再生がストップされる
    • モーダルの背景クリック、およびエスケープキーでモーダルを閉じることができる
    • モーダルを閉じた際はフォーカス位置が元のボタンに戻る
  • Web Components での実装
    • Vite 環境で制作
    • Lit / TypeScript で記述
    • CSS での装飾は極力コンポーネント外で、機能に対して必要最低限のスタイリングのみをコンポーネントに付与する

実装したコード

実装したものがこちらになります。 Lit の記述方法は Lit v2 Documentation および Vite の環境構築時にインストールされていたサンプルファイルを参考にしています。

モーダル外部要素へのアクセスについては inert 属性を使用しています。ブラウザサポートが不十分なため、polyfill(wicg-inert) と併用します。

import "wicg-inert";
import { html, css, LitElement } from "lit";
import { customElement, state, queryAssignedElements } from "lit/decorators.js";
import { query } from "lit/decorators/query.js";

type Direction = "open" | "close";

const body = document.getElementsByTagName("body")[0];
const inertTarget = document.getElementsByClassName("base")[0] as HTMLElement;

@customElement("custom-modal")
export class CustomElement extends LitElement {
  @state()
  expanded = false;

  @state()
  _button!: HTMLButtonElement;

  @query("#target")
  _target!: HTMLElement;

  @queryAssignedElements({ slot: "custom-modal-content" })
  _content!: Array<HTMLElement>;

  constructor() {
    super();

    const modalButtons = document.getElementsByClassName("js-modal-button");

    Array.from(modalButtons).forEach((buttonElement) => {
      buttonElement.addEventListener("click", () => {
        const target = buttonElement.getAttribute("aria-controls");
        const modalTarget = document.getElementById(target!) as CustomElement;

        this._button = buttonElement as HTMLButtonElement;
        modalTarget._openModal();
      });
    });

    window.addEventListener("keydown", (event) => {
      if (event.code === "Escape") {
        this._closeModal();
      }
    });
  }

  private _stopYouTubeMovie(iframeElement: HTMLIFrameElement) {
    if (!iframeElement.contentWindow) return;
    iframeElement.contentWindow.postMessage(
      '{"event":"command","func":"pauseVideo","args":""}',
      "*"
    );
  }

  private _toggleScreenLock = (direction: Direction) => {
    switch (direction) {
      case "open":
        body.classList.add("u-overflow-hidden");
        break;
      case "close":
        body.classList.remove("u-overflow-hidden");
        break;
      default:
        const neverValue: never = direction;
        throw new Error(`${neverValue} is an invalid action.`);
    }
  };

  private _openModal() {
    this.expanded = true;
    inertTarget.inert = true;
    this._toggleScreenLock("open");
  }

  private _closeModal() {
    const iframeElement = this._content[0].getElementsByTagName("iframe")[0];

    this.expanded = false;
    inertTarget.inert = false;
    this._button.focus();
    this._toggleScreenLock("close");

    if (!iframeElement) return;
    this._stopYouTubeMovie(iframeElement);
  }

  protected render() {
    return html`
      <div
        class="custom-modal-container"
        id="target"
        role="dialog"
        aria-modal="true"
        aria-hidden="${String(!this.expanded)}"
        inert="${String(!this.expanded)}"
      >
        <div class="custom-modal">
          <div class="custom-modal_backdrop" @click="${this._closeModal}"></div>
          <div class="custom-modal-inner">
            <div class="custom-modal-content">
              <slot name="custom-modal-content"></slot>
            </div>
          </div>
          <button
            class="custom-modal_close-btn"
            @click="${this._closeModal}"
            type="button"
            aria-label="閉じる"
          ></button>
        </div>
      </div>
    `;
  }

  static styles = [
    css`
      button {
        overflow: visible;
        text-transform: none;
        min-height: 1.5em;
        color: inherit;
        font-weight: inherit;
        font-style: inherit;
        font-family: inherit;
        -webkit-appearance: button;
        cursor: pointer;
        border-style: none;
        background-color: transparent;
      }

      button::-moz-focus-inner {
        border: 0;
        padding: 0;
      }

      button[disabled] {
        cursor: default;
      }

      .custom-modal-container {
        visibility: hidden;
        opacity: 0;
        position: fixed;
        z-index: 100;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        transition: opacity 0.3s, visibility 0s 0.3s;
        padding-bottom: 8rem;
      }

      .custom-modal-container[aria-hidden="false"] {
        overflow-y: auto;
        visibility: visible;
        opacity: 1;
        transition: opacity 0.3s, visibility 0s 0s;
      }

      .custom-modal {
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
        min-height: 100%;
      }

      .custom-modal-inner {
        position: relative;
        width: 100%;
        pointer-events: none;
      }

      .custom-modal-content {
        display: none;
        position: relative;
        margin: 0 auto;
        width: 100%;
        pointer-events: painted;
      }

      .custom-modal-container[aria-hidden="false"] .custom-modal-content {
        display: block;
      }

      .custom-modal_backdrop {
        position: absolute;
        inset: 0;
        background-color: rgba(0, 0, 0, 0.8);
      }

      .custom-modal_close-btn {
        position: absolute;
        z-index: 1;
        top: 0;
        right: 0;
        margin: 0 auto;
        pointer-events: painted;
        width: 4.8rem;
        height: 4.8rem;
        min-height: auto;
      }

      .custom-modal_close-btn::before,
      .custom-modal_close-btn::after {
        content: "";
        display: block;
        position: absolute;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        margin: auto;
        width: 2.2rem;
        height: 2px;
        background-color: #fff;
      }

      .custom-modal_close-btn::before {
        transform: rotate(45deg);
      }

      .custom-modal_close-btn::after {
        transform: rotate(-45deg);
      }

      @media screen and (max-width: 767px) {
        .custom-modal-content {
          max-width: 32rem;
        }
      }

      @media screen and (min-width: 768px) {
        .custom-modal-content {
          max-width: 85rem;
        }

        .custom-modal_close-btn {
          top: 2.4rem;
          right: 2.4rem;
        }
      }

      @media (hover: hover) {
        .custom-modal_close-btn {
          opacity: 1;
          transition: opacity 0.1s ease-out;
        }

        .custom-modal_close-btn:hover {
          opacity: 0.7;
          transition: opacity 0.2s ease-out;
        }
      }
    `,
  ];
}

declare global {
  interface HTMLElementTagNameMap {
    "custom-modal": CustomElement;
  }

  interface HTMLElement {
    inert: any;
  }
}

上記の Web Components を使用したモーダルのマークアップがこちら。

<button class="js-modal-button" aria-controls="unique-id" type="button" aria-label="モーダルを開く">ボタン</button>

<custom-modal id="unique-id">
  <div class="modal_inner" slot="custom-modal-content">
    <iframe width="560" height="315" src="https://www.youtube.com/embed/oRdxUFDoQe0?rel=0&enablejsapi=1" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
  </div>
</custom-modal>

id とボタン、開閉するコンテンツだけ渡して使用できるようにしています。

実装の確認

state, property

開閉状態を保持する expanded と、モーダルを閉じた際にフォーカスを戻すために押下されたボタンを保持する _button を持ちます。

queryAssignedElements

こちらでコンポーネントで定義した slot に入ってきた要素にアクセスします。 モーダルを閉じた際に YouTube を停止させるために使用しています。

constructor

こちらでコンポーネントの初期化を行っています。 アコーディオンのコンポーネントとは異なり、モーダルの UI はボタンとコンテンツがマークアップ上離れることが想定されるので、こちらでボタン(aria-controls)とコンテンツ (id) の紐付けを行います。

_openModal, _closeModal

開閉、背景要素の活性・非活性のコントロール、フォーカスの移動もこちらで行っています。 inert 属性を付与する inertTarget は .base を基準に設定していますので、custom-modal コンポーネントは .base より外側にマークアップする必要があります。

まとめ

こちらもアコーディオン同様、もっとよい調整があればアップデート予定です。特に背景固定の _toggleScreenLock は iOS で仕事をしていないので、調整予定です。

参考

Lit v2 Documentation WAI-ARIA Authoring Practices 1.2 Vite