@usefy/use-on-click-outside 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,536 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/geon0529/usefy/master/assets/logo.png" alt="usefy logo" width="120" />
3
+ </p>
4
+
5
+ <h1 align="center">@usefy/use-on-click-outside</h1>
6
+
7
+ <p align="center">
8
+ <strong>A React hook for detecting clicks outside of specified elements</strong>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/@usefy/use-on-click-outside">
13
+ <img src="https://img.shields.io/npm/v/@usefy/use-on-click-outside.svg?style=flat-square&color=007acc" alt="npm version" />
14
+ </a>
15
+ <a href="https://www.npmjs.com/package/@usefy/use-on-click-outside">
16
+ <img src="https://img.shields.io/npm/dm/@usefy/use-on-click-outside.svg?style=flat-square&color=007acc" alt="npm downloads" />
17
+ </a>
18
+ <a href="https://bundlephobia.com/package/@usefy/use-on-click-outside">
19
+ <img src="https://img.shields.io/bundlephobia/minzip/@usefy/use-on-click-outside?style=flat-square&color=007acc" alt="bundle size" />
20
+ </a>
21
+ <a href="https://github.com/geon0529/usefy/blob/master/LICENSE">
22
+ <img src="https://img.shields.io/npm/l/@usefy/use-on-click-outside.svg?style=flat-square&color=007acc" alt="license" />
23
+ </a>
24
+ </p>
25
+
26
+ <p align="center">
27
+ <a href="#installation">Installation</a> •
28
+ <a href="#quick-start">Quick Start</a> •
29
+ <a href="#api-reference">API Reference</a> •
30
+ <a href="#examples">Examples</a> •
31
+ <a href="#license">License</a>
32
+ </p>
33
+
34
+ ---
35
+
36
+ ## Overview
37
+
38
+ `@usefy/use-on-click-outside` detects clicks outside of specified element(s) and calls your handler. Perfect for closing modals, dropdowns, popovers, tooltips, and any UI component that should dismiss when clicking elsewhere.
39
+
40
+ **Part of the [@usefy](https://www.npmjs.com/org/usefy) ecosystem** — a collection of production-ready React hooks designed for modern applications.
41
+
42
+ ### Why use-on-click-outside?
43
+
44
+ - **Zero Dependencies** — Pure React implementation with no external dependencies
45
+ - **TypeScript First** — Full type safety with exported interfaces
46
+ - **Multiple Refs Support** — Pass a single ref or an array of refs (e.g., button + dropdown)
47
+ - **Exclude Elements** — Exclude specific elements from triggering the handler via `excludeRefs` or `shouldExclude`
48
+ - **Mouse + Touch Support** — Handles both `mousedown` and `touchstart` events for mobile compatibility
49
+ - **Capture Phase** — Uses capture phase by default to avoid `stopPropagation` issues
50
+ - **Conditional Activation** — Enable/disable via the `enabled` option
51
+ - **Handler Stability** — No re-registration when handler changes
52
+ - **SSR Compatible** — Works seamlessly with Next.js, Remix, and other SSR frameworks
53
+ - **Well Tested** — 97.61% test coverage with Vitest
54
+
55
+ ---
56
+
57
+ ## Installation
58
+
59
+ ```bash
60
+ # npm
61
+ npm install @usefy/use-on-click-outside
62
+
63
+ # yarn
64
+ yarn add @usefy/use-on-click-outside
65
+
66
+ # pnpm
67
+ pnpm add @usefy/use-on-click-outside
68
+ ```
69
+
70
+ ### Peer Dependencies
71
+
72
+ This package requires React 18 or 19:
73
+
74
+ ```json
75
+ {
76
+ "peerDependencies": {
77
+ "react": "^18.0.0 || ^19.0.0"
78
+ }
79
+ }
80
+ ```
81
+
82
+ ---
83
+
84
+ ## Quick Start
85
+
86
+ ```tsx
87
+ import { useOnClickOutside } from "@usefy/use-on-click-outside";
88
+ import { useRef, useState } from "react";
89
+
90
+ function Modal({ onClose }: { onClose: () => void }) {
91
+ const modalRef = useRef<HTMLDivElement>(null);
92
+
93
+ useOnClickOutside(modalRef, () => onClose());
94
+
95
+ return (
96
+ <div className="overlay">
97
+ <div ref={modalRef} className="modal">
98
+ Click outside to close
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+ ```
104
+
105
+ ---
106
+
107
+ ## API Reference
108
+
109
+ ### `useOnClickOutside(ref, handler, options?)`
110
+
111
+ A hook that detects clicks outside of specified element(s).
112
+
113
+ #### Parameters
114
+
115
+ | Parameter | Type | Description |
116
+ | --------- | ------------------------ | -------------------------------------------------- |
117
+ | `ref` | `RefTarget<T>` | Single ref or array of refs to detect outside clicks for |
118
+ | `handler` | `OnClickOutsideHandler` | Callback function called when a click outside is detected |
119
+ | `options` | `UseOnClickOutsideOptions` | Configuration options |
120
+
121
+ #### Options
122
+
123
+ | Option | Type | Default | Description |
124
+ | ---------------- | --------------------------- | -------------- | ----------------------------------------------------- |
125
+ | `enabled` | `boolean` | `true` | Whether the event listener is active |
126
+ | `capture` | `boolean` | `true` | Use event capture phase (immune to stopPropagation) |
127
+ | `eventType` | `MouseEventType` | `"mousedown"` | Mouse event type to listen for |
128
+ | `touchEventType` | `TouchEventType` | `"touchstart"` | Touch event type to listen for |
129
+ | `detectTouch` | `boolean` | `true` | Whether to detect touch events (mobile support) |
130
+ | `excludeRefs` | `RefObject<HTMLElement>[]` | `[]` | Refs to exclude from outside click detection |
131
+ | `shouldExclude` | `(target: Node) => boolean` | `undefined` | Custom function to determine if target should be excluded |
132
+ | `eventTarget` | `Document \| HTMLElement \| Window` | `document` | The event target to attach listeners to |
133
+
134
+ #### Types
135
+
136
+ ```typescript
137
+ type ClickOutsideEvent = MouseEvent | TouchEvent;
138
+ type OnClickOutsideHandler = (event: ClickOutsideEvent) => void;
139
+ type MouseEventType = "mousedown" | "mouseup" | "click" | "pointerdown" | "pointerup";
140
+ type TouchEventType = "touchstart" | "touchend";
141
+ type RefTarget<T extends HTMLElement> =
142
+ | React.RefObject<T | null>
143
+ | Array<React.RefObject<HTMLElement | null>>;
144
+ ```
145
+
146
+ #### Returns
147
+
148
+ `void`
149
+
150
+ ---
151
+
152
+ ## Examples
153
+
154
+ ### Basic Modal
155
+
156
+ ```tsx
157
+ import { useOnClickOutside } from "@usefy/use-on-click-outside";
158
+ import { useRef, useState } from "react";
159
+
160
+ function Modal({ isOpen, onClose, children }: ModalProps) {
161
+ const modalRef = useRef<HTMLDivElement>(null);
162
+
163
+ useOnClickOutside(modalRef, onClose, { enabled: isOpen });
164
+
165
+ if (!isOpen) return null;
166
+
167
+ return (
168
+ <div className="overlay">
169
+ <div ref={modalRef} className="modal">
170
+ {children}
171
+ </div>
172
+ </div>
173
+ );
174
+ }
175
+ ```
176
+
177
+ ### Dropdown with Multiple Refs
178
+
179
+ When you have a button that toggles a dropdown, you want clicks on both the button and dropdown to be considered "inside":
180
+
181
+ ```tsx
182
+ import { useOnClickOutside } from "@usefy/use-on-click-outside";
183
+ import { useRef, useState } from "react";
184
+
185
+ function Dropdown() {
186
+ const [isOpen, setIsOpen] = useState(false);
187
+ const buttonRef = useRef<HTMLButtonElement>(null);
188
+ const menuRef = useRef<HTMLDivElement>(null);
189
+
190
+ // Both button and menu are considered "inside"
191
+ useOnClickOutside(
192
+ [buttonRef, menuRef],
193
+ () => setIsOpen(false),
194
+ { enabled: isOpen }
195
+ );
196
+
197
+ return (
198
+ <>
199
+ <button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
200
+ Toggle Menu
201
+ </button>
202
+ {isOpen && (
203
+ <div ref={menuRef} className="dropdown-menu">
204
+ <button>Option 1</button>
205
+ <button>Option 2</button>
206
+ <button>Option 3</button>
207
+ </div>
208
+ )}
209
+ </>
210
+ );
211
+ }
212
+ ```
213
+
214
+ ### Exclude Specific Elements
215
+
216
+ Use `excludeRefs` to prevent certain elements from triggering the outside click handler:
217
+
218
+ ```tsx
219
+ import { useOnClickOutside } from "@usefy/use-on-click-outside";
220
+ import { useRef, useState } from "react";
221
+
222
+ function ModalWithToast() {
223
+ const [isOpen, setIsOpen] = useState(false);
224
+ const modalRef = useRef<HTMLDivElement>(null);
225
+ const toastRef = useRef<HTMLDivElement>(null);
226
+
227
+ useOnClickOutside(modalRef, () => setIsOpen(false), {
228
+ enabled: isOpen,
229
+ excludeRefs: [toastRef], // Clicks on toast won't close modal
230
+ });
231
+
232
+ return (
233
+ <>
234
+ {isOpen && (
235
+ <div className="overlay">
236
+ <div ref={modalRef} className="modal">
237
+ Modal Content
238
+ </div>
239
+ </div>
240
+ )}
241
+ <div ref={toastRef} className="toast">
242
+ This toast won't close the modal when clicked
243
+ </div>
244
+ </>
245
+ );
246
+ }
247
+ ```
248
+
249
+ ### Custom Exclude Logic with shouldExclude
250
+
251
+ Use `shouldExclude` for dynamic exclusion based on element properties:
252
+
253
+ ```tsx
254
+ import { useOnClickOutside } from "@usefy/use-on-click-outside";
255
+ import { useRef, useState } from "react";
256
+
257
+ function MenuWithIgnoredElements() {
258
+ const [isOpen, setIsOpen] = useState(false);
259
+ const menuRef = useRef<HTMLDivElement>(null);
260
+
261
+ useOnClickOutside(menuRef, () => setIsOpen(false), {
262
+ enabled: isOpen,
263
+ shouldExclude: (target) => {
264
+ // Ignore clicks on elements with specific class
265
+ return (target as Element).closest?.(".ignore-outside-click") !== null;
266
+ },
267
+ });
268
+
269
+ return (
270
+ <>
271
+ {isOpen && (
272
+ <div ref={menuRef} className="menu">
273
+ Menu Content
274
+ </div>
275
+ )}
276
+ <button className="ignore-outside-click">
277
+ This button won't close the menu
278
+ </button>
279
+ </>
280
+ );
281
+ }
282
+ ```
283
+
284
+ ### Popover with Touch Support
285
+
286
+ Touch events are enabled by default for mobile compatibility:
287
+
288
+ ```tsx
289
+ import { useOnClickOutside } from "@usefy/use-on-click-outside";
290
+ import { useRef, useState } from "react";
291
+
292
+ function Popover({ trigger, content }: PopoverProps) {
293
+ const [isOpen, setIsOpen] = useState(false);
294
+ const popoverRef = useRef<HTMLDivElement>(null);
295
+
296
+ // Handles both mouse and touch events
297
+ useOnClickOutside(popoverRef, () => setIsOpen(false), {
298
+ enabled: isOpen,
299
+ detectTouch: true, // default
300
+ });
301
+
302
+ return (
303
+ <div ref={popoverRef}>
304
+ <button onClick={() => setIsOpen(!isOpen)}>{trigger}</button>
305
+ {isOpen && <div className="popover-content">{content}</div>}
306
+ </div>
307
+ );
308
+ }
309
+ ```
310
+
311
+ ### Context Menu
312
+
313
+ ```tsx
314
+ import { useOnClickOutside } from "@usefy/use-on-click-outside";
315
+ import { useRef, useState } from "react";
316
+
317
+ function ContextMenu() {
318
+ const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
319
+ const menuRef = useRef<HTMLDivElement>(null);
320
+
321
+ useOnClickOutside(menuRef, () => setMenu(null), {
322
+ enabled: menu !== null,
323
+ });
324
+
325
+ const handleContextMenu = (e: React.MouseEvent) => {
326
+ e.preventDefault();
327
+ setMenu({ x: e.clientX, y: e.clientY });
328
+ };
329
+
330
+ return (
331
+ <div onContextMenu={handleContextMenu} className="context-area">
332
+ Right-click anywhere
333
+ {menu && (
334
+ <div
335
+ ref={menuRef}
336
+ className="context-menu"
337
+ style={{ position: "fixed", left: menu.x, top: menu.y }}
338
+ >
339
+ <button>Cut</button>
340
+ <button>Copy</button>
341
+ <button>Paste</button>
342
+ </div>
343
+ )}
344
+ </div>
345
+ );
346
+ }
347
+ ```
348
+
349
+ ### Different Event Types
350
+
351
+ You can customize which mouse/touch events trigger the handler:
352
+
353
+ ```tsx
354
+ import { useOnClickOutside } from "@usefy/use-on-click-outside";
355
+
356
+ // Use 'click' instead of 'mousedown' (fires after full click completes)
357
+ useOnClickOutside(ref, handler, {
358
+ eventType: "click",
359
+ });
360
+
361
+ // Use 'touchend' instead of 'touchstart'
362
+ useOnClickOutside(ref, handler, {
363
+ touchEventType: "touchend",
364
+ });
365
+
366
+ // Disable touch detection entirely
367
+ useOnClickOutside(ref, handler, {
368
+ detectTouch: false,
369
+ });
370
+ ```
371
+
372
+ ---
373
+
374
+ ## TypeScript
375
+
376
+ This hook is written in TypeScript with exported types.
377
+
378
+ ```tsx
379
+ import {
380
+ useOnClickOutside,
381
+ type UseOnClickOutsideOptions,
382
+ type OnClickOutsideHandler,
383
+ type ClickOutsideEvent,
384
+ type RefTarget,
385
+ type MouseEventType,
386
+ type TouchEventType,
387
+ } from "@usefy/use-on-click-outside";
388
+
389
+ // Handler type
390
+ const handleOutsideClick: OnClickOutsideHandler = (event) => {
391
+ console.log("Clicked outside at:", event.clientX, event.clientY);
392
+ };
393
+
394
+ // Options type
395
+ const options: UseOnClickOutsideOptions = {
396
+ enabled: true,
397
+ capture: true,
398
+ eventType: "mousedown",
399
+ detectTouch: true,
400
+ };
401
+
402
+ useOnClickOutside(ref, handleOutsideClick, options);
403
+ ```
404
+
405
+ ---
406
+
407
+ ## Testing
408
+
409
+ This package maintains comprehensive test coverage to ensure reliability and stability.
410
+
411
+ ### Test Coverage
412
+
413
+ | Category | Coverage |
414
+ | ---------- | ------------------ |
415
+ | Statements | 97.61% (41/42) |
416
+ | Branches | 93.93% (31/33) |
417
+ | Functions | 100% (7/7) |
418
+ | Lines | 97.61% (41/42) |
419
+
420
+ ### Test Categories
421
+
422
+ <details>
423
+ <summary><strong>Basic Functionality Tests</strong></summary>
424
+
425
+ - Call handler when clicked outside the element
426
+ - Not call handler when clicked inside the element
427
+ - Not call handler when target is not connected to DOM
428
+ - Handle events with correct type (MouseEvent/TouchEvent)
429
+
430
+ </details>
431
+
432
+ <details>
433
+ <summary><strong>Multiple Refs Tests</strong></summary>
434
+
435
+ - Support array of refs
436
+ - Not call handler when clicking inside any of the refs
437
+ - Call handler only when clicking outside all refs
438
+
439
+ </details>
440
+
441
+ <details>
442
+ <summary><strong>Enabled Option Tests</strong></summary>
443
+
444
+ - Not call handler when enabled is false
445
+ - Not register event listener when disabled
446
+ - Toggle listener when enabled changes
447
+ - Default enabled to true
448
+
449
+ </details>
450
+
451
+ <details>
452
+ <summary><strong>Exclude Refs Tests</strong></summary>
453
+
454
+ - Not call handler when clicking on excluded element
455
+ - Support multiple exclude refs
456
+ - Handle dynamically added exclude refs
457
+
458
+ </details>
459
+
460
+ <details>
461
+ <summary><strong>shouldExclude Function Tests</strong></summary>
462
+
463
+ - Not call handler when shouldExclude returns true
464
+ - Pass correct target to shouldExclude function
465
+
466
+ </details>
467
+
468
+ <details>
469
+ <summary><strong>Touch Events Tests</strong></summary>
470
+
471
+ - Call handler on touchstart when detectTouch is true
472
+ - Not listen for touch events when detectTouch is false
473
+
474
+ </details>
475
+
476
+ <details>
477
+ <summary><strong>Handler Stability Tests</strong></summary>
478
+
479
+ - Not re-register listener when handler changes
480
+ - Call the latest handler after update
481
+
482
+ </details>
483
+
484
+ <details>
485
+ <summary><strong>Cleanup Tests</strong></summary>
486
+
487
+ - Remove event listeners on unmount
488
+ - Not call handler after unmount
489
+
490
+ </details>
491
+
492
+ ### Running Tests
493
+
494
+ ```bash
495
+ # Run all tests
496
+ pnpm test
497
+
498
+ # Run tests in watch mode
499
+ pnpm test:watch
500
+
501
+ # Run tests with coverage report
502
+ pnpm test --coverage
503
+ ```
504
+
505
+ ---
506
+
507
+ ## Related Packages
508
+
509
+ Explore other hooks in the **@usefy** collection:
510
+
511
+ | Package | Description |
512
+ | ------------------------------------------------------------------------------------------ | ----------------------------------- |
513
+ | [@usefy/use-click-any-where](https://www.npmjs.com/package/@usefy/use-click-any-where) | Document-wide click detection |
514
+ | [@usefy/use-toggle](https://www.npmjs.com/package/@usefy/use-toggle) | Boolean state management |
515
+ | [@usefy/use-counter](https://www.npmjs.com/package/@usefy/use-counter) | Counter state management |
516
+ | [@usefy/use-debounce](https://www.npmjs.com/package/@usefy/use-debounce) | Value debouncing |
517
+ | [@usefy/use-debounce-callback](https://www.npmjs.com/package/@usefy/use-debounce-callback) | Debounced callbacks |
518
+ | [@usefy/use-throttle](https://www.npmjs.com/package/@usefy/use-throttle) | Value throttling |
519
+ | [@usefy/use-throttle-callback](https://www.npmjs.com/package/@usefy/use-throttle-callback) | Throttled callbacks |
520
+ | [@usefy/use-local-storage](https://www.npmjs.com/package/@usefy/use-local-storage) | localStorage state synchronization |
521
+ | [@usefy/use-session-storage](https://www.npmjs.com/package/@usefy/use-session-storage) | sessionStorage state synchronization|
522
+ | [@usefy/use-copy-to-clipboard](https://www.npmjs.com/package/@usefy/use-copy-to-clipboard) | Clipboard operations |
523
+
524
+ ---
525
+
526
+ ## License
527
+
528
+ MIT © [mirunamu](https://github.com/geon0529)
529
+
530
+ This package is part of the [usefy](https://github.com/geon0529/usefy) monorepo.
531
+
532
+ ---
533
+
534
+ <p align="center">
535
+ <sub>Built with care by the usefy team</sub>
536
+ </p>
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Event types for click outside detection (mouse + touch)
3
+ */
4
+ type ClickOutsideEvent = MouseEvent | TouchEvent;
5
+ /**
6
+ * Handler function type for click outside events
7
+ */
8
+ type OnClickOutsideHandler = (event: ClickOutsideEvent) => void;
9
+ /**
10
+ * Mouse event type options
11
+ */
12
+ type MouseEventType = "mousedown" | "mouseup" | "click" | "pointerdown" | "pointerup";
13
+ /**
14
+ * Touch event type options
15
+ */
16
+ type TouchEventType = "touchstart" | "touchend";
17
+ /**
18
+ * Ref target type - supports single ref or array of refs
19
+ * Array accepts mixed element types (e.g., [buttonRef, divRef])
20
+ */
21
+ type RefTarget<T extends HTMLElement = HTMLElement> = React.RefObject<T | null> | Array<React.RefObject<HTMLElement | null>>;
22
+ /**
23
+ * Options for useOnClickOutside hook
24
+ */
25
+ interface UseOnClickOutsideOptions {
26
+ /**
27
+ * Whether the event listener is enabled
28
+ * @default true
29
+ */
30
+ enabled?: boolean;
31
+ /**
32
+ * Whether to use event capture phase.
33
+ * When true, the handler is called before the event reaches the target element,
34
+ * making it immune to stopPropagation calls.
35
+ * @default true
36
+ */
37
+ capture?: boolean;
38
+ /**
39
+ * Mouse event type to listen for
40
+ * @default 'mousedown'
41
+ */
42
+ eventType?: MouseEventType;
43
+ /**
44
+ * Touch event type to listen for
45
+ * @default 'touchstart'
46
+ */
47
+ touchEventType?: TouchEventType;
48
+ /**
49
+ * Whether to detect touch events (for mobile support)
50
+ * @default true
51
+ */
52
+ detectTouch?: boolean;
53
+ /**
54
+ * Array of refs to exclude from outside click detection.
55
+ * Clicks on these elements will not trigger the handler.
56
+ */
57
+ excludeRefs?: Array<React.RefObject<HTMLElement | null>>;
58
+ /**
59
+ * Custom function to determine if a target should be excluded.
60
+ * Return true to ignore clicks on the target element.
61
+ * @param target - The clicked element
62
+ * @returns Whether to exclude this element from triggering the handler
63
+ */
64
+ shouldExclude?: (target: Node) => boolean;
65
+ /**
66
+ * The event target to attach listeners to
67
+ * @default document
68
+ */
69
+ eventTarget?: Document | HTMLElement | Window | null;
70
+ }
71
+ /**
72
+ * Detects clicks outside of specified element(s) and calls the provided handler.
73
+ * Useful for closing modals, dropdowns, popovers, and similar UI components.
74
+ *
75
+ * @param ref - Single ref or array of refs to detect outside clicks for
76
+ * @param handler - Callback function called when a click outside is detected
77
+ * @param options - Configuration options for the event listener
78
+ *
79
+ * @example
80
+ * ```tsx
81
+ * // Basic usage - close modal on outside click
82
+ * function Modal({ isOpen, onClose }) {
83
+ * const modalRef = useRef<HTMLDivElement>(null);
84
+ *
85
+ * useOnClickOutside(modalRef, () => onClose(), { enabled: isOpen });
86
+ *
87
+ * if (!isOpen) return null;
88
+ *
89
+ * return (
90
+ * <div className="overlay">
91
+ * <div ref={modalRef} className="modal">
92
+ * Modal content
93
+ * </div>
94
+ * </div>
95
+ * );
96
+ * }
97
+ * ```
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * // Multiple refs - button and dropdown menu
102
+ * function Dropdown() {
103
+ * const [isOpen, setIsOpen] = useState(false);
104
+ * const buttonRef = useRef<HTMLButtonElement>(null);
105
+ * const menuRef = useRef<HTMLDivElement>(null);
106
+ *
107
+ * useOnClickOutside(
108
+ * [buttonRef, menuRef],
109
+ * () => setIsOpen(false),
110
+ * { enabled: isOpen }
111
+ * );
112
+ *
113
+ * return (
114
+ * <>
115
+ * <button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
116
+ * Toggle
117
+ * </button>
118
+ * {isOpen && (
119
+ * <div ref={menuRef}>Dropdown content</div>
120
+ * )}
121
+ * </>
122
+ * );
123
+ * }
124
+ * ```
125
+ *
126
+ * @example
127
+ * ```tsx
128
+ * // With exclude refs - ignore specific elements
129
+ * function ModalWithPortal({ isOpen, onClose }) {
130
+ * const modalRef = useRef<HTMLDivElement>(null);
131
+ * const toastRef = useRef<HTMLDivElement>(null);
132
+ *
133
+ * useOnClickOutside(modalRef, onClose, {
134
+ * enabled: isOpen,
135
+ * excludeRefs: [toastRef], // Clicks on toast won't close modal
136
+ * });
137
+ *
138
+ * return (
139
+ * <>
140
+ * {isOpen && <div ref={modalRef}>Modal</div>}
141
+ * <div ref={toastRef}>Toast notification</div>
142
+ * </>
143
+ * );
144
+ * }
145
+ * ```
146
+ *
147
+ * @example
148
+ * ```tsx
149
+ * // With custom exclude function
150
+ * useOnClickOutside(ref, handleClose, {
151
+ * shouldExclude: (target) => {
152
+ * // Ignore clicks on elements with specific class
153
+ * return (target as Element).closest?.('.ignore-outside-click') !== null;
154
+ * },
155
+ * });
156
+ * ```
157
+ */
158
+ declare function useOnClickOutside<T extends HTMLElement = HTMLElement>(ref: RefTarget<T>, handler: OnClickOutsideHandler, options?: UseOnClickOutsideOptions): void;
159
+
160
+ export { type ClickOutsideEvent, type MouseEventType, type OnClickOutsideHandler, type RefTarget, type TouchEventType, type UseOnClickOutsideOptions, useOnClickOutside };
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Event types for click outside detection (mouse + touch)
3
+ */
4
+ type ClickOutsideEvent = MouseEvent | TouchEvent;
5
+ /**
6
+ * Handler function type for click outside events
7
+ */
8
+ type OnClickOutsideHandler = (event: ClickOutsideEvent) => void;
9
+ /**
10
+ * Mouse event type options
11
+ */
12
+ type MouseEventType = "mousedown" | "mouseup" | "click" | "pointerdown" | "pointerup";
13
+ /**
14
+ * Touch event type options
15
+ */
16
+ type TouchEventType = "touchstart" | "touchend";
17
+ /**
18
+ * Ref target type - supports single ref or array of refs
19
+ * Array accepts mixed element types (e.g., [buttonRef, divRef])
20
+ */
21
+ type RefTarget<T extends HTMLElement = HTMLElement> = React.RefObject<T | null> | Array<React.RefObject<HTMLElement | null>>;
22
+ /**
23
+ * Options for useOnClickOutside hook
24
+ */
25
+ interface UseOnClickOutsideOptions {
26
+ /**
27
+ * Whether the event listener is enabled
28
+ * @default true
29
+ */
30
+ enabled?: boolean;
31
+ /**
32
+ * Whether to use event capture phase.
33
+ * When true, the handler is called before the event reaches the target element,
34
+ * making it immune to stopPropagation calls.
35
+ * @default true
36
+ */
37
+ capture?: boolean;
38
+ /**
39
+ * Mouse event type to listen for
40
+ * @default 'mousedown'
41
+ */
42
+ eventType?: MouseEventType;
43
+ /**
44
+ * Touch event type to listen for
45
+ * @default 'touchstart'
46
+ */
47
+ touchEventType?: TouchEventType;
48
+ /**
49
+ * Whether to detect touch events (for mobile support)
50
+ * @default true
51
+ */
52
+ detectTouch?: boolean;
53
+ /**
54
+ * Array of refs to exclude from outside click detection.
55
+ * Clicks on these elements will not trigger the handler.
56
+ */
57
+ excludeRefs?: Array<React.RefObject<HTMLElement | null>>;
58
+ /**
59
+ * Custom function to determine if a target should be excluded.
60
+ * Return true to ignore clicks on the target element.
61
+ * @param target - The clicked element
62
+ * @returns Whether to exclude this element from triggering the handler
63
+ */
64
+ shouldExclude?: (target: Node) => boolean;
65
+ /**
66
+ * The event target to attach listeners to
67
+ * @default document
68
+ */
69
+ eventTarget?: Document | HTMLElement | Window | null;
70
+ }
71
+ /**
72
+ * Detects clicks outside of specified element(s) and calls the provided handler.
73
+ * Useful for closing modals, dropdowns, popovers, and similar UI components.
74
+ *
75
+ * @param ref - Single ref or array of refs to detect outside clicks for
76
+ * @param handler - Callback function called when a click outside is detected
77
+ * @param options - Configuration options for the event listener
78
+ *
79
+ * @example
80
+ * ```tsx
81
+ * // Basic usage - close modal on outside click
82
+ * function Modal({ isOpen, onClose }) {
83
+ * const modalRef = useRef<HTMLDivElement>(null);
84
+ *
85
+ * useOnClickOutside(modalRef, () => onClose(), { enabled: isOpen });
86
+ *
87
+ * if (!isOpen) return null;
88
+ *
89
+ * return (
90
+ * <div className="overlay">
91
+ * <div ref={modalRef} className="modal">
92
+ * Modal content
93
+ * </div>
94
+ * </div>
95
+ * );
96
+ * }
97
+ * ```
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * // Multiple refs - button and dropdown menu
102
+ * function Dropdown() {
103
+ * const [isOpen, setIsOpen] = useState(false);
104
+ * const buttonRef = useRef<HTMLButtonElement>(null);
105
+ * const menuRef = useRef<HTMLDivElement>(null);
106
+ *
107
+ * useOnClickOutside(
108
+ * [buttonRef, menuRef],
109
+ * () => setIsOpen(false),
110
+ * { enabled: isOpen }
111
+ * );
112
+ *
113
+ * return (
114
+ * <>
115
+ * <button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
116
+ * Toggle
117
+ * </button>
118
+ * {isOpen && (
119
+ * <div ref={menuRef}>Dropdown content</div>
120
+ * )}
121
+ * </>
122
+ * );
123
+ * }
124
+ * ```
125
+ *
126
+ * @example
127
+ * ```tsx
128
+ * // With exclude refs - ignore specific elements
129
+ * function ModalWithPortal({ isOpen, onClose }) {
130
+ * const modalRef = useRef<HTMLDivElement>(null);
131
+ * const toastRef = useRef<HTMLDivElement>(null);
132
+ *
133
+ * useOnClickOutside(modalRef, onClose, {
134
+ * enabled: isOpen,
135
+ * excludeRefs: [toastRef], // Clicks on toast won't close modal
136
+ * });
137
+ *
138
+ * return (
139
+ * <>
140
+ * {isOpen && <div ref={modalRef}>Modal</div>}
141
+ * <div ref={toastRef}>Toast notification</div>
142
+ * </>
143
+ * );
144
+ * }
145
+ * ```
146
+ *
147
+ * @example
148
+ * ```tsx
149
+ * // With custom exclude function
150
+ * useOnClickOutside(ref, handleClose, {
151
+ * shouldExclude: (target) => {
152
+ * // Ignore clicks on elements with specific class
153
+ * return (target as Element).closest?.('.ignore-outside-click') !== null;
154
+ * },
155
+ * });
156
+ * ```
157
+ */
158
+ declare function useOnClickOutside<T extends HTMLElement = HTMLElement>(ref: RefTarget<T>, handler: OnClickOutsideHandler, options?: UseOnClickOutsideOptions): void;
159
+
160
+ export { type ClickOutsideEvent, type MouseEventType, type OnClickOutsideHandler, type RefTarget, type TouchEventType, type UseOnClickOutsideOptions, useOnClickOutside };
package/dist/index.js ADDED
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ useOnClickOutside: () => useOnClickOutside
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/useOnClickOutside.ts
28
+ var import_react = require("react");
29
+ function normalizeRefs(ref) {
30
+ return Array.isArray(ref) ? ref : [ref];
31
+ }
32
+ function isClickOutside(event, refs, excludeRefs, shouldExclude) {
33
+ const target = event.target;
34
+ if (!target || !target.isConnected) {
35
+ return false;
36
+ }
37
+ if (shouldExclude?.(target)) {
38
+ return false;
39
+ }
40
+ for (const excludeRef of excludeRefs) {
41
+ if (excludeRef.current?.contains(target)) {
42
+ return false;
43
+ }
44
+ }
45
+ for (const ref of refs) {
46
+ if (ref.current?.contains(target)) {
47
+ return false;
48
+ }
49
+ }
50
+ return true;
51
+ }
52
+ function useOnClickOutside(ref, handler, options = {}) {
53
+ const {
54
+ enabled = true,
55
+ capture = true,
56
+ eventType = "mousedown",
57
+ touchEventType = "touchstart",
58
+ detectTouch = true,
59
+ excludeRefs = [],
60
+ shouldExclude,
61
+ eventTarget
62
+ } = options;
63
+ const handlerRef = (0, import_react.useRef)(handler);
64
+ const shouldExcludeRef = (0, import_react.useRef)(shouldExclude);
65
+ const excludeRefsRef = (0, import_react.useRef)(excludeRefs);
66
+ const refRef = (0, import_react.useRef)(ref);
67
+ handlerRef.current = handler;
68
+ shouldExcludeRef.current = shouldExclude;
69
+ excludeRefsRef.current = excludeRefs;
70
+ refRef.current = ref;
71
+ (0, import_react.useEffect)(() => {
72
+ if (typeof document === "undefined") {
73
+ return;
74
+ }
75
+ if (!enabled) {
76
+ return;
77
+ }
78
+ const normalizedRefs = normalizeRefs(refRef.current);
79
+ const target = eventTarget ?? document;
80
+ const handleMouseEvent = (event) => {
81
+ if (isClickOutside(
82
+ event,
83
+ normalizedRefs,
84
+ excludeRefsRef.current,
85
+ shouldExcludeRef.current
86
+ )) {
87
+ handlerRef.current(event);
88
+ }
89
+ };
90
+ const handleTouchEvent = (event) => {
91
+ if (isClickOutside(
92
+ event,
93
+ normalizedRefs,
94
+ excludeRefsRef.current,
95
+ shouldExcludeRef.current
96
+ )) {
97
+ handlerRef.current(event);
98
+ }
99
+ };
100
+ target.addEventListener(eventType, handleMouseEvent, { capture });
101
+ if (detectTouch) {
102
+ target.addEventListener(touchEventType, handleTouchEvent, { capture });
103
+ }
104
+ return () => {
105
+ target.removeEventListener(eventType, handleMouseEvent, { capture });
106
+ if (detectTouch) {
107
+ target.removeEventListener(touchEventType, handleTouchEvent, {
108
+ capture
109
+ });
110
+ }
111
+ };
112
+ }, [enabled, capture, eventType, touchEventType, detectTouch, eventTarget]);
113
+ }
114
+ // Annotate the CommonJS export names for ESM import in node:
115
+ 0 && (module.exports = {
116
+ useOnClickOutside
117
+ });
118
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/useOnClickOutside.ts"],"sourcesContent":["export { useOnClickOutside } from \"./useOnClickOutside\";\nexport type {\n UseOnClickOutsideOptions,\n OnClickOutsideHandler,\n ClickOutsideEvent,\n RefTarget,\n MouseEventType,\n TouchEventType,\n} from \"./useOnClickOutside\";\n","import { useEffect, useRef } from \"react\";\n\n/**\n * Event types for click outside detection (mouse + touch)\n */\nexport type ClickOutsideEvent = MouseEvent | TouchEvent;\n\n/**\n * Handler function type for click outside events\n */\nexport type OnClickOutsideHandler = (event: ClickOutsideEvent) => void;\n\n/**\n * Mouse event type options\n */\nexport type MouseEventType =\n | \"mousedown\"\n | \"mouseup\"\n | \"click\"\n | \"pointerdown\"\n | \"pointerup\";\n\n/**\n * Touch event type options\n */\nexport type TouchEventType = \"touchstart\" | \"touchend\";\n\n/**\n * Ref target type - supports single ref or array of refs\n * Array accepts mixed element types (e.g., [buttonRef, divRef])\n */\nexport type RefTarget<T extends HTMLElement = HTMLElement> =\n | React.RefObject<T | null>\n | Array<React.RefObject<HTMLElement | null>>;\n\n/**\n * Options for useOnClickOutside hook\n */\nexport interface UseOnClickOutsideOptions {\n /**\n * Whether the event listener is enabled\n * @default true\n */\n enabled?: boolean;\n\n /**\n * Whether to use event capture phase.\n * When true, the handler is called before the event reaches the target element,\n * making it immune to stopPropagation calls.\n * @default true\n */\n capture?: boolean;\n\n /**\n * Mouse event type to listen for\n * @default 'mousedown'\n */\n eventType?: MouseEventType;\n\n /**\n * Touch event type to listen for\n * @default 'touchstart'\n */\n touchEventType?: TouchEventType;\n\n /**\n * Whether to detect touch events (for mobile support)\n * @default true\n */\n detectTouch?: boolean;\n\n /**\n * Array of refs to exclude from outside click detection.\n * Clicks on these elements will not trigger the handler.\n */\n excludeRefs?: Array<React.RefObject<HTMLElement | null>>;\n\n /**\n * Custom function to determine if a target should be excluded.\n * Return true to ignore clicks on the target element.\n * @param target - The clicked element\n * @returns Whether to exclude this element from triggering the handler\n */\n shouldExclude?: (target: Node) => boolean;\n\n /**\n * The event target to attach listeners to\n * @default document\n */\n eventTarget?: Document | HTMLElement | Window | null;\n}\n\n/**\n * Normalizes ref input to always return an array of refs\n */\nfunction normalizeRefs<T extends HTMLElement>(\n ref: RefTarget<T>\n): Array<React.RefObject<HTMLElement | null>> {\n return Array.isArray(ref) ? ref : [ref];\n}\n\n/**\n * Checks if a click event occurred outside of all specified elements\n */\nfunction isClickOutside(\n event: ClickOutsideEvent,\n refs: Array<React.RefObject<HTMLElement | null>>,\n excludeRefs: Array<React.RefObject<HTMLElement | null>>,\n shouldExclude?: (target: Node) => boolean\n): boolean {\n const target = event.target as Node;\n\n // Check if target exists in DOM\n if (!target || !target.isConnected) {\n return false;\n }\n\n // Check custom exclude function\n if (shouldExclude?.(target)) {\n return false;\n }\n\n // Check exclude refs\n for (const excludeRef of excludeRefs) {\n if (excludeRef.current?.contains(target)) {\n return false;\n }\n }\n\n // Check target refs - if clicked inside any of them, it's not an outside click\n for (const ref of refs) {\n if (ref.current?.contains(target)) {\n return false;\n }\n }\n\n return true;\n}\n\n/**\n * Detects clicks outside of specified element(s) and calls the provided handler.\n * Useful for closing modals, dropdowns, popovers, and similar UI components.\n *\n * @param ref - Single ref or array of refs to detect outside clicks for\n * @param handler - Callback function called when a click outside is detected\n * @param options - Configuration options for the event listener\n *\n * @example\n * ```tsx\n * // Basic usage - close modal on outside click\n * function Modal({ isOpen, onClose }) {\n * const modalRef = useRef<HTMLDivElement>(null);\n *\n * useOnClickOutside(modalRef, () => onClose(), { enabled: isOpen });\n *\n * if (!isOpen) return null;\n *\n * return (\n * <div className=\"overlay\">\n * <div ref={modalRef} className=\"modal\">\n * Modal content\n * </div>\n * </div>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Multiple refs - button and dropdown menu\n * function Dropdown() {\n * const [isOpen, setIsOpen] = useState(false);\n * const buttonRef = useRef<HTMLButtonElement>(null);\n * const menuRef = useRef<HTMLDivElement>(null);\n *\n * useOnClickOutside(\n * [buttonRef, menuRef],\n * () => setIsOpen(false),\n * { enabled: isOpen }\n * );\n *\n * return (\n * <>\n * <button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>\n * Toggle\n * </button>\n * {isOpen && (\n * <div ref={menuRef}>Dropdown content</div>\n * )}\n * </>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // With exclude refs - ignore specific elements\n * function ModalWithPortal({ isOpen, onClose }) {\n * const modalRef = useRef<HTMLDivElement>(null);\n * const toastRef = useRef<HTMLDivElement>(null);\n *\n * useOnClickOutside(modalRef, onClose, {\n * enabled: isOpen,\n * excludeRefs: [toastRef], // Clicks on toast won't close modal\n * });\n *\n * return (\n * <>\n * {isOpen && <div ref={modalRef}>Modal</div>}\n * <div ref={toastRef}>Toast notification</div>\n * </>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // With custom exclude function\n * useOnClickOutside(ref, handleClose, {\n * shouldExclude: (target) => {\n * // Ignore clicks on elements with specific class\n * return (target as Element).closest?.('.ignore-outside-click') !== null;\n * },\n * });\n * ```\n */\nexport function useOnClickOutside<T extends HTMLElement = HTMLElement>(\n ref: RefTarget<T>,\n handler: OnClickOutsideHandler,\n options: UseOnClickOutsideOptions = {}\n): void {\n const {\n enabled = true,\n capture = true,\n eventType = \"mousedown\",\n touchEventType = \"touchstart\",\n detectTouch = true,\n excludeRefs = [],\n shouldExclude,\n eventTarget,\n } = options;\n\n // Store handler in ref to avoid re-registering event listeners\n const handlerRef = useRef<OnClickOutsideHandler>(handler);\n\n // Store shouldExclude in ref to avoid re-registering event listeners\n const shouldExcludeRef = useRef(shouldExclude);\n\n // Store excludeRefs in ref to avoid re-registering event listeners\n const excludeRefsRef = useRef(excludeRefs);\n\n // Store ref in a ref to avoid re-registering when array is passed inline\n const refRef = useRef(ref);\n\n // Update refs when values change\n handlerRef.current = handler;\n shouldExcludeRef.current = shouldExclude;\n excludeRefsRef.current = excludeRefs;\n refRef.current = ref;\n\n useEffect(() => {\n // SSR check\n if (typeof document === \"undefined\") {\n return;\n }\n\n // Don't add listener if disabled\n if (!enabled) {\n return;\n }\n\n // Normalize refs to array (use refRef.current to get latest value)\n const normalizedRefs = normalizeRefs(refRef.current);\n\n // Get the event target (default to document)\n const target = eventTarget ?? document;\n\n // Internal handler for mouse events\n const handleMouseEvent = (event: Event) => {\n if (\n isClickOutside(\n event as MouseEvent,\n normalizedRefs,\n excludeRefsRef.current,\n shouldExcludeRef.current\n )\n ) {\n handlerRef.current(event as MouseEvent);\n }\n };\n\n // Internal handler for touch events\n const handleTouchEvent = (event: Event) => {\n if (\n isClickOutside(\n event as TouchEvent,\n normalizedRefs,\n excludeRefsRef.current,\n shouldExcludeRef.current\n )\n ) {\n handlerRef.current(event as TouchEvent);\n }\n };\n\n // Add event listeners\n target.addEventListener(eventType, handleMouseEvent, { capture });\n\n if (detectTouch) {\n target.addEventListener(touchEventType, handleTouchEvent, { capture });\n }\n\n // Cleanup\n return () => {\n target.removeEventListener(eventType, handleMouseEvent, { capture });\n\n if (detectTouch) {\n target.removeEventListener(touchEventType, handleTouchEvent, {\n capture,\n });\n }\n };\n }, [enabled, capture, eventType, touchEventType, detectTouch, eventTarget]);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,mBAAkC;AA+FlC,SAAS,cACP,KAC4C;AAC5C,SAAO,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAC,GAAG;AACxC;AAKA,SAAS,eACP,OACA,MACA,aACA,eACS;AACT,QAAM,SAAS,MAAM;AAGrB,MAAI,CAAC,UAAU,CAAC,OAAO,aAAa;AAClC,WAAO;AAAA,EACT;AAGA,MAAI,gBAAgB,MAAM,GAAG;AAC3B,WAAO;AAAA,EACT;AAGA,aAAW,cAAc,aAAa;AACpC,QAAI,WAAW,SAAS,SAAS,MAAM,GAAG;AACxC,aAAO;AAAA,IACT;AAAA,EACF;AAGA,aAAW,OAAO,MAAM;AACtB,QAAI,IAAI,SAAS,SAAS,MAAM,GAAG;AACjC,aAAO;AAAA,IACT;AAAA,EACF;AAEA,SAAO;AACT;AAyFO,SAAS,kBACd,KACA,SACA,UAAoC,CAAC,GAC/B;AACN,QAAM;AAAA,IACJ,UAAU;AAAA,IACV,UAAU;AAAA,IACV,YAAY;AAAA,IACZ,iBAAiB;AAAA,IACjB,cAAc;AAAA,IACd,cAAc,CAAC;AAAA,IACf;AAAA,IACA;AAAA,EACF,IAAI;AAGJ,QAAM,iBAAa,qBAA8B,OAAO;AAGxD,QAAM,uBAAmB,qBAAO,aAAa;AAG7C,QAAM,qBAAiB,qBAAO,WAAW;AAGzC,QAAM,aAAS,qBAAO,GAAG;AAGzB,aAAW,UAAU;AACrB,mBAAiB,UAAU;AAC3B,iBAAe,UAAU;AACzB,SAAO,UAAU;AAEjB,8BAAU,MAAM;AAEd,QAAI,OAAO,aAAa,aAAa;AACnC;AAAA,IACF;AAGA,QAAI,CAAC,SAAS;AACZ;AAAA,IACF;AAGA,UAAM,iBAAiB,cAAc,OAAO,OAAO;AAGnD,UAAM,SAAS,eAAe;AAG9B,UAAM,mBAAmB,CAAC,UAAiB;AACzC,UACE;AAAA,QACE;AAAA,QACA;AAAA,QACA,eAAe;AAAA,QACf,iBAAiB;AAAA,MACnB,GACA;AACA,mBAAW,QAAQ,KAAmB;AAAA,MACxC;AAAA,IACF;AAGA,UAAM,mBAAmB,CAAC,UAAiB;AACzC,UACE;AAAA,QACE;AAAA,QACA;AAAA,QACA,eAAe;AAAA,QACf,iBAAiB;AAAA,MACnB,GACA;AACA,mBAAW,QAAQ,KAAmB;AAAA,MACxC;AAAA,IACF;AAGA,WAAO,iBAAiB,WAAW,kBAAkB,EAAE,QAAQ,CAAC;AAEhE,QAAI,aAAa;AACf,aAAO,iBAAiB,gBAAgB,kBAAkB,EAAE,QAAQ,CAAC;AAAA,IACvE;AAGA,WAAO,MAAM;AACX,aAAO,oBAAoB,WAAW,kBAAkB,EAAE,QAAQ,CAAC;AAEnE,UAAI,aAAa;AACf,eAAO,oBAAoB,gBAAgB,kBAAkB;AAAA,UAC3D;AAAA,QACF,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF,GAAG,CAAC,SAAS,SAAS,WAAW,gBAAgB,aAAa,WAAW,CAAC;AAC5E;","names":[]}
package/dist/index.mjs ADDED
@@ -0,0 +1,91 @@
1
+ // src/useOnClickOutside.ts
2
+ import { useEffect, useRef } from "react";
3
+ function normalizeRefs(ref) {
4
+ return Array.isArray(ref) ? ref : [ref];
5
+ }
6
+ function isClickOutside(event, refs, excludeRefs, shouldExclude) {
7
+ const target = event.target;
8
+ if (!target || !target.isConnected) {
9
+ return false;
10
+ }
11
+ if (shouldExclude?.(target)) {
12
+ return false;
13
+ }
14
+ for (const excludeRef of excludeRefs) {
15
+ if (excludeRef.current?.contains(target)) {
16
+ return false;
17
+ }
18
+ }
19
+ for (const ref of refs) {
20
+ if (ref.current?.contains(target)) {
21
+ return false;
22
+ }
23
+ }
24
+ return true;
25
+ }
26
+ function useOnClickOutside(ref, handler, options = {}) {
27
+ const {
28
+ enabled = true,
29
+ capture = true,
30
+ eventType = "mousedown",
31
+ touchEventType = "touchstart",
32
+ detectTouch = true,
33
+ excludeRefs = [],
34
+ shouldExclude,
35
+ eventTarget
36
+ } = options;
37
+ const handlerRef = useRef(handler);
38
+ const shouldExcludeRef = useRef(shouldExclude);
39
+ const excludeRefsRef = useRef(excludeRefs);
40
+ const refRef = useRef(ref);
41
+ handlerRef.current = handler;
42
+ shouldExcludeRef.current = shouldExclude;
43
+ excludeRefsRef.current = excludeRefs;
44
+ refRef.current = ref;
45
+ useEffect(() => {
46
+ if (typeof document === "undefined") {
47
+ return;
48
+ }
49
+ if (!enabled) {
50
+ return;
51
+ }
52
+ const normalizedRefs = normalizeRefs(refRef.current);
53
+ const target = eventTarget ?? document;
54
+ const handleMouseEvent = (event) => {
55
+ if (isClickOutside(
56
+ event,
57
+ normalizedRefs,
58
+ excludeRefsRef.current,
59
+ shouldExcludeRef.current
60
+ )) {
61
+ handlerRef.current(event);
62
+ }
63
+ };
64
+ const handleTouchEvent = (event) => {
65
+ if (isClickOutside(
66
+ event,
67
+ normalizedRefs,
68
+ excludeRefsRef.current,
69
+ shouldExcludeRef.current
70
+ )) {
71
+ handlerRef.current(event);
72
+ }
73
+ };
74
+ target.addEventListener(eventType, handleMouseEvent, { capture });
75
+ if (detectTouch) {
76
+ target.addEventListener(touchEventType, handleTouchEvent, { capture });
77
+ }
78
+ return () => {
79
+ target.removeEventListener(eventType, handleMouseEvent, { capture });
80
+ if (detectTouch) {
81
+ target.removeEventListener(touchEventType, handleTouchEvent, {
82
+ capture
83
+ });
84
+ }
85
+ };
86
+ }, [enabled, capture, eventType, touchEventType, detectTouch, eventTarget]);
87
+ }
88
+ export {
89
+ useOnClickOutside
90
+ };
91
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/useOnClickOutside.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\";\n\n/**\n * Event types for click outside detection (mouse + touch)\n */\nexport type ClickOutsideEvent = MouseEvent | TouchEvent;\n\n/**\n * Handler function type for click outside events\n */\nexport type OnClickOutsideHandler = (event: ClickOutsideEvent) => void;\n\n/**\n * Mouse event type options\n */\nexport type MouseEventType =\n | \"mousedown\"\n | \"mouseup\"\n | \"click\"\n | \"pointerdown\"\n | \"pointerup\";\n\n/**\n * Touch event type options\n */\nexport type TouchEventType = \"touchstart\" | \"touchend\";\n\n/**\n * Ref target type - supports single ref or array of refs\n * Array accepts mixed element types (e.g., [buttonRef, divRef])\n */\nexport type RefTarget<T extends HTMLElement = HTMLElement> =\n | React.RefObject<T | null>\n | Array<React.RefObject<HTMLElement | null>>;\n\n/**\n * Options for useOnClickOutside hook\n */\nexport interface UseOnClickOutsideOptions {\n /**\n * Whether the event listener is enabled\n * @default true\n */\n enabled?: boolean;\n\n /**\n * Whether to use event capture phase.\n * When true, the handler is called before the event reaches the target element,\n * making it immune to stopPropagation calls.\n * @default true\n */\n capture?: boolean;\n\n /**\n * Mouse event type to listen for\n * @default 'mousedown'\n */\n eventType?: MouseEventType;\n\n /**\n * Touch event type to listen for\n * @default 'touchstart'\n */\n touchEventType?: TouchEventType;\n\n /**\n * Whether to detect touch events (for mobile support)\n * @default true\n */\n detectTouch?: boolean;\n\n /**\n * Array of refs to exclude from outside click detection.\n * Clicks on these elements will not trigger the handler.\n */\n excludeRefs?: Array<React.RefObject<HTMLElement | null>>;\n\n /**\n * Custom function to determine if a target should be excluded.\n * Return true to ignore clicks on the target element.\n * @param target - The clicked element\n * @returns Whether to exclude this element from triggering the handler\n */\n shouldExclude?: (target: Node) => boolean;\n\n /**\n * The event target to attach listeners to\n * @default document\n */\n eventTarget?: Document | HTMLElement | Window | null;\n}\n\n/**\n * Normalizes ref input to always return an array of refs\n */\nfunction normalizeRefs<T extends HTMLElement>(\n ref: RefTarget<T>\n): Array<React.RefObject<HTMLElement | null>> {\n return Array.isArray(ref) ? ref : [ref];\n}\n\n/**\n * Checks if a click event occurred outside of all specified elements\n */\nfunction isClickOutside(\n event: ClickOutsideEvent,\n refs: Array<React.RefObject<HTMLElement | null>>,\n excludeRefs: Array<React.RefObject<HTMLElement | null>>,\n shouldExclude?: (target: Node) => boolean\n): boolean {\n const target = event.target as Node;\n\n // Check if target exists in DOM\n if (!target || !target.isConnected) {\n return false;\n }\n\n // Check custom exclude function\n if (shouldExclude?.(target)) {\n return false;\n }\n\n // Check exclude refs\n for (const excludeRef of excludeRefs) {\n if (excludeRef.current?.contains(target)) {\n return false;\n }\n }\n\n // Check target refs - if clicked inside any of them, it's not an outside click\n for (const ref of refs) {\n if (ref.current?.contains(target)) {\n return false;\n }\n }\n\n return true;\n}\n\n/**\n * Detects clicks outside of specified element(s) and calls the provided handler.\n * Useful for closing modals, dropdowns, popovers, and similar UI components.\n *\n * @param ref - Single ref or array of refs to detect outside clicks for\n * @param handler - Callback function called when a click outside is detected\n * @param options - Configuration options for the event listener\n *\n * @example\n * ```tsx\n * // Basic usage - close modal on outside click\n * function Modal({ isOpen, onClose }) {\n * const modalRef = useRef<HTMLDivElement>(null);\n *\n * useOnClickOutside(modalRef, () => onClose(), { enabled: isOpen });\n *\n * if (!isOpen) return null;\n *\n * return (\n * <div className=\"overlay\">\n * <div ref={modalRef} className=\"modal\">\n * Modal content\n * </div>\n * </div>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // Multiple refs - button and dropdown menu\n * function Dropdown() {\n * const [isOpen, setIsOpen] = useState(false);\n * const buttonRef = useRef<HTMLButtonElement>(null);\n * const menuRef = useRef<HTMLDivElement>(null);\n *\n * useOnClickOutside(\n * [buttonRef, menuRef],\n * () => setIsOpen(false),\n * { enabled: isOpen }\n * );\n *\n * return (\n * <>\n * <button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>\n * Toggle\n * </button>\n * {isOpen && (\n * <div ref={menuRef}>Dropdown content</div>\n * )}\n * </>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // With exclude refs - ignore specific elements\n * function ModalWithPortal({ isOpen, onClose }) {\n * const modalRef = useRef<HTMLDivElement>(null);\n * const toastRef = useRef<HTMLDivElement>(null);\n *\n * useOnClickOutside(modalRef, onClose, {\n * enabled: isOpen,\n * excludeRefs: [toastRef], // Clicks on toast won't close modal\n * });\n *\n * return (\n * <>\n * {isOpen && <div ref={modalRef}>Modal</div>}\n * <div ref={toastRef}>Toast notification</div>\n * </>\n * );\n * }\n * ```\n *\n * @example\n * ```tsx\n * // With custom exclude function\n * useOnClickOutside(ref, handleClose, {\n * shouldExclude: (target) => {\n * // Ignore clicks on elements with specific class\n * return (target as Element).closest?.('.ignore-outside-click') !== null;\n * },\n * });\n * ```\n */\nexport function useOnClickOutside<T extends HTMLElement = HTMLElement>(\n ref: RefTarget<T>,\n handler: OnClickOutsideHandler,\n options: UseOnClickOutsideOptions = {}\n): void {\n const {\n enabled = true,\n capture = true,\n eventType = \"mousedown\",\n touchEventType = \"touchstart\",\n detectTouch = true,\n excludeRefs = [],\n shouldExclude,\n eventTarget,\n } = options;\n\n // Store handler in ref to avoid re-registering event listeners\n const handlerRef = useRef<OnClickOutsideHandler>(handler);\n\n // Store shouldExclude in ref to avoid re-registering event listeners\n const shouldExcludeRef = useRef(shouldExclude);\n\n // Store excludeRefs in ref to avoid re-registering event listeners\n const excludeRefsRef = useRef(excludeRefs);\n\n // Store ref in a ref to avoid re-registering when array is passed inline\n const refRef = useRef(ref);\n\n // Update refs when values change\n handlerRef.current = handler;\n shouldExcludeRef.current = shouldExclude;\n excludeRefsRef.current = excludeRefs;\n refRef.current = ref;\n\n useEffect(() => {\n // SSR check\n if (typeof document === \"undefined\") {\n return;\n }\n\n // Don't add listener if disabled\n if (!enabled) {\n return;\n }\n\n // Normalize refs to array (use refRef.current to get latest value)\n const normalizedRefs = normalizeRefs(refRef.current);\n\n // Get the event target (default to document)\n const target = eventTarget ?? document;\n\n // Internal handler for mouse events\n const handleMouseEvent = (event: Event) => {\n if (\n isClickOutside(\n event as MouseEvent,\n normalizedRefs,\n excludeRefsRef.current,\n shouldExcludeRef.current\n )\n ) {\n handlerRef.current(event as MouseEvent);\n }\n };\n\n // Internal handler for touch events\n const handleTouchEvent = (event: Event) => {\n if (\n isClickOutside(\n event as TouchEvent,\n normalizedRefs,\n excludeRefsRef.current,\n shouldExcludeRef.current\n )\n ) {\n handlerRef.current(event as TouchEvent);\n }\n };\n\n // Add event listeners\n target.addEventListener(eventType, handleMouseEvent, { capture });\n\n if (detectTouch) {\n target.addEventListener(touchEventType, handleTouchEvent, { capture });\n }\n\n // Cleanup\n return () => {\n target.removeEventListener(eventType, handleMouseEvent, { capture });\n\n if (detectTouch) {\n target.removeEventListener(touchEventType, handleTouchEvent, {\n capture,\n });\n }\n };\n }, [enabled, capture, eventType, touchEventType, detectTouch, eventTarget]);\n}\n"],"mappings":";AAAA,SAAS,WAAW,cAAc;AA+FlC,SAAS,cACP,KAC4C;AAC5C,SAAO,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAC,GAAG;AACxC;AAKA,SAAS,eACP,OACA,MACA,aACA,eACS;AACT,QAAM,SAAS,MAAM;AAGrB,MAAI,CAAC,UAAU,CAAC,OAAO,aAAa;AAClC,WAAO;AAAA,EACT;AAGA,MAAI,gBAAgB,MAAM,GAAG;AAC3B,WAAO;AAAA,EACT;AAGA,aAAW,cAAc,aAAa;AACpC,QAAI,WAAW,SAAS,SAAS,MAAM,GAAG;AACxC,aAAO;AAAA,IACT;AAAA,EACF;AAGA,aAAW,OAAO,MAAM;AACtB,QAAI,IAAI,SAAS,SAAS,MAAM,GAAG;AACjC,aAAO;AAAA,IACT;AAAA,EACF;AAEA,SAAO;AACT;AAyFO,SAAS,kBACd,KACA,SACA,UAAoC,CAAC,GAC/B;AACN,QAAM;AAAA,IACJ,UAAU;AAAA,IACV,UAAU;AAAA,IACV,YAAY;AAAA,IACZ,iBAAiB;AAAA,IACjB,cAAc;AAAA,IACd,cAAc,CAAC;AAAA,IACf;AAAA,IACA;AAAA,EACF,IAAI;AAGJ,QAAM,aAAa,OAA8B,OAAO;AAGxD,QAAM,mBAAmB,OAAO,aAAa;AAG7C,QAAM,iBAAiB,OAAO,WAAW;AAGzC,QAAM,SAAS,OAAO,GAAG;AAGzB,aAAW,UAAU;AACrB,mBAAiB,UAAU;AAC3B,iBAAe,UAAU;AACzB,SAAO,UAAU;AAEjB,YAAU,MAAM;AAEd,QAAI,OAAO,aAAa,aAAa;AACnC;AAAA,IACF;AAGA,QAAI,CAAC,SAAS;AACZ;AAAA,IACF;AAGA,UAAM,iBAAiB,cAAc,OAAO,OAAO;AAGnD,UAAM,SAAS,eAAe;AAG9B,UAAM,mBAAmB,CAAC,UAAiB;AACzC,UACE;AAAA,QACE;AAAA,QACA;AAAA,QACA,eAAe;AAAA,QACf,iBAAiB;AAAA,MACnB,GACA;AACA,mBAAW,QAAQ,KAAmB;AAAA,MACxC;AAAA,IACF;AAGA,UAAM,mBAAmB,CAAC,UAAiB;AACzC,UACE;AAAA,QACE;AAAA,QACA;AAAA,QACA,eAAe;AAAA,QACf,iBAAiB;AAAA,MACnB,GACA;AACA,mBAAW,QAAQ,KAAmB;AAAA,MACxC;AAAA,IACF;AAGA,WAAO,iBAAiB,WAAW,kBAAkB,EAAE,QAAQ,CAAC;AAEhE,QAAI,aAAa;AACf,aAAO,iBAAiB,gBAAgB,kBAAkB,EAAE,QAAQ,CAAC;AAAA,IACvE;AAGA,WAAO,MAAM;AACX,aAAO,oBAAoB,WAAW,kBAAkB,EAAE,QAAQ,CAAC;AAEnE,UAAI,aAAa;AACf,eAAO,oBAAoB,gBAAgB,kBAAkB;AAAA,UAC3D;AAAA,QACF,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF,GAAG,CAAC,SAAS,SAAS,WAAW,gBAAgB,aAAa,WAAW,CAAC;AAC5E;","names":[]}
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@usefy/use-on-click-outside",
3
+ "version": "0.0.1",
4
+ "description": "A React hook for detecting clicks outside of specified elements",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "sideEffects": false,
19
+ "peerDependencies": {
20
+ "react": "^18.0.0 || ^19.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@testing-library/jest-dom": "^6.9.1",
24
+ "@testing-library/react": "^16.3.1",
25
+ "@testing-library/user-event": "^14.6.1",
26
+ "@types/react": "^19.0.0",
27
+ "jsdom": "^27.3.0",
28
+ "react": "^19.0.0",
29
+ "rimraf": "^6.0.1",
30
+ "tsup": "^8.0.0",
31
+ "typescript": "^5.0.0",
32
+ "vitest": "^4.0.16"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/geon0529/usefy.git",
40
+ "directory": "packages/use-on-click-outside"
41
+ },
42
+ "license": "MIT",
43
+ "keywords": [
44
+ "react",
45
+ "hooks",
46
+ "click-outside",
47
+ "outside-click",
48
+ "modal",
49
+ "dropdown",
50
+ "event-listener",
51
+ "useOnClickOutside"
52
+ ],
53
+ "scripts": {
54
+ "build": "tsup",
55
+ "dev": "tsup --watch",
56
+ "test": "vitest run",
57
+ "test:watch": "vitest",
58
+ "typecheck": "tsc --noEmit",
59
+ "clean": "rimraf dist"
60
+ }
61
+ }