Last active
February 27, 2025 02:07
-
-
Save manabuyasuda/e399d1aa69f2d3463a74950b1b1d60f0 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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