汎用 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 で仕事をしていないので、調整予定です。