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

はじめに

最近になってようやく Web Components をちゃんと使い始めました。 よく使う UI コンポーネントなど、汎用性のあるものを Web Components で作って手元に置いておきたいなという思いがあるのですが、今回はアコーディオンを Lit を使って実装してみました。

要件整理

  • アクセシビリティに配慮したアコーディオンの制作
    • 開閉状態をスクリーンリーダーに伝えたい
    • セマンティクスを意識し、aria を使用してマークアップの関連付けを行いたい
  • Web Components での実装
    • Vite 環境で制作
    • Lit / TypeScript で記述
    • CSS での装飾はコンポーネント外で、機能に対して必要最低限のスタイリングのみをコンポーネントに付与する
  • 単体での使用を想定
    • 複数個アコーディオンがあった場合、他のアコーディオンコンポーネントには開閉の影響を及ぼさない

実装したコード

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

import { html, css, LitElement } from "lit";
import {
  customElement,
  state,
  property,
  queryAssignedElements,
} from "lit/decorators.js";

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

  @property()
  id!: string;

  @queryAssignedElements({ slot: "accordion-button" })
  _button!: Array<HTMLElement>;

  constructor() {
    super();

    window.addEventListener("DOMContentLoaded", (): void => {
      this._button[0].setAttribute("aria-expanded", String(this.expanded));
      this._button[0].setAttribute("aria-controls", this.id);
    });
  }

  get _target(): HTMLElement {
    return this.renderRoot?.querySelector(`#${this.id}`) as HTMLElement;
  }

  private _openTarget(element: HTMLElement): void {
    const targetHeight = element.scrollHeight;

    element.style.height = `${targetHeight}px`;
  }

  private _closeTarget(element: HTMLElement): void {
    element.style.height = "0px";
  }

  public _handleClick(): void {
    this.expanded = !this.expanded;

    if (this.expanded) {
      this._openTarget(this._target);
      this._button[0].setAttribute("aria-expanded", String(this.expanded));
    } else {
      this._closeTarget(this._target);
      this._button[0].setAttribute("aria-expanded", String(this.expanded));
    }
  }

  protected render() {
    return html`
      <div class="custom-accordion">
        <div class="custom-accordion_button">
          <slot name="accordion-button" @click="${this._handleClick}"></slot>
        </div>
        <div
          class="custom-accordion_contents"
          id="${this.id}"
          aria-hidden="${String(!this.expanded)}"
        >
          <slot name="accordion-contents"></slot>
        </div>
      </div>
    `;
  }

  static styles = [
    css`
      .custom-accordion_contents {
        overflow: hidden;
        transition: height 0.2s ease-in-out;
      }

      .custom-accordion_contents[aria-hidden="true"] {
        height: 0;
      }
    `,
  ];
}

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

上記の Web Components を使用したアコーディオンのマークアップがこちら。

<custom-accordion id="unique-id">
  <button slot="accordion-button" type="button">ボタン</button>
  <div slot="accordion-contents">コンテンツ</div>
</custom-accordion>

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

実装の確認

state, property

React でいうところの useState と props になります。 今回はアコーディオンの状態を expanded というステートで管理し、これを基準にアコーディオンの開閉状態や aria の設定を行います。 id はコンポーネントを使用する時に外部から渡すことを想定しています。

queryAssignedElements

こちらでコンポーネントで定義した slot に入ってきた要素にアクセスします。 コンポーネント側で aria-expanded, aria-controls まで管理できればよかったのですが、コンポーネント内の slot に aria を設定しても、想定する読み上げ(開閉状態)が行われません。 スタイリングの都合などを考慮すると、ボタンとコンテンツはコンポーネント外から渡した方が都合がよさそうだったので、コンポーネントから slot を経由した子要素にアクセスし、aria の付与・制御を行うこととしました。

constructor

こちらでコンポーネントの初期化を行っています。 初期値はマークアップの方に記述してしまっても良いのですが、細かい記述を気にせずに使えるアコーディオンにしたかったので、aria-expanded および aria-controls を初期化時に付与します。 constructor に直接 this._button[0] を記述しても DOM にアクセスができないようだったので、DOMContentLoaded のイベント内でで初期化を行なっています。

get

アコーディオンのコンテンツを指す ID になります。 aria-controls でボタンとコンテンツの ID の紐付けを行いたかったのですが、外部から渡された ID に対して .custom-accordion_contents が動的に変更される必要があります。 @query() のデコレーターに渡される値を property 基準で動的にしたい、というのがモチベーションで、こちらは get で記述となりました。

まとめ

もっとよい調整があればアップデート予定ですが、それなりに汎用性のあるアコーディオンができたのではないかと思います。 WAI-ARIA Authoring Practices も参考にしたのですが、ボタンを見出しとして扱うのも意味的には良いのかもしれません。 実際に h タグでボタンを囲ってみたり、role=“heading” をボタンに付与したりも試してもみたのですが、role が変わってしまうとボタンが持つ状態の読み上げが失われてしまう挙動となったため、実装を見送りました。 外部ボタンからアコーディオンの状態を制御できるよう、public なメソッドを作っておくとより汎用的に使えるかも、とも思ったり。

参考

Lit v2 Documentation WAI-ARIA Authoring Practices 1.2 Vite