/** * useClickOutside Hook * Detect clicks outside a referenced element and manage open state * * Features: * - Track element reference with useRef * - Detect clicks outside element * - Support for multiple target nodes * - Optional callback on outside click * - Automatic cleanup * - Support for both mouse and touch events * - Respects disabled state * * @example * // Basic usage with dialog * const { ref, isOpen, setIsOpen } = useClickOutside() * * return ( *
* * {isOpen && ( * * Item 1 * Item 2 * * )} *
* ) * * @example * // With callback * const { ref, isOpen, setIsOpen } = useClickOutside({ * onClickOutside: () => console.log('Closed from outside click'), * excludeRefs: [triggerButtonRef] * }) * * @example * // Close dropdown when clicking outside * const { ref, isOpen, setIsOpen } = useClickOutside() * * useEffect(() => { * if (!isOpen) return * const handleClick = () => setIsOpen(false) * // Dropdown closes when clicking outside * }, [isOpen]) */ import { useRef, useState, useCallback, useEffect } from 'react' export interface UseClickOutsideOptions { /** Callback when click occurs outside element */ onClickOutside?: () => void /** Additional refs to exclude from outside click detection */ excludeRefs?: React.RefObject[] /** Include touch events in addition to mouse events */ includeTouch?: boolean /** Delay before detecting outside click (ms) */ delayMs?: number } export interface UseClickOutsideReturn { /** Ref to attach to the target element */ ref: React.RefObject /** Whether the element is currently open/visible */ isOpen: boolean /** Set the open state */ setIsOpen: (open: boolean) => void /** Toggle the open state */ toggle: () => void } export function useClickOutside( options: UseClickOutsideOptions = {} ): UseClickOutsideReturn { const { onClickOutside, excludeRefs = [], includeTouch = true, delayMs = 0, } = options const ref = useRef(null) const [isOpen, setIsOpen] = useState(false) const delayTimeoutRef = useRef | undefined>(undefined) const toggle = useCallback(() => { setIsOpen((prev) => !prev) }, []) useEffect(() => { if (!isOpen) return const handleClickOutside = (event: MouseEvent | TouchEvent) => { // Check if click is outside the main ref if (ref.current?.contains(event.target as Node)) { return } // Check if click is within any excluded refs const isWithinExcluded = excludeRefs.some((excludeRef) => excludeRef.current?.contains(event.target as Node) ) if (isWithinExcluded) { return } // Handle delay if (delayMs > 0) { if (delayTimeoutRef.current) { clearTimeout(delayTimeoutRef.current) } delayTimeoutRef.current = setTimeout(() => { setIsOpen(false) onClickOutside?.() }, delayMs) } else { setIsOpen(false) onClickOutside?.() } } // Add event listeners document.addEventListener('mousedown', handleClickOutside) if (includeTouch) { document.addEventListener('touchstart', handleClickOutside) } // Cleanup return () => { document.removeEventListener('mousedown', handleClickOutside) if (includeTouch) { document.removeEventListener('touchstart', handleClickOutside) } if (delayTimeoutRef.current) { clearTimeout(delayTimeoutRef.current) } } }, [isOpen, excludeRefs, delayMs, includeTouch, onClickOutside]) return { ref, isOpen, setIsOpen, toggle, } }