Skip to content

Instantly share code, notes, and snippets.

@manabuyasuda
Last active February 27, 2025 02:07
Show Gist options
  • Save manabuyasuda/e399d1aa69f2d3463a74950b1b1d60f0 to your computer and use it in GitHub Desktop.
Save manabuyasuda/e399d1aa69f2d3463a74950b1b1d60f0 to your computer and use it in GitHub Desktop.
import { useEffect, useCallback, useRef, useState } from 'react';
/**
* 開閉処理以外のオプション設定。
*/
type ModalOptions = {
/** Escキーでモーダルを閉じるかどうか @default true */
closeOnEsc?: boolean;
/** フォーカストラップを有効にするかどうか @default true */
enableFocusTrap?: boolean;
/** フォーカス可能な要素のセレクタ @default 標準的なフォーカス可能要素 */
focusableSelector?: string;
/** オーバーレイをクリックしてモーダルを閉じるかどうか @default true */
closeOnOverlayClick?: boolean;
/** 不可視要素をフォーカス可能な要素から除外するかどうか @default false */
excludeInvisibleElements?: boolean;
/** モーダルの目的を説明するラベル(スクリーンリーダー用) */
label?: string;
};
/**
* モーダルのプロパティ。
*/
type UseModalProps = {
/** モーダルの表示状態 */
isOpen: boolean;
/** モーダルを閉じる時のコールバック関数 */
onClose: () => void;
/** モーダルを開いた要素のref */
triggerRef?: React.RefObject<HTMLElement>;
/** オプション設定 */
options?: ModalOptions;
};
/**
* モーダルの戻り値。
*/
type UseModalReturn = {
/** モーダル要素に設定する属性 */
modalProps: {
ref: React.RefObject<HTMLDivElement>;
role: 'dialog';
'aria-modal': true;
'aria-label'?: string;
};
/** オーバーレイ要素に設定するイベントハンドラー */
overlayProps: {
onClick: (e: React.MouseEvent) => void;
};
/** 閉じるボタン要素に設定するイベントハンドラー */
closeButtonProps: {
onClick: () => void;
};
};
/**
* デフォルトのフォーカス可能な要素のセレクタ。
*/
const DEFAULT_FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'details',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
/**
* モーダルのロジック部分を管理するカスタムフックです。
* dialogタグを使わずにモーダルの開閉やフォーカス管理、属性の管理をします。
* WAI-ARIAのダイアログパターンに準拠しています(aria-labelを使用している点だけ異なる)。
* https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
*
* @example
* // モーダルコンポーネント
* function ExampleModal({ isOpen, onClose, triggerRef }) {
* const { modalProps, overlayProps, closeButtonProps } = useModal({
* isOpen,
* onClose,
* triggerRef,
* options: {
* label: '商品の詳細情報',
* closeOnEsc: true, // Escキーでモーダルを閉じる
* enableFocusTrap: true, // フォーカストラップを有効にする
* closeOnOverlayClick: true, // オーバーレイをクリックしてモーダルを閉じる
* excludeInvisibleElements: false, // 不可視要素をフォーカス可能な要素から除外する
* },
* });
*
* return (
* <>
* {isOpen && createPortal(
* <div className="styles.overlay" {...overlayProps}>
* <div className="styles.modal" {...modalProps}>
* <button {...closeButtonProps}>閉じる</button>
* <div>コンテンツ...</div>
* </div>
* </div>,
* document.body
* )}
* </>
* );
* }
*
* // モーダルを使用するコンテナコンポーネント
* function ExampleModalContainer() {
* const [isOpen, setIsOpen] = useState(false);
* const triggerRef = useRef<HTMLDivElement>(null);
*
* return (
* <div className="modal-container">
* <h1>商品詳細ページ</h1>
*
* <button
* ref={triggerRef}
* onClick={() => setIsOpen(true)}
* className="detail-button"
* >
* 詳細を見る
* </button>
*
* <ExampleModal
* isOpen={isOpen}
* onClose={() => setIsOpen(false)}
* triggerRef={triggerRef}
* />
* </div>
* );
* }
*/
export const useModal = ({
isOpen,
onClose,
triggerRef,
options = {},
}: UseModalProps): UseModalReturn => {
const {
closeOnEsc = true,
enableFocusTrap = true,
focusableSelector = DEFAULT_FOCUSABLE_SELECTOR,
closeOnOverlayClick = true,
excludeInvisibleElements = false,
label,
} = options;
// モーダル要素への参照
const modalRef = useRef<HTMLDivElement>(null);
// モーダルを開いた要素への参照
const triggerFocusRef = useRef<HTMLElement | null>(null);
/**
* 祖先要素までさかのぼって、要素が視覚的に表示可能かどうかを判定します。
* `excludeInvisibleElements`を`true`にした場合にだけ実行されます。
* 計算量が多くなる可能性があるので、モーダルは`createPortal`で生成してください。
*/
const isElementVisible = useCallback((element: HTMLElement): boolean => {
let current: HTMLElement | null = element;
// document.bodyまで全ての親要素をチェック
while (current && current !== document.body) {
const style = window.getComputedStyle(current);
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0'
) {
return false;
}
current = current.parentElement;
}
return true;
}, []);
/**
* フォーカス可能な要素を取得します。
*/
const getFocusableElements = useCallback(() => {
const modal = modalRef.current;
if (!modal) return [];
const elements = Array.from(
modal.querySelectorAll(focusableSelector),
).filter(
(element): element is HTMLElement => element instanceof HTMLElement,
);
// 不可視要素がある場合は、その要素を除外する
return excludeInvisibleElements
? elements.filter(isElementVisible)
: elements;
}, [focusableSelector, excludeInvisibleElements, isElementVisible]);
/**
* フォーカスを設定する関数です。
* DOMの更新を待ってからフォーカスを設定します。
*/
const setFocus = useCallback((element: HTMLElement | null) => {
if (!element) return;
requestAnimationFrame(() => {
element.focus();
});
}, []);
/**
* onClose関数を呼び出してモーダルを閉じてから、モーダルを開いた要素にフォーカスを戻します。
*/
const handleClose = useCallback(() => {
onClose();
setFocus(triggerFocusRef.current);
}, [onClose, setFocus]);
/**
* クリックされた要素がモーダル内の要素かどうかを判定し、モーダル外であればモーダルを閉じます。
*/
const handleOverlayClick = useCallback(
(e: React.MouseEvent) => {
if (!closeOnOverlayClick) return;
const modalElement = modalRef.current;
if (!modalElement?.contains(e.target as HTMLElement)) {
handleClose();
}
},
[closeOnOverlayClick, handleClose],
);
/**
* フォーカストラップを制御して、フォーカスがモーダル内で循環するようにします。
*/
const handleFocusTrap = useCallback(
(e: KeyboardEvent) => {
const focusableElements = getFocusableElements();
const currentFocus = document.activeElement;
// フォーカス可能な要素がない場合は処理を終了
if (focusableElements.length === 0 || !currentFocus) return;
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
const isShiftTab = e.shiftKey;
const isFirstElement = currentFocus === firstFocusable;
const isLastElement = currentFocus === lastFocusable;
// 最初の要素でShift+Tabを押した場合、最後の要素にフォーカスを移動する
if (isFirstElement && isShiftTab) {
e.preventDefault();
setFocus(lastFocusable);
// 最後の要素でTabを押した場合、最初の要素にフォーカスを移動する
} else if (isLastElement && !isShiftTab) {
e.preventDefault();
setFocus(firstFocusable);
}
},
[getFocusableElements, setFocus],
);
/**
* キーボードイベントによって処理を分岐させます。
*/
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!isOpen) return;
switch (e.key) {
// Escキーが押された場合はモーダルを閉じる
case 'Escape':
if (closeOnEsc) {
handleClose();
}
break;
// Tabキーが押された場合はフォーカストラップを実行する
case 'Tab':
if (enableFocusTrap) {
handleFocusTrap(e);
}
break;
default:
break;
}
},
[isOpen, closeOnEsc, enableFocusTrap, handleClose, handleFocusTrap],
);
/**
* モーダルを開く場合は最初のフォーカス可能な要素に、閉じる場合はモーダルを開いた要素にフォーカスを設定します。
*/
useEffect(() => {
if (isOpen) {
// モーダルを開いた要素を保持する
triggerFocusRef.current = triggerRef?.current ?? null;
// モーダルのマウント完了を待ってフォーカスを設定する
const setupFocusControl = () => {
const modal = modalRef.current;
if (!modal) {
// モーダルが未マウントの場合は次のフレームで再試行する
requestAnimationFrame(setupFocusControl);
return;
}
const focusableElements = getFocusableElements();
// モーダルが存在する場合、フォーカス可能な最初の要素にフォーカスを設定する
if (focusableElements.length > 0) {
setFocus(focusableElements[0]);
}
};
// フォーカス設定を開始する
requestAnimationFrame(setupFocusControl);
} else {
// モーダルを開いた要素にフォーカスを戻す
setFocus(triggerFocusRef.current);
}
}, [isOpen, getFocusableElements, triggerRef, setFocus]);
/**
* キーボードイベントリスナーを設定します。
*/
useEffect(() => {
if (!isOpen) return;
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, handleKeyDown]);
return {
modalProps: {
ref: modalRef,
role: 'dialog',
'aria-modal': true,
...(label ? { 'aria-label': label } : {}),
},
overlayProps: {
onClick: handleOverlayClick,
},
closeButtonProps: {
onClick: handleClose,
},
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment