feature: Create comprehensive custom hooks library (WIP)

- Extract 5 new custom hooks:
  * useAutoScroll - Auto-scroll element into view
  * useClickOutside - Detect outside clicks with exclusions
  * useMachinePolling - Dynamic machine status polling
  * useErrorPopoverState - Error popover state management
  * useBluetoothDeviceListener - Bluetooth device discovery

- Reorganize all hooks into categorized folders:
  * utility/ - Generic reusable patterns
  * domain/ - Business logic for embroidery/patterns
  * ui/ - Library integration (Konva)
  * platform/ - Electron/Pyodide specific

- Create barrel exports for clean imports (@/hooks)

- Update components to use new hooks:
  * AppHeader uses useErrorPopoverState
  * ProgressMonitor uses useAutoScroll
  * FileUpload, PatternCanvas use barrel exports

Part 1: Hooks extraction and reorganization
Still TODO: Update remaining components, add tests, add documentation

Related to #40

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-12-27 12:19:12 +01:00
parent 2372278081
commit e1aadc9e1f
20 changed files with 594 additions and 89 deletions

View file

@ -1,8 +1,7 @@
import { useState, useEffect } from "react";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore"; import { useMachineStore } from "../stores/useMachineStore";
import { useUIStore } from "../stores/useUIStore"; import { useUIStore } from "../stores/useUIStore";
import { usePrevious } from "../hooks/usePrevious"; import { useErrorPopoverState } from "@/hooks";
import { WorkflowStepper } from "./WorkflowStepper"; import { WorkflowStepper } from "./WorkflowStepper";
import { ErrorPopoverContent } from "./ErrorPopover"; import { ErrorPopoverContent } from "./ErrorPopover";
import { import {
@ -61,17 +60,16 @@ export function AppHeader() {
})), })),
); );
// State management for error popover auto-open/close // Error popover state management
const [errorPopoverOpen, setErrorPopoverOpen] = useState(false); const {
const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>( isOpen: errorPopoverOpen,
null, handleOpenChange: handlePopoverOpenChange,
); } = useErrorPopoverState({
const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false); machineError,
machineErrorMessage,
// Track previous values for comparison pyodideError,
const prevMachineError = usePrevious(machineError); hasError,
const prevErrorMessage = usePrevious(machineErrorMessage); });
const prevPyodideError = usePrevious(pyodideError);
// Get state visual info for header status badge // Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machineStatus); const stateVisual = getStateVisualInfo(machineStatus);
@ -90,67 +88,6 @@ export function AppHeader() {
? getStatusIndicatorState(machineStatus) ? getStatusIndicatorState(machineStatus)
: "idle"; : "idle";
// Auto-open/close error popover based on error state changes
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
// Check if there's any error now
const hasAnyError =
machineErrorMessage || pyodideError || hasError(machineError);
// Check if there was any error before
const hadAnyError =
prevErrorMessage || prevPyodideError || hasError(prevMachineError);
// Auto-open popover when new error appears (but not if user manually dismissed)
const isNewMachineError =
hasError(machineError) &&
machineError !== prevMachineError &&
machineError !== dismissedErrorCode;
const isNewErrorMessage =
machineErrorMessage && machineErrorMessage !== prevErrorMessage;
const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError;
if (
!wasManuallyDismissed &&
(isNewMachineError || isNewErrorMessage || isNewPyodideError)
) {
setErrorPopoverOpen(true);
}
// Auto-close popover when all errors are cleared
if (!hasAnyError && hadAnyError) {
setErrorPopoverOpen(false);
setDismissedErrorCode(null); // Reset dismissed tracking
setWasManuallyDismissed(false); // Reset manual dismissal flag
}
}, [
machineError,
machineErrorMessage,
pyodideError,
dismissedErrorCode,
wasManuallyDismissed,
prevMachineError,
prevErrorMessage,
prevPyodideError,
]);
/* eslint-enable react-hooks/set-state-in-effect */
// Handle manual popover dismiss
const handlePopoverOpenChange = (open: boolean) => {
setErrorPopoverOpen(open);
// If user manually closes it while any error is present, remember this to prevent reopening
if (
!open &&
(hasError(machineError) || machineErrorMessage || pyodideError)
) {
setWasManuallyDismissed(true);
// Also track the specific machine error code if present
if (hasError(machineError)) {
setDismissedErrorCode(machineError);
}
}
};
return ( return (
<TooltipProvider> <TooltipProvider>
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0"> <header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">

View file

@ -10,9 +10,11 @@ import {
canUploadPattern, canUploadPattern,
getMachineStateCategory, getMachineStateCategory,
} from "../utils/machineStateHelpers"; } from "../utils/machineStateHelpers";
import { useFileUpload } from "../hooks/useFileUpload"; import {
import { usePatternRotationUpload } from "../hooks/usePatternRotationUpload"; useFileUpload,
import { usePatternValidation } from "../hooks/usePatternValidation"; usePatternRotationUpload,
usePatternValidation,
} from "@/hooks";
import { PatternInfoSkeleton } from "./SkeletonLoader"; import { PatternInfoSkeleton } from "./SkeletonLoader";
import { PatternInfo } from "./PatternInfo"; import { PatternInfo } from "./PatternInfo";
import { import {

View file

@ -21,8 +21,7 @@ import { ThreadLegend } from "./ThreadLegend";
import { PatternPositionIndicator } from "./PatternPositionIndicator"; import { PatternPositionIndicator } from "./PatternPositionIndicator";
import { ZoomControls } from "./ZoomControls"; import { ZoomControls } from "./ZoomControls";
import { PatternLayer } from "./PatternLayer"; import { PatternLayer } from "./PatternLayer";
import { useCanvasViewport } from "../../hooks/useCanvasViewport"; import { useCanvasViewport, usePatternTransform } from "@/hooks";
import { usePatternTransform } from "../../hooks/usePatternTransform";
export function PatternCanvas() { export function PatternCanvas() {
// Machine store // Machine store

View file

@ -1,4 +1,5 @@
import { useRef, useEffect, useMemo } from "react"; import { useMemo } from "react";
import { useAutoScroll } from "@/hooks";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore"; import { useMachineStore } from "../stores/useMachineStore";
import { usePatternStore } from "../stores/usePatternStore"; import { usePatternStore } from "../stores/usePatternStore";
@ -54,7 +55,6 @@ export function ProgressMonitor() {
const pesData = usePatternStore((state) => state.pesData); const pesData = usePatternStore((state) => state.pesData);
const uploadedPesData = usePatternStore((state) => state.uploadedPesData); const uploadedPesData = usePatternStore((state) => state.uploadedPesData);
const displayPattern = uploadedPesData || pesData; const displayPattern = uploadedPesData || pesData;
const currentBlockRef = useRef<HTMLDivElement>(null);
// State indicators // State indicators
const isMaskTraceComplete = const isMaskTraceComplete =
@ -127,14 +127,7 @@ export function ProgressMonitor() {
}, [colorBlocks, currentStitch]); }, [colorBlocks, currentStitch]);
// Auto-scroll to current block // Auto-scroll to current block
useEffect(() => { const currentBlockRef = useAutoScroll(currentBlockIndex);
if (currentBlockRef.current) {
currentBlockRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [currentBlockIndex]);
return ( return (
<Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden"> <Card className="p-0 gap-0 lg:h-full border-l-4 border-accent-600 dark:border-accent-500 flex flex-col lg:overflow-hidden">

12
src/hooks/domain/index.ts Normal file
View file

@ -0,0 +1,12 @@
export { usePatternValidation } from "./usePatternValidation";
export { usePatternRotationUpload } from "./usePatternRotationUpload";
export { useMachinePolling } from "./useMachinePolling";
export { useErrorPopoverState } from "./useErrorPopoverState";
export type {
UseMachinePollingOptions,
UseMachinePollingReturn,
} from "./useMachinePolling";
export type {
UseErrorPopoverStateOptions,
UseErrorPopoverStateReturn,
} from "./useErrorPopoverState";

View file

@ -0,0 +1,137 @@
/**
* useErrorPopoverState Hook
*
* Manages error popover state with sophisticated auto-open/close behavior.
* Automatically opens when new errors appear and closes when all errors are cleared.
* Tracks manual dismissal to prevent reopening for the same error.
*
* This hook is designed for multi-source error handling (e.g., machine errors,
* pyodide errors, error messages) and provides a consistent UX for error notification.
*
* @param options - Configuration options
* @param options.machineError - Current machine error code
* @param options.machineErrorMessage - Current machine error message
* @param options.pyodideError - Current Pyodide error message
* @param options.hasError - Function to check if an error code represents an error
* @returns Object containing popover state and control functions
*
* @example
* ```tsx
* const { isOpen, handleOpenChange } = useErrorPopoverState({
* machineError,
* machineErrorMessage,
* pyodideError,
* hasError: (code) => code !== 0 && code !== undefined
* });
*
* return (
* <Popover open={isOpen} onOpenChange={handleOpenChange}>
* <PopoverContent>{errorMessage}</PopoverContent>
* </Popover>
* );
* ```
*/
import { useState, useEffect } from "react";
import { usePrevious } from "../utility/usePrevious";
export interface UseErrorPopoverStateOptions {
machineError: number | undefined;
machineErrorMessage: string | null;
pyodideError: string | null;
hasError: (error: number | undefined) => boolean;
}
export interface UseErrorPopoverStateReturn {
isOpen: boolean;
handleOpenChange: (open: boolean) => void;
dismissedErrorCode: number | null;
wasManuallyDismissed: boolean;
}
export function useErrorPopoverState(
options: UseErrorPopoverStateOptions,
): UseErrorPopoverStateReturn {
const { machineError, machineErrorMessage, pyodideError, hasError } = options;
// Internal state
const [isOpen, setIsOpen] = useState(false);
const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>(
null,
);
const [wasManuallyDismissed, setWasManuallyDismissed] = useState(false);
// Track previous values for comparison
const prevMachineError = usePrevious(machineError);
const prevErrorMessage = usePrevious(machineErrorMessage);
const prevPyodideError = usePrevious(pyodideError);
// Auto-open/close logic
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
// Check if there's any error now
const hasAnyError =
machineErrorMessage || pyodideError || hasError(machineError);
// Check if there was any error before
const hadAnyError =
prevErrorMessage || prevPyodideError || hasError(prevMachineError);
// Auto-open popover when new error appears (but not if user manually dismissed)
const isNewMachineError =
hasError(machineError) &&
machineError !== prevMachineError &&
machineError !== dismissedErrorCode;
const isNewErrorMessage =
machineErrorMessage && machineErrorMessage !== prevErrorMessage;
const isNewPyodideError = pyodideError && pyodideError !== prevPyodideError;
if (
!wasManuallyDismissed &&
(isNewMachineError || isNewErrorMessage || isNewPyodideError)
) {
setIsOpen(true);
}
// Auto-close popover when all errors are cleared
if (!hasAnyError && hadAnyError) {
setIsOpen(false);
setDismissedErrorCode(null); // Reset dismissed tracking
setWasManuallyDismissed(false); // Reset manual dismissal flag
}
}, [
machineError,
machineErrorMessage,
pyodideError,
dismissedErrorCode,
wasManuallyDismissed,
prevMachineError,
prevErrorMessage,
prevPyodideError,
hasError,
]);
/* eslint-enable react-hooks/set-state-in-effect */
// Handle manual popover dismiss
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
// If user manually closes it while any error is present, remember this to prevent reopening
if (
!open &&
(hasError(machineError) || machineErrorMessage || pyodideError)
) {
setWasManuallyDismissed(true);
// Also track the specific machine error code if present
if (hasError(machineError)) {
setDismissedErrorCode(machineError);
}
}
};
return {
isOpen,
handleOpenChange,
dismissedErrorCode,
wasManuallyDismissed,
};
}

View file

@ -0,0 +1,174 @@
/**
* useMachinePolling Hook
*
* Implements dynamic polling for machine status based on machine state.
* Uses adaptive polling intervals and conditional progress polling during sewing.
*
* Polling intervals:
* - 500ms for active states (SEWING, MASK_TRACING, SEWING_DATA_RECEIVE)
* - 1000ms for waiting states (COLOR_CHANGE_WAIT, MASK_TRACE_LOCK_WAIT, SEWING_WAIT)
* - 2000ms for idle/other states
*
* Additionally polls service count every 10 seconds.
*
* @param options - Configuration options
* @param options.machineStatus - Current machine status to determine polling interval
* @param options.patternInfo - Current pattern info for resumable pattern check
* @param options.onStatusRefresh - Callback to refresh machine status
* @param options.onProgressRefresh - Callback to refresh sewing progress
* @param options.onServiceCountRefresh - Callback to refresh service count
* @param options.onPatternInfoRefresh - Callback to refresh pattern info
* @param options.shouldCheckResumablePattern - Function to check if resumable pattern exists
* @returns Object containing start/stop functions and polling state
*
* @example
* ```tsx
* const { startPolling, stopPolling, isPolling } = useMachinePolling({
* machineStatus,
* patternInfo,
* onStatusRefresh: async () => { ... },
* onProgressRefresh: async () => { ... },
* onServiceCountRefresh: async () => { ... },
* onPatternInfoRefresh: async () => { ... },
* shouldCheckResumablePattern: () => resumeAvailable
* });
*
* useEffect(() => {
* startPolling();
* return () => stopPolling();
* }, []);
* ```
*/
import { useState, useCallback, useRef, useEffect } from "react";
import { MachineStatus } from "../../types/machine";
import type { PatternInfo } from "../../types/machine";
export interface UseMachinePollingOptions {
machineStatus: MachineStatus;
patternInfo: PatternInfo | null;
onStatusRefresh: () => Promise<void>;
onProgressRefresh: () => Promise<void>;
onServiceCountRefresh: () => Promise<void>;
onPatternInfoRefresh: () => Promise<void>;
shouldCheckResumablePattern: () => boolean;
}
export interface UseMachinePollingReturn {
startPolling: () => void;
stopPolling: () => void;
isPolling: boolean;
}
export function useMachinePolling(
options: UseMachinePollingOptions,
): UseMachinePollingReturn {
const {
machineStatus,
patternInfo,
onStatusRefresh,
onProgressRefresh,
onServiceCountRefresh,
onPatternInfoRefresh,
shouldCheckResumablePattern,
} = options;
const [isPolling, setIsPolling] = useState(false);
const pollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const serviceCountIntervalRef = useRef<NodeJS.Timeout | null>(null);
const pollFunctionRef = useRef<() => Promise<void>>();
// Function to determine polling interval based on machine status
const getPollInterval = useCallback((status: MachineStatus) => {
// Fast polling for active states
if (
status === MachineStatus.SEWING ||
status === MachineStatus.MASK_TRACING ||
status === MachineStatus.SEWING_DATA_RECEIVE
) {
return 500;
} else if (
status === MachineStatus.COLOR_CHANGE_WAIT ||
status === MachineStatus.MASK_TRACE_LOCK_WAIT ||
status === MachineStatus.SEWING_WAIT
) {
return 1000;
}
return 2000; // Default for idle states
}, []);
// Main polling function
const poll = useCallback(async () => {
await onStatusRefresh();
// Refresh progress during sewing
if (machineStatus === MachineStatus.SEWING) {
await onProgressRefresh();
}
// Check if we have a cached pattern and pattern info needs refreshing
// This follows the app's logic for resumable patterns
if (shouldCheckResumablePattern() && patternInfo?.totalStitches === 0) {
await onPatternInfoRefresh();
}
// Schedule next poll with updated interval
const newInterval = getPollInterval(machineStatus);
if (pollFunctionRef.current) {
pollTimeoutRef.current = setTimeout(pollFunctionRef.current, newInterval);
}
}, [
machineStatus,
patternInfo,
onStatusRefresh,
onProgressRefresh,
onPatternInfoRefresh,
shouldCheckResumablePattern,
getPollInterval,
]);
// Store poll function in ref for recursive setTimeout
useEffect(() => {
pollFunctionRef.current = poll;
}, [poll]);
const stopPolling = useCallback(() => {
if (pollTimeoutRef.current) {
clearTimeout(pollTimeoutRef.current);
pollTimeoutRef.current = null;
}
if (serviceCountIntervalRef.current) {
clearInterval(serviceCountIntervalRef.current);
serviceCountIntervalRef.current = null;
}
setIsPolling(false);
}, []);
const startPolling = useCallback(() => {
// Stop any existing polling
stopPolling();
// Start main polling
const initialInterval = getPollInterval(machineStatus);
pollTimeoutRef.current = setTimeout(poll, initialInterval);
// Start service count polling (every 10 seconds)
serviceCountIntervalRef.current = setInterval(onServiceCountRefresh, 10000);
setIsPolling(true);
}, [
machineStatus,
poll,
stopPolling,
getPollInterval,
onServiceCountRefresh,
]);
return {
startPolling,
stopPolling,
isPolling,
};
}

11
src/hooks/index.ts Normal file
View file

@ -0,0 +1,11 @@
// Utility Hooks - Generic, reusable patterns
export * from "./utility";
// Domain Hooks - Business logic for embroidery/pattern operations
export * from "./domain";
// UI Hooks - Library/framework integration (Konva, etc.)
export * from "./ui";
// Platform Hooks - Electron/Pyodide specific functionality
export * from "./platform";

View file

@ -0,0 +1,3 @@
export { useFileUpload } from "./useFileUpload";
export { useBluetoothDeviceListener } from "./useBluetoothDeviceListener";
export type { UseBluetoothDeviceListenerReturn } from "./useBluetoothDeviceListener";

View file

@ -0,0 +1,90 @@
/**
* useBluetoothDeviceListener Hook
*
* Listens for Bluetooth device discovery events from Electron IPC.
* Automatically manages device list state and provides platform detection.
*
* This hook is Electron-specific and will gracefully handle browser environments
* by returning empty state.
*
* @param onDevicesChanged - Optional callback when device list changes
* @returns Object containing devices array, scanning state, and platform support flag
*
* @example
* ```tsx
* const { devices, isScanning, isSupported } = useBluetoothDeviceListener(
* (devices) => {
* if (devices.length > 0) {
* console.log('Devices found:', devices);
* }
* }
* );
*
* if (!isSupported) {
* return <div>Bluetooth pairing only available in Electron app</div>;
* }
*
* return (
* <div>
* {isScanning && <p>Scanning...</p>}
* {devices.map(device => <div key={device.id}>{device.name}</div>)}
* </div>
* );
* ```
*/
import { useEffect, useState } from "react";
import type { BluetoothDevice } from "../../types/electron";
export interface UseBluetoothDeviceListenerReturn {
devices: BluetoothDevice[];
isScanning: boolean;
isSupported: boolean;
}
export function useBluetoothDeviceListener(
onDevicesChanged?: (devices: BluetoothDevice[]) => void,
): UseBluetoothDeviceListenerReturn {
const [devices, setDevices] = useState<BluetoothDevice[]>([]);
const [isScanning, setIsScanning] = useState(false);
// Check if Electron API is available
const isSupported =
typeof window !== "undefined" &&
!!window.electronAPI?.onBluetoothDeviceList;
useEffect(() => {
// Only set up listener in Electron
if (!isSupported) {
return;
}
const handleDeviceList = (deviceList: BluetoothDevice[]) => {
setDevices(deviceList);
// Start scanning when first update received
if (deviceList.length === 0) {
setIsScanning(true);
} else {
// Stop showing scanning state once we have devices
setIsScanning(false);
}
// Call optional callback
onDevicesChanged?.(deviceList);
};
// Register listener
window.electronAPI!.onBluetoothDeviceList(handleDeviceList);
// Note: Electron IPC listeners are typically not cleaned up individually
// as they're meant to persist. If cleanup is needed, the Electron main
// process should handle it.
}, [isSupported, onDevicesChanged]);
return {
devices,
isScanning,
isSupported,
};
}

2
src/hooks/ui/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { useCanvasViewport } from "./useCanvasViewport";
export { usePatternTransform } from "./usePatternTransform";

View file

@ -0,0 +1,5 @@
export { usePrevious } from "./usePrevious";
export { useAutoScroll } from "./useAutoScroll";
export { useClickOutside } from "./useClickOutside";
export type { UseAutoScrollOptions } from "./useAutoScroll";
export type { UseClickOutsideOptions } from "./useClickOutside";

View file

@ -0,0 +1,51 @@
/**
* useAutoScroll Hook
*
* Automatically scrolls an element into view when a dependency changes.
* Useful for keeping the current item visible in scrollable lists.
*
* @param dependency - The value to watch for changes (e.g., current index)
* @param options - Scroll behavior options
* @returns RefObject to attach to the element that should be scrolled into view
*
* @example
* ```tsx
* const currentItemRef = useAutoScroll(currentIndex, {
* behavior: "smooth",
* block: "nearest"
* });
*
* return (
* <div ref={isCurrent ? currentItemRef : null}>
* Current Item
* </div>
* );
* ```
*/
import { useEffect, useRef, type RefObject } from "react";
export interface UseAutoScrollOptions {
behavior?: ScrollBehavior;
block?: ScrollLogicalPosition;
inline?: ScrollLogicalPosition;
}
export function useAutoScroll<T extends HTMLElement>(
dependency: unknown,
options?: UseAutoScrollOptions,
): RefObject<T> {
const ref = useRef<T>(null);
useEffect(() => {
if (ref.current) {
ref.current.scrollIntoView({
behavior: options?.behavior || "smooth",
block: options?.block || "nearest",
inline: options?.inline,
});
}
}, [dependency, options?.behavior, options?.block, options?.inline]);
return ref;
}

View file

@ -0,0 +1,89 @@
/**
* useClickOutside Hook
*
* Detects clicks outside a referenced element and executes a handler function.
* Useful for closing dropdown menus, popovers, modals, and other overlay UI elements.
*
* @param ref - Reference to the element to monitor for outside clicks
* @param handler - Callback function to execute when outside click is detected
* @param options - Configuration options
* @param options.enabled - Whether the listener is active (default: true)
* @param options.excludeRefs - Array of refs that should not trigger the handler when clicked
*
* @example
* ```tsx
* const dropdownRef = useRef<HTMLDivElement>(null);
* const buttonRef = useRef<HTMLButtonElement>(null);
*
* useClickOutside(
* dropdownRef,
* () => setIsOpen(false),
* {
* enabled: isOpen,
* excludeRefs: [buttonRef] // Don't close when clicking the button
* }
* );
*
* return (
* <>
* <button ref={buttonRef}>Toggle</button>
* {isOpen && <div ref={dropdownRef}>Content</div>}
* </>
* );
* ```
*/
import { useEffect, type RefObject } from "react";
export interface UseClickOutsideOptions {
enabled?: boolean;
excludeRefs?: (
| RefObject<HTMLElement>
| { current: Record<string, HTMLElement | null> }
)[];
}
export function useClickOutside<T extends HTMLElement>(
ref: RefObject<T>,
handler: (event: MouseEvent) => void,
options?: UseClickOutsideOptions,
): void {
const { enabled = true, excludeRefs = [] } = options || {};
useEffect(() => {
if (!enabled) return;
const handleClickOutside = (event: MouseEvent) => {
// Check if click is outside the main ref
if (ref.current && !ref.current.contains(event.target as Node)) {
// Check if click is on any excluded refs
const clickedExcluded = excludeRefs.some((excludeRef) => {
if (!excludeRef.current) return false;
// Handle object of refs (e.g., { [key: number]: HTMLElement | null })
if (
typeof excludeRef.current === "object" &&
!("nodeType" in excludeRef.current)
) {
return Object.values(excludeRef.current).some((element) =>
element?.contains(event.target as Node),
);
}
// Handle single ref
return (excludeRef.current as HTMLElement).contains(
event.target as Node,
);
});
// Only call handler if click was not on excluded elements
if (!clickedExcluded) {
handler(event);
}
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [ref, handler, enabled, excludeRefs]);
}