Web Componentsで作る、Radix UIライクなDialogコンポーネント
はじめに
Web Componentsで作る、Radix UIライクなAccordionコンポーネントに続き、今回はRadix UIライクなDialogを作成していきます。
Radix UIでは独自のdialogコンポーネントを実装していますが、独自実装よりも多くのメリットを享受できるため、今回は<dialog>
要素を拡張する<ui-dialog>
カスタムコンポーネントを作成します。
作成する<ui-dialog>の仕様
今回作成する<ui-dialog>
(カスタムコンポーネント)は、以下の仕様とします。
- WAI-ARIAに準拠したアクセシビリティ対応。
- モーダルダイアログとして機能する。
-
open
属性で開閉状態を制御する(boolean)。 -
modal
属性でモーダルか(デフォルト)、モードレスかを選択可能。 -
closedby
属性で、ダイアログ外をクリックした時に閉じるかどうかを制御。デフォルトはauto
(モーダルの場合閉じ、モードレスの場合は閉じない)。他、any
(常に閉じる)、none
(閉じない)、closerequest
(dialogのclose requestでのみ閉じる)が指定可能。 -
onOpenChange
カスタムイベントで、開閉状態の変化を外部に通知。 - Escキーでダイアログを閉じることができる(
closedby="none"
の場合は除く)。 -
<ui-dialog>
外にトリガーを設置可能 -
<ui-dialog-trigger>
でダイアログを開くトリガーとなる要素を指定できる。 -
<ui-dialog-close>
でダイアログを閉じるトリガーとなる要素を指定できる。 -
<ui-dialog-title>
でダイアログのタイトルを指定できる。 -
<ui-dialog-description>
でダイアログの説明文を指定できる。
<ui-dialog>
のメリット
<dialog>
を直接利用する場合、dialogを開くためのJS処理の作成が必須になりますが、<ui-dialog>
カスタムコンポーネントを使うことで、htmlの記述だけで<dialog>
の実装が可能となります。
実装サンプル
開発環境
TypeScriptとzustandをインストールした環境を用意してください。
サンプルコードはCodepenなどでもJSの設定JavaScript Preprocessor
でTypeScript
を選択することで動作が可能です。
Dialogコンポーネントの実装
1. 状態管理ストア (Zustand)の準備
まず、Dialogの状態を管理するために、Zustandという軽量な状態管理ライブラリを使用します。
Zustandは主にReactで利用されるライブラリですが、vanillajsでも利用が可能です。
ここでは、Zustandのインポート、属性設定を簡略化するユーティリティ関数を準備します。
import { createStore } from "zustand/vanilla";
import { subscribeWithSelector } from "zustand/middleware";
// ユーティリティ関数:属性の一括設定
const setAttrsElement = (
element: HTMLElement | null,
attributes: { [key: string]: string | undefined }
) => {
for (const [key, value] of Object.entries(attributes)) {
if (value === undefined) {
element?.removeAttribute(key);
} else {
element?.setAttribute(key, value);
}
}
};
2. UiDialog (Dialog Rootコンポーネント)
Dialog全体の動作を制御するコンポーネントです。
modal
やclosedby
の状態を管理し、状態の変更を伝播させます。Zustandのsubscribe
を使うことで、状態の変更を監視し、リアクティブにUIを更新します。これにより、命令的にDOMを操作するのではなく、宣言的なスタイルでUIを構築できます。
reactのuseEffectでUIの更新を行なっているイメージです。
開いてUiDialogの実装を確認する
export type DialogClosedby = 'any' | 'closerequest' | 'none';
export type DialogClosedbyDefault = 'auto';
const getClosedby = (value: string | null): DialogClosedby | DialogClosedbyDefault => {
if (value === 'any' || value === 'closerequest' || value === 'none') {
return value as DialogClosedby;
}
return 'auto';
};
interface DialogStoreState {
open: boolean;
modal: boolean;
closedby: DialogClosedby | DialogClosedbyDefault;
dialogId: string;
titleId: string;
descriptionId: string;
}
export class UiDialog extends HTMLElement {
private $dialog: HTMLDialogElement | null = null;
unsubscribe: (() => void) | undefined = undefined;
useRootStore = createStore(
subscribeWithSelector<DialogStoreState>((set) => ({
open: false,
modal: false,
closedby: 'auto',
dialogId: `dialog-${Math.random().toString(36).slice(2)}`,
titleId: `dialog-title-${Math.random().toString(36).slice(2)}`,
descriptionId: `dialog-description-${Math.random().toString(36).slice(2)}`,
})),
);
static get observedAttributes() {
return ['open', 'modal', 'closedby'];
}
constructor() {
super();
}
connectedCallback(): void {
const open = this.hasAttribute('open');
const modal = this.getAttribute('modal') !== 'false'; // 'false'の場合のみfalse
const closedby = getClosedby(this.getAttribute('closedby'));
const $title = this.querySelector('ui-dialog-title:not(:scope ui-dialog *)');
const $description = this.querySelector('ui-dialog-description:not(:scope ui-dialog *)');
this.$dialog = this.querySelector('dialog:not(:scope ui-dialog *)');
const dialogId = this.$dialog?.getAttribute('id') ?? this.useRootStore.getState().dialogId;
const titleId = $title?.getAttribute('id') ?? this.useRootStore.getState().titleId;
const descriptionId = $description?.getAttribute('id') ?? this.useRootStore.getState().descriptionId;
// 初期状態を store に反映
this.useRootStore.setState({
open,
modal,
closedby,
dialogId,
titleId,
descriptionId,
});
// data-state設定
setAttrsElement(this, {
'data-state': open ? 'open' : 'closed',
});
this.unsubscribe = this.useRootStore.subscribe(
(state) => state.open,
(open) => {
setAttrsElement(this, {
'data-state': open ? 'open' : 'closed',
});
// 外部に通知するカスタムイベントを発火
this.dispatchEvent(
new CustomEvent('onOpenChange', {
detail: { open: open, target: this },
}),
);
},
);
}
disconnectedCallback(): void {}
attributeChangedCallback(property: string, oldValue: string | null, newValue: string | null) {
// openの変更で表示を切り替え
if (property === 'open' && newValue !== oldValue) {
if (newValue === null) {
this.close();
} else {
const { modal } = this.useRootStore.getState();
if (modal) {
this.showModal();
} else {
this.show();
}
}
this.useRootStore.setState({
open: newValue === null,
});
}
// modal, closedbyの変更をstoreに反映
if (property === 'modal' && newValue !== oldValue) {
this.useRootStore.setState({
modal: this.getAttribute('modal') !== 'false', // 'false'の場合のみfalse
});
}
if (property === 'closedby' && newValue !== oldValue) {
this.useRootStore.setState({
closedby: getClosedby(newValue),
});
}
}
showModal(): void {
this.$dialog?.showModal();
this.useRootStore.setState({
open: true,
});
}
close(): void {
this.$dialog?.close();
this.useRootStore.setState({
open: false,
});
}
show(): void {
this.$dialog?.show();
this.useRootStore.setState({
open: true,
});
}
}
Prop
プロパティ名 | 説明 | デフォルト値 | 備考 |
---|---|---|---|
open |
ダイアログの開閉状態。true で開く、false で閉じる。 |
false |
外部から開閉状態を制御する場合に使用。 |
modal |
ダイアログをモーダル表示するかどうか。true でモーダル、false でモードレス。 |
true |
|
closedby |
ダイアログを閉じる方法。auto (モーダルの場合は外側クリックとEscで閉じ、モードレスの場合は閉じない)any (常に外側クリックとEscで閉じる)none (閉じない)closerequest (dialogのclose request イベントでのみ閉じる) |
auto |
Attribute
属性名 | 説明 | デフォルト値 | 備考 |
---|---|---|---|
data-state |
open | closed
|
- |
onOpenChange
カスタムイベントの使い方
UiDialogコンポーネントは、開閉状態が変更されたときにonOpenChange
カスタムイベントを発火します。
このイベントをリッスンすることで、Dialogの状態変化を外部から検知し、他の処理と連携できます。
<ui-dialog id="my-dialog">
~~~省略~~~
</ui-dialog>
<script>
const dialog = document.getElementById("my-dialog");
dialog.addEventListener("onOpenChange", (event) => {
console.log("Dialog open state changed:", event.detail.open);
// ここで、event.detail.open を使って何か処理を行う
});
</script>
-
event.detail.open
: ダイアログの開閉状態(true
orfalse
)。
3. UiDialogTrigger (Dialogを開くトリガー)
Dialogを開くためのトリガーとなるコンポーネントです。
<ui-dialog-trigger>
タグでラップした<button>
要素のclick
イベントをリッスンし、Dialogのopen状態をtrue
に更新します。(正確には、modal属性に応じてshowModal()
またはshow()
を呼び出します。)
アクセシビリティ属性
<button>
要素には、アクセシビリティをのため、以下の属性が付与されます。
属性名 | 説明 |
---|---|
aria-haspopup="dialog" |
この属性は、ボタンがダイアログを開くトリガーであることを示します。 |
aria-expanded="false" |
この属性は、ダイアログが現在閉じていることを示します。ダイアログが開かれるとtrue に更新されます。 |
aria-controls="dialog-xxxxx" |
この属性は、ボタンが制御するダイアログのIDを指定します。これにより、スクリーンリーダーはどの要素が関連しているかを理解できます。 |
<ui-dialog-trigger>
<button type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="dialog-xxxxx" data-state="closed">open</button>
</ui-dialog-trigger>
開いてUiDialogTriggerの実装を確認する
export class UiDialogTrigger extends HTMLElement {
private $root: UiDialog | null = null;
private $button: HTMLButtonElement | null = null;
constructor() {
super();
}
connectedCallback(): void {
this.$root = this.closest('ui-dialog');
if (!this.$root) {
console.error('ui-dialog-trigger must be child of ui-dialog');
return;
}
this.$button = this.querySelector('button:not(:scope ui-dialog *)');
const { dialogId, open } = this.$root.useRootStore.getState();
setAttrsElement(this.$button, {
type: 'button',
'aria-haspopup': 'dialog',
'aria-expanded': open ? 'true' : 'false',
'aria-controls': dialogId,
'data-state': open ? 'open' : 'closed',
});
this.$button?.addEventListener('click', this.handleClick);
this.$root.useRootStore.subscribe(
(state) => state.open,
(open) => {
setAttrsElement(this, {
'data-state': open ? 'open' : 'closed',
});
setAttrsElement(this.$button, {
'aria-expanded': open ? 'true' : 'false',
'data-state': open ? 'open' : 'closed',
});
},
);
}
disconnectedCallback(): void {
this.$button?.removeEventListener('click', this.handleClick);
}
private handleClick = (): void => {
if (!this.$root) return;
const { modal } = this.$root.useRootStore.getState();
if (modal) {
this.$root.showModal();
} else {
this.$root.show();
}
};
}
Attribute
属性名 | 説明 | デフォルト値 | 備考 |
---|---|---|---|
data-state |
open | closed
|
- |
4. UiDialogContent (Dialogのコンテンツ)
Dialogのコンテンツ部分を表すコンポーネントです。
基本的にはネイティブの<dialog>
の機能を利用しますが、closedby
属性についてはまだ利用できるブラウザが限られるため独自に実装します。
具体的には<dialog>
をクリックした時にborder
の外側であればclose
イベントを発火するようにしています。
開いてUiDialogContentの実装を確認する
export class UiDialogContent extends HTMLElement {
private $root: UiDialog | null = null;
private $dialog: HTMLDialogElement | null = null;
private unsubscribe: (() => void) | undefined = undefined;
private observer: MutationObserver | null = null;
connectedCallback(): void {
this.$root = this.closest('ui-dialog');
if (!this.$root) {
console.error('ui-accordion-content must be child of ui-dialog');
return;
}
this.$dialog = this.$root.querySelector('dialog:not(:scope ui-dialog *)');
if (!this.$dialog || this.$dialog.tagName !== 'DIALOG') {
console.error('<dialog> is required as a child element of ui-dialog-content');
return;
}
const { open, modal, dialogId, titleId, descriptionId } = this.$root.useRootStore.getState();
setAttrsElement(this, {
'data-state': open ? 'open' : 'closed',
});
setAttrsElement(this.$dialog, {
id: dialogId,
'aria-labelledby': titleId,
'aria-describedby': descriptionId,
'data-state': open ? 'open' : 'closed',
});
// 初期状態がopenならshowModal()
if (open) {
if (modal) {
this.$dialog?.showModal();
} else {
this.$dialog?.show();
}
}
this.unsubscribe = this.$root.useRootStore.subscribe(
(state) => state.open,
(open) => {
if (open) {
this.$dialog?.addEventListener('keydown', this.handleStopPropagationEscape);
this.$dialog?.addEventListener('click', this.handleLightDismiss);
} else {
this.$dialog?.removeEventListener('keydown', this.handleStopPropagationEscape);
this.$dialog?.removeEventListener('click', this.handleLightDismiss);
}
setAttrsElement(this, {
'data-state': open ? 'open' : 'closed',
});
setAttrsElement(this.$dialog, {
'data-state': open ? 'open' : 'closed',
});
},
);
}
disconnectedCallback(): void {
if (this.unsubscribe) this.unsubscribe();
// observerを停止
this.observer?.disconnect();
this.observer = null;
}
private handleStopPropagationEscape = (event: Event): void => {
const state = this.$root?.useRootStore.getState();
// closedby='none'の場合、Escapeキー(Close request)イベントを止める
// https://html.spec.whatwg.org/#close-request
if (state?.open && state?.closedby === 'none' && event instanceof KeyboardEvent && event.key === 'Escape') {
event.stopImmediatePropagation();
event.preventDefault();
}
};
private handleLightDismiss = (event: MouseEvent): void => {
const state = this.$root?.useRootStore.getState();
const target = event.target as Element;
// closedby='any'の場合、dialog背景クリックで閉じる
if (state?.open && state?.closedby === 'any' && this.$dialog && target) {
if (target.nodeName === 'DIALOG') {
const rect = target.getBoundingClientRect();
// クリック座標 (ビューポート基準)
const clickX = event.clientX;
const clickY = event.clientY;
// 要素内かどうかを判定
const isInside = clickX >= rect.left && clickX <= rect.right && clickY >= rect.top && clickY <= rect.bottom;
if (!isInside) {
this.$dialog.close('dismiss');
}
}
}
};
}
Attribute
属性名 | 説明 | デフォルト値 | 備考 |
---|---|---|---|
data-state |
open | closed
|
- |
5. UiDialogClose (Dialogを閉じるトリガー)
Dialogを閉じるためのトリガーとなるコンポーネントです。
<ui-dialog-close>
タグでラップしたボタン要素のclick
イベントをリッスンし、Dialogを閉じます。
サンプルのhtmlではこのボタン要素にautofocus
属性を付与しています。
<dialog>: ダイアログ要素 - HTML: ハイパーテキストマークアップ言語 | MDN
autofocus
属性を使用して初期フォーカスの配置を明確に示すと、特定のダイアログに対して最適な初期フォーカスの配置とみなされる要素に初期フォーカスが設定するのに役立ちます。ダイアログの初期フォーカスがどこに設定されるか常にわからない場合、特にダイアログのコンテンツが呼び出されたときに動的に描画される場合、必要であれば<dialog>
要素そのものにフォーカスを当てることが、初期フォーカスの配置として最適と判断されるかもしれません。
開いてUiDialogCloseの実装を確認する
export class UiDialogClose extends HTMLElement {
private $root: UiDialog | null = null;
private $button: HTMLButtonElement | null = null;
constructor() {
super();
}
connectedCallback(): void {
this.$root = this.closest('ui-dialog');
if (!this.$root) {
console.error('ui-dialog-close must be child of ui-dialog');
return;
}
this.$button = this.querySelector('button:not(:scope ui-dialog *)');
setAttrsElement(this.$button, {
type: 'button',
});
this.$button?.addEventListener('click', this.handleClick);
}
disconnectedCallback(): void {
this.$button?.removeEventListener('click', this.handleClick);
}
// $rootのclose関数を実行しダイアログを閉じる
private handleClick = (): void => {
this.$root?.close();
};
}
6. UiDialogTitle (Dialogのタイトル)
Dialogのタイトルを表すコンポーネントです。
id
属性を持たない場合、自動的に付与し、aria-labelledby
属性でDialog Contentと関連付けます。
開いてUiDialogTitleの実装を確認する
export class UiDialogTitle extends HTMLElement {
private $root: UiDialog | null = null;
connectedCallback(): void {
this.$root = this.closest('ui-dialog');
if (!this.$root) {
console.error('ui-accordion-title must be child of ui-dialog-content');
return;
}
const { titleId } = this.$root.useRootStore.getState();
setAttrsElement(this, {
id: titleId,
});
}
disconnectedCallback(): void {}
}
7. UiDialogDescription (Dialogの説明)
Dialogの説明を表すコンポーネントです。
id
属性を持たない場合、自動的に付与し、aria-describedby
属性でDialog Contentと関連付けます。
開いてUiDialogDescriptionの実装を確認する
export class UiDialogDescription extends HTMLElement {
private $root: UiDialog | null = null;
connectedCallback(): void {
this.$root = this.closest('ui-dialog');
if (!this.$root) {
console.error('ui-accordion-description must be child of ui-dialog-content');
return;
}
const { descriptionId } = this.$root.useRootStore.getState();
setAttrsElement(this, {
id: descriptionId,
});
}
disconnectedCallback(): void {}
}
8. UiDialogOutsideTrigger(Dialog外のトリガー)
<ui-dialog>
外に設置するトリガーです。data-target
属性に<ui-dialog>
を取得できるCSS Selectorを指定することで、対象のダイアログを開きます。
<ui-dialog>
とトリガーボタンを分けて配置したい場合などに利用します。
一般的なHTMLでのWeb制作においては、トリガーボタンとDialog要素を別々の場所に配置することで、コードの見通しが向上します。そのため、Radix UIには存在しないコンポーネントを独自に用意しました。
開いてUiDialogOutsideTriggerの実装を確認する
export class UiDialogOutsideTrigger extends HTMLElement {
private $root: UiDialog | null = null;
private $button: HTMLButtonElement | null = null;
constructor() {
super();
}
connectedCallback(): void {
const target = this.getAttribute('data-target') || '';
const $targetRoot = document.querySelector(target);
if (!$targetRoot) {
console.error('Target ui-dialog is not found');
return;
}
if ($targetRoot.tagName !== 'UI-DIALOG') {
console.error('The target is not <ui-dialog>');
return;
}
this.$root = $targetRoot as UiDialog;
this.$button = this.querySelector('button');
const { dialogId, open } = this.$root.useRootStore.getState();
setAttrsElement(this.$button, {
type: 'button',
'aria-haspopup': 'dialog',
'aria-expanded': open ? 'true' : 'false',
'aria-controls': dialogId,
'data-state': open ? 'open' : 'closed',
});
this.$button?.addEventListener('click', this.handleClick);
this.$root.useRootStore.subscribe(
(state) => state.open,
(open) => {
setAttrsElement(this, {
'data-state': open ? 'open' : 'closed',
});
setAttrsElement(this.$button, {
'aria-expanded': open ? 'true' : 'false',
'data-state': open ? 'open' : 'closed',
});
},
);
}
disconnectedCallback(): void {
this.$button?.removeEventListener('click', this.handleClick);
}
private handleClick = (): void => {
if (!this.$root) return;
const { modal } = this.$root.useRootStore.getState();
if (modal) {
this.$root.showModal();
} else {
this.$root.show();
}
};
}
9. カスタム要素の定義
作成したWeb Componentsをカスタム要素として登録します。
customElements.define('ui-dialog', UiDialog);
customElements.define('ui-dialog-trigger', UiDialogTrigger);
customElements.define('ui-dialog-close', UiDialogClose);
customElements.define('ui-dialog-content', UiDialogContent);
customElements.define('ui-dialog-title', UiDialogTitle);
customElements.define('ui-dialog-description', UiDialogDescription);
customElements.define('ui-dialog-outside-trigger', UiDialogOutsideTrigger);
また、グローバルに型を宣言します。
declare global {
interface HTMLElementTagNameMap {
'ui-dialog': UiDialog;
'ui-dialog-trigger': UiDialogTrigger;
'ui-dialog-close': UiDialogClose;
'ui-dialog-content': UiDialogContent;
'ui-dialog-title': UiDialogTitle;
'ui-dialog-description': UiDialogDescription;
'ui-dialog-outside-trigger': UiDialogOutsideTrigger;
}
}
HTMLでの使用例
HTML
<ui-dialog>
<ui-dialog-trigger><button>open</button></ui-dialog-trigger>
<ui-dialog-content>
<dialog>
<ui-dialog-close><button autofocus>close</button></ui-dialog-close>
<ui-dialog-title><h2>タイトル</h2></ui-dialog-title>
<ui-dialog-description><p>説明</p></ui-dialog-description>
<p>コンテンツ</p>
</dialog>
</ui-dialog-content>
</ui-dialog>
CSS
任意の方法でスタイリングします。
dialogの開閉アニメーションはMDNで紹介されている@starting-style
によるtransitionを付与しています。
<dialog>: ダイアログ要素 - HTML: ハイパーテキストマークアップ言語 | MDN
CSS では、
@starting-style
ブロックを記述して、opacity
およびtransform
プロパティのトランジション開始時のスタイル、dialog[open]
状態のトランジション終了時のスタイル、<dialog>
が表示された後に元の状態に戻る際の既定のdialog
状態のスタイルを定義します。注意してほしいのは、<dialog>
のtransition
リストには、これらのプロパティだけでなく、display
とoverlay
プロパティも含まれ、それぞれにallow-discrete
が設定されていることです。また、開いたときに現れる
<dialog>
の背後に現れる::backdrop
のbackground-color
プロパティに開始時のスタイル値を設定し、素敵な暗転アニメーションを指定しました。dialog[open]::backdrop
セレクターは、ダイアログが開いているときに、<dialog>
要素の背景のみを選択します。
開いてCSSを確認する
:where:is(ui-dialog, ui-dialog-trigger, ui-dialog-content, ui-dialog-close, ui-dialog-title, ui-dialog-description) {
display: block;
}
ui-dialog-outside-trigger button,
ui-dialog-trigger button {
text-align: left;
padding: 10px;
background-color: #eee;
border: none;
border-radius: 4px;
cursor: pointer;
}
ui-dialog-content dialog {
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px;
width: 100%;
max-width: 500px;
position: relative;
}
ui-dialog-close button {
font-size: 0.8rem;
position: absolute;
top: 10px;
right: 10px;
}
ui-dialog-title h2 {
font-weight: bold;
font-size: 1.4rem;
}
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
/* 開いた状態のダイアログ */
ui-dialog-content dialog[open] {
opacity: 1;
}
/* ※safari対策 */
ui-dialog:not([modal="false"]) ui-dialog-content dialog {
position: fixed;
inset-block: 0;
}
/* 閉じた状態のダイアログ */
ui-dialog-content dialog {
opacity: 0;
transition:
opacity 0.15s ease-out,
overlay 0.15s ease-out allow-discrete,
display 0.15s ease-out allow-discrete;
/* transition: all 0.15s allow-discrete;
と等しい*/
}
/* 開く前の状態 */
/* 詳細度が同じであるため、前の dialog[open] ルールの後に置かなければ効果がありません */
@starting-style {
ui-dialog-content dialog[open] {
opacity: 0;
}
}
/* ダイアログがモーダルで最上位に来た場合に :backdrop をトランジションする */
ui-dialog-content dialog::backdrop {
background-color: rgb(0 0 0 / 0%);
transition:
display 0.15s allow-discrete,
overlay 0.15s allow-discrete,
background-color 0.15s;
/* transition: all 0.15s allow-discrete;
と等しい */
}
ui-dialog-content dialog[open]::backdrop {
background-color: rgb(0 0 0 / 50%);
}
/* この開始スタイル設定ルールは、上記のセレクター内にネストすることができません。
入れ子セレクターは擬似要素を表すことができないからです。 */
@starting-style {
ui-dialog-content dialog[open]::backdrop {
background-color: rgb(0 0 0 / 0%);
}
}
まとめ
この記事では、Web Componentsを使って、Radix UIライクなDialogコンポーネントを実装する方法を解説しました。
Web Componentsの主要な概念(Custom Elements)と、Zustandによる状態管理を組み合わせることで、
フレームワークに依存しない、再利用可能で保守性の高いコンポーネントを作成できることができます。
特にコーポレートweb開発のようなページごとに個別のhtmlやcss、jsが利用され、複数の作業者がいるような環境では便利に使えるのではないでしょうか。
次はTooltipとTabsの作成を予定しています。
以上です。
Discussion