目的
modal内でportalを使って自作のUIを作る場合、
自作のUIはmodalのDOMツリーの外に出てしまうため自作UIをクリックすると外側を押したと判定される。
今回は内側と判定するDOMを複数指定することで問題を回避する
import { useCallbackRef } from '@chakra-ui/react';
import { useEffect, useRef } from 'react';
export type UseMultipleOutsideClickProps = {
/**
* フックが有効かどうかを制御するフラグ
*/
enabled?: boolean;
/**
* 監視対象のDOM要素への参照配列
*/
refs: React.RefObject<HTMLElement>[];
/**
* すべての監視対象要素の外側でクリックが発生した時に実行されるコールバック関数
*/
handler?: (e: Event) => void;
};
/**
* 複数の要素の外側クリックを管理するカスタムフック
* 使用例: ネストされたモーダルや複雑なドロップダウンシステムで、
* 複数の要素の外側クリックを監視する必要がある場合に使用
*/
export function useMultipleOutsideClick(props: UseMultipleOutsideClickProps) {
const { refs, handler, enabled = true } = props;
// handlerの参照を保持し、更新時も安定した参照を維持
const savedHandler = useCallbackRef(handler);
// クリックイベントの状態を管理するための参照
const stateRef = useRef({
isPointerDown: false, // ポインターが押下されているかどうか
ignoreEmulatedMouseEvents: false, // タッチイベント後のマウスイベントを無視するためのフラグ
});
const state = stateRef.current;
useEffect(() => {
// フックが無効な場合は何もしない
if (!enabled) return;
// ポインターが押下された時の処理
const onPointerDown = (e: Event) => {
if (isValidEvent(e, refs)) {
state.isPointerDown = true;
}
};
// マウスボタンが離された時の処理
const onMouseUp = (event: MouseEvent) => {
// タッチイベント後の余分なマウスイベントを無視
if (state.ignoreEmulatedMouseEvents) {
state.ignoreEmulatedMouseEvents = false;
return;
}
// ポインターが押下されており、かつ有効なイベントの場合にハンドラーを実行
if (state.isPointerDown && handler && isValidEvent(event, refs)) {
state.isPointerDown = false;
savedHandler(event);
}
};
// タッチが終了した時の処理
const onTouchEnd = (event: TouchEvent) => {
// タッチ後のマウスイベントを無視するためにフラグを設定
state.ignoreEmulatedMouseEvents = true;
if (handler && state.isPointerDown && isValidEvent(event, refs)) {
state.isPointerDown = false;
savedHandler(event);
}
};
// 最初のrefからドキュメントを取得、なければデフォルトのdocumentを使用
const doc = getOwnerDocument(refs[0]?.current);
// イベントリスナーの登録
// captureフェーズで実行するため第3引数にtrueを指定
doc.addEventListener('mousedown', onPointerDown, true);
doc.addEventListener('mouseup', onMouseUp, true);
doc.addEventListener('touchstart', onPointerDown, true);
doc.addEventListener('touchend', onTouchEnd, true);
// クリーンアップ関数:イベントリスナーの削除
return () => {
doc.removeEventListener('mousedown', onPointerDown, true);
doc.removeEventListener('mouseup', onMouseUp, true);
doc.removeEventListener('touchstart', onPointerDown, true);
doc.removeEventListener('touchend', onTouchEnd, true);
};
}, [handler, refs, savedHandler, state, enabled]);
}
/**
* イベントのターゲットが監視対象の要素の外側にあるかチェックする関数
* @param event - 発生したイベント
* @param refs - 監視対象の要素への参照配列
* @returns すべての監視対象要素の外側でイベントが発生した場合はtrue
*/
function isValidEvent(event: Event, refs: React.RefObject<HTMLElement>[]) {
// イベントのターゲットを取得(composedPathがある場合はそれを使用)
const target = (event.composedPath?.()[0] ?? event.target) as HTMLElement;
if (target) {
// ターゲットが属するドキュメントを取得し、
// ターゲットがドキュメント内に存在するかチェック
const doc = getOwnerDocument(target);
if (!doc.contains(target)) return false;
}
// すべての監視対象要素についてターゲットが含まれていないことを確認
// 一つでも含まれている場合はfalseを返す
return refs.every((ref) => !ref.current?.contains(target));
}
/**
* 要素が属するドキュメントを取得する補助関数
* @param node - ドキュメントを取得したい要素
* @returns 要素が属するDocument、要素がない場合はグローバルのdocument
*/
function getOwnerDocument(node?: Element | null): Document {
return node?.ownerDocument ?? document;
}