@opensite/hooks 2.0.7 → 2.1.0

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.
@@ -0,0 +1,151 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ // ---------------------------------------------------------------------------
3
+ // Detection internals
4
+ // ---------------------------------------------------------------------------
5
+ /**
6
+ * CSS media query targeting devices whose *primary* pointing device is coarse
7
+ * and lacks hover — the W3C Interaction Media Features (Level 4) definition of
8
+ * a touch-first device.
9
+ *
10
+ * Evaluated via `matchMedia` and subscribed to with a `change` listener so
11
+ * convertible laptops, detachable tablets, and foldables react to input-mode
12
+ * switches in real time.
13
+ *
14
+ * @see https://www.w3.org/TR/mediaqueries-4/#mf-interaction
15
+ */
16
+ const TOUCH_MEDIA_QUERY = "(hover: none) and (pointer: coarse)";
17
+ /**
18
+ * Synchronously evaluate touch capability using a two-tier strategy.
19
+ *
20
+ * **Tier 1 — Interaction Media Features** (`pointer: coarse` + `hover: none`).
21
+ * Most accurate on modern browsers (Chrome 41+, Firefox 64+, Safari 9+) and
22
+ * correctly classifies hybrid devices by inspecting only the *primary* input.
23
+ *
24
+ * **Tier 2 — Legacy touch-API probe** (`ontouchstart` on window,
25
+ * `navigator.maxTouchPoints`). Covers older Android WebView and pre-Chromium
26
+ * Edge. This probe can produce false positives on some desktop touchscreens,
27
+ * but it is only reached when Tier 1 is inconclusive.
28
+ *
29
+ * The function must only be called in a browser context — callers are
30
+ * responsible for guarding with `typeof window !== "undefined"`.
31
+ */
32
+ function detectTouchCapability() {
33
+ // Tier 1: Interaction Media Features (wide support since ~2018).
34
+ if (typeof window.matchMedia === "function") {
35
+ const mql = window.matchMedia(TOUCH_MEDIA_QUERY);
36
+ // `mql.media` is set to `"not all"` when the browser does not understand
37
+ // the query at all — in that case we fall through to the legacy probe.
38
+ if (mql.media !== "not all") {
39
+ return mql.matches ? "touch" : "desktop";
40
+ }
41
+ }
42
+ // Tier 2: legacy touch-API probe.
43
+ if ("ontouchstart" in window ||
44
+ (typeof navigator !== "undefined" &&
45
+ typeof navigator.maxTouchPoints === "number" &&
46
+ navigator.maxTouchPoints > 0)) {
47
+ return "touch";
48
+ }
49
+ return "desktop";
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // Hook
53
+ // ---------------------------------------------------------------------------
54
+ /**
55
+ * Detect whether the device's primary input mechanism is touch-based.
56
+ *
57
+ * Uses a layered detection strategy combining CSS Interaction Media Features
58
+ * (`pointer: coarse`, `hover: none`) with legacy touch-API probing
59
+ * (`ontouchstart`, `maxTouchPoints`) for maximum browser coverage.
60
+ *
61
+ * ### SSR safety
62
+ *
63
+ * The hook returns `"unknown"` (or a caller-supplied `defaultDeviceType`) on
64
+ * the server **and** during the first client render, so the server and client
65
+ * always produce identical markup — no hydration mismatch. After the commit
66
+ * phase, a `useEffect` evaluates synchronously and subscribes to `matchMedia`
67
+ * change events.
68
+ *
69
+ * ### Dynamic updates
70
+ *
71
+ * On browsers that support Interaction Media Features, the hook listens for
72
+ * `change` events on the `(hover: none) and (pointer: coarse)` query. This
73
+ * means convertible laptops, detachable tablets, and foldables will
74
+ * automatically re-classify when the user switches input modes — no polling
75
+ * required.
76
+ *
77
+ * ### Performance
78
+ *
79
+ * - Detection runs once on mount (one `matchMedia` call) — no per-render work.
80
+ * - The `change` listener is passive and only fires on actual input-mode
81
+ * transitions (rare).
82
+ * - The returned object is memoized; `deviceType` must change for the
83
+ * reference to update.
84
+ * - The `recheck` callback is `useCallback`-stable for the hook's lifetime.
85
+ *
86
+ * @param options - Optional configuration. See {@link UseIsTouchDeviceOptions}.
87
+ * @returns A memoized {@link UseIsTouchDeviceResult} object.
88
+ *
89
+ * @example
90
+ * ```tsx
91
+ * import { useIsTouchDevice } from "@opensite/hooks/useIsTouchDevice";
92
+ *
93
+ * function Toolbar() {
94
+ * const { isTouchDevice } = useIsTouchDevice();
95
+ * return isTouchDevice ? <TouchToolbar /> : <DesktopToolbar />;
96
+ * }
97
+ * ```
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * // With a server-side hint to eliminate layout shift
102
+ * function Page({ ssrDeviceType }: { ssrDeviceType: DeviceType }) {
103
+ * const { deviceType } = useIsTouchDevice({
104
+ * defaultDeviceType: ssrDeviceType,
105
+ * });
106
+ * // ...
107
+ * }
108
+ * ```
109
+ */
110
+ export function useIsTouchDevice(options = {}) {
111
+ const { defaultDeviceType = "unknown" } = options;
112
+ // SSR-safe initial state — never accesses browser APIs at initialization.
113
+ const [deviceType, setDeviceType] = useState(defaultDeviceType);
114
+ // Stable imperative re-evaluation callback. Guarded so it is a safe no-op
115
+ // if accidentally called during SSR (e.g. in a shared util).
116
+ const recheck = useCallback(() => {
117
+ if (typeof window === "undefined")
118
+ return;
119
+ setDeviceType(detectTouchCapability());
120
+ }, []);
121
+ useEffect(() => {
122
+ if (typeof window === "undefined")
123
+ return;
124
+ // Synchronous first evaluation — updates state in the same commit so
125
+ // consumers see the real value on the very first paint after hydration.
126
+ setDeviceType(detectTouchCapability());
127
+ // Subscribe to media-query changes so hybrid/convertible devices update
128
+ // when the user switches between touch and pointer input modes.
129
+ if (typeof window.matchMedia !== "function")
130
+ return;
131
+ const mql = window.matchMedia(TOUCH_MEDIA_QUERY);
132
+ // If the browser doesn't understand the query there's nothing to listen
133
+ // for — the legacy probe result from above is our final answer.
134
+ if (mql.media === "not all")
135
+ return;
136
+ const onChange = (event) => {
137
+ setDeviceType(event.matches ? "touch" : "desktop");
138
+ };
139
+ mql.addEventListener("change", onChange);
140
+ return () => {
141
+ mql.removeEventListener("change", onChange);
142
+ };
143
+ }, []);
144
+ // Memoize the return object so consumers that destructure or pass the whole
145
+ // result into dependency arrays don't trigger spurious re-renders.
146
+ return useMemo(() => ({
147
+ deviceType,
148
+ isTouchDevice: deviceType === "touch",
149
+ recheck,
150
+ }), [deviceType, recheck]);
151
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Granular classification of the detected primary input device.
3
+ *
4
+ * - `"unknown"` – returned during SSR and before the first client-side
5
+ * evaluation completes. Consumers should treat this as "not yet determined"
6
+ * and render a safe, non-committal default (e.g. show both touch and pointer
7
+ * affordances, or a loading skeleton).
8
+ * - `"touch"` – the primary input is a coarse pointer with no hover capability
9
+ * (phones, most tablets in touch mode).
10
+ * - `"desktop"` – the primary input is a fine pointer with hover support
11
+ * (mouse, trackpad, stylus with hover).
12
+ */
13
+ export type DeviceType = "unknown" | "touch" | "desktop";
14
+ /**
15
+ * Configuration options for {@link useIsTouchDevice}.
16
+ */
17
+ export interface UseIsTouchDeviceOptions {
18
+ /**
19
+ * Value returned during SSR and before the first client-side detection.
20
+ *
21
+ * Defaults to `"unknown"`. Override with `"touch"` or `"desktop"` when you
22
+ * have server-side hints (e.g. User-Agent parsing via middleware) and want to
23
+ * avoid a visible layout shift after hydration.
24
+ */
25
+ defaultDeviceType?: DeviceType;
26
+ }
27
+ /**
28
+ * Shape returned by {@link useIsTouchDevice}.
29
+ *
30
+ * The object reference is memoized with `useMemo` so it remains referentially
31
+ * stable across re-renders when `deviceType` has not changed — safe to include
32
+ * in downstream dependency arrays without triggering spurious effects.
33
+ */
34
+ export interface UseIsTouchDeviceResult {
35
+ /** Granular device classification (`"unknown"` | `"touch"` | `"desktop"`). */
36
+ deviceType: DeviceType;
37
+ /** Convenience boolean — `true` when `deviceType === "touch"`. */
38
+ isTouchDevice: boolean;
39
+ /**
40
+ * Re-run touch detection imperatively.
41
+ *
42
+ * Useful after external events that the hook cannot observe automatically
43
+ * (e.g. a WebHID device connect, or a custom "mode switch" UI toggle).
44
+ * The callback reference is stable across the lifetime of the hook.
45
+ */
46
+ recheck: () => void;
47
+ }
48
+ /**
49
+ * Detect whether the device's primary input mechanism is touch-based.
50
+ *
51
+ * Uses a layered detection strategy combining CSS Interaction Media Features
52
+ * (`pointer: coarse`, `hover: none`) with legacy touch-API probing
53
+ * (`ontouchstart`, `maxTouchPoints`) for maximum browser coverage.
54
+ *
55
+ * ### SSR safety
56
+ *
57
+ * The hook returns `"unknown"` (or a caller-supplied `defaultDeviceType`) on
58
+ * the server **and** during the first client render, so the server and client
59
+ * always produce identical markup — no hydration mismatch. After the commit
60
+ * phase, a `useEffect` evaluates synchronously and subscribes to `matchMedia`
61
+ * change events.
62
+ *
63
+ * ### Dynamic updates
64
+ *
65
+ * On browsers that support Interaction Media Features, the hook listens for
66
+ * `change` events on the `(hover: none) and (pointer: coarse)` query. This
67
+ * means convertible laptops, detachable tablets, and foldables will
68
+ * automatically re-classify when the user switches input modes — no polling
69
+ * required.
70
+ *
71
+ * ### Performance
72
+ *
73
+ * - Detection runs once on mount (one `matchMedia` call) — no per-render work.
74
+ * - The `change` listener is passive and only fires on actual input-mode
75
+ * transitions (rare).
76
+ * - The returned object is memoized; `deviceType` must change for the
77
+ * reference to update.
78
+ * - The `recheck` callback is `useCallback`-stable for the hook's lifetime.
79
+ *
80
+ * @param options - Optional configuration. See {@link UseIsTouchDeviceOptions}.
81
+ * @returns A memoized {@link UseIsTouchDeviceResult} object.
82
+ *
83
+ * @example
84
+ * ```tsx
85
+ * import { useIsTouchDevice } from "@opensite/hooks/useIsTouchDevice";
86
+ *
87
+ * function Toolbar() {
88
+ * const { isTouchDevice } = useIsTouchDevice();
89
+ * return isTouchDevice ? <TouchToolbar /> : <DesktopToolbar />;
90
+ * }
91
+ * ```
92
+ *
93
+ * @example
94
+ * ```tsx
95
+ * // With a server-side hint to eliminate layout shift
96
+ * function Page({ ssrDeviceType }: { ssrDeviceType: DeviceType }) {
97
+ * const { deviceType } = useIsTouchDevice({
98
+ * defaultDeviceType: ssrDeviceType,
99
+ * });
100
+ * // ...
101
+ * }
102
+ * ```
103
+ */
104
+ export declare function useIsTouchDevice(options?: UseIsTouchDeviceOptions): UseIsTouchDeviceResult;
@@ -0,0 +1,151 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ // ---------------------------------------------------------------------------
3
+ // Detection internals
4
+ // ---------------------------------------------------------------------------
5
+ /**
6
+ * CSS media query targeting devices whose *primary* pointing device is coarse
7
+ * and lacks hover — the W3C Interaction Media Features (Level 4) definition of
8
+ * a touch-first device.
9
+ *
10
+ * Evaluated via `matchMedia` and subscribed to with a `change` listener so
11
+ * convertible laptops, detachable tablets, and foldables react to input-mode
12
+ * switches in real time.
13
+ *
14
+ * @see https://www.w3.org/TR/mediaqueries-4/#mf-interaction
15
+ */
16
+ const TOUCH_MEDIA_QUERY = "(hover: none) and (pointer: coarse)";
17
+ /**
18
+ * Synchronously evaluate touch capability using a two-tier strategy.
19
+ *
20
+ * **Tier 1 — Interaction Media Features** (`pointer: coarse` + `hover: none`).
21
+ * Most accurate on modern browsers (Chrome 41+, Firefox 64+, Safari 9+) and
22
+ * correctly classifies hybrid devices by inspecting only the *primary* input.
23
+ *
24
+ * **Tier 2 — Legacy touch-API probe** (`ontouchstart` on window,
25
+ * `navigator.maxTouchPoints`). Covers older Android WebView and pre-Chromium
26
+ * Edge. This probe can produce false positives on some desktop touchscreens,
27
+ * but it is only reached when Tier 1 is inconclusive.
28
+ *
29
+ * The function must only be called in a browser context — callers are
30
+ * responsible for guarding with `typeof window !== "undefined"`.
31
+ */
32
+ function detectTouchCapability() {
33
+ // Tier 1: Interaction Media Features (wide support since ~2018).
34
+ if (typeof window.matchMedia === "function") {
35
+ const mql = window.matchMedia(TOUCH_MEDIA_QUERY);
36
+ // `mql.media` is set to `"not all"` when the browser does not understand
37
+ // the query at all — in that case we fall through to the legacy probe.
38
+ if (mql.media !== "not all") {
39
+ return mql.matches ? "touch" : "desktop";
40
+ }
41
+ }
42
+ // Tier 2: legacy touch-API probe.
43
+ if ("ontouchstart" in window ||
44
+ (typeof navigator !== "undefined" &&
45
+ typeof navigator.maxTouchPoints === "number" &&
46
+ navigator.maxTouchPoints > 0)) {
47
+ return "touch";
48
+ }
49
+ return "desktop";
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // Hook
53
+ // ---------------------------------------------------------------------------
54
+ /**
55
+ * Detect whether the device's primary input mechanism is touch-based.
56
+ *
57
+ * Uses a layered detection strategy combining CSS Interaction Media Features
58
+ * (`pointer: coarse`, `hover: none`) with legacy touch-API probing
59
+ * (`ontouchstart`, `maxTouchPoints`) for maximum browser coverage.
60
+ *
61
+ * ### SSR safety
62
+ *
63
+ * The hook returns `"unknown"` (or a caller-supplied `defaultDeviceType`) on
64
+ * the server **and** during the first client render, so the server and client
65
+ * always produce identical markup — no hydration mismatch. After the commit
66
+ * phase, a `useEffect` evaluates synchronously and subscribes to `matchMedia`
67
+ * change events.
68
+ *
69
+ * ### Dynamic updates
70
+ *
71
+ * On browsers that support Interaction Media Features, the hook listens for
72
+ * `change` events on the `(hover: none) and (pointer: coarse)` query. This
73
+ * means convertible laptops, detachable tablets, and foldables will
74
+ * automatically re-classify when the user switches input modes — no polling
75
+ * required.
76
+ *
77
+ * ### Performance
78
+ *
79
+ * - Detection runs once on mount (one `matchMedia` call) — no per-render work.
80
+ * - The `change` listener is passive and only fires on actual input-mode
81
+ * transitions (rare).
82
+ * - The returned object is memoized; `deviceType` must change for the
83
+ * reference to update.
84
+ * - The `recheck` callback is `useCallback`-stable for the hook's lifetime.
85
+ *
86
+ * @param options - Optional configuration. See {@link UseIsTouchDeviceOptions}.
87
+ * @returns A memoized {@link UseIsTouchDeviceResult} object.
88
+ *
89
+ * @example
90
+ * ```tsx
91
+ * import { useIsTouchDevice } from "@opensite/hooks/useIsTouchDevice";
92
+ *
93
+ * function Toolbar() {
94
+ * const { isTouchDevice } = useIsTouchDevice();
95
+ * return isTouchDevice ? <TouchToolbar /> : <DesktopToolbar />;
96
+ * }
97
+ * ```
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * // With a server-side hint to eliminate layout shift
102
+ * function Page({ ssrDeviceType }: { ssrDeviceType: DeviceType }) {
103
+ * const { deviceType } = useIsTouchDevice({
104
+ * defaultDeviceType: ssrDeviceType,
105
+ * });
106
+ * // ...
107
+ * }
108
+ * ```
109
+ */
110
+ export function useIsTouchDevice(options = {}) {
111
+ const { defaultDeviceType = "unknown" } = options;
112
+ // SSR-safe initial state — never accesses browser APIs at initialization.
113
+ const [deviceType, setDeviceType] = useState(defaultDeviceType);
114
+ // Stable imperative re-evaluation callback. Guarded so it is a safe no-op
115
+ // if accidentally called during SSR (e.g. in a shared util).
116
+ const recheck = useCallback(() => {
117
+ if (typeof window === "undefined")
118
+ return;
119
+ setDeviceType(detectTouchCapability());
120
+ }, []);
121
+ useEffect(() => {
122
+ if (typeof window === "undefined")
123
+ return;
124
+ // Synchronous first evaluation — updates state in the same commit so
125
+ // consumers see the real value on the very first paint after hydration.
126
+ setDeviceType(detectTouchCapability());
127
+ // Subscribe to media-query changes so hybrid/convertible devices update
128
+ // when the user switches between touch and pointer input modes.
129
+ if (typeof window.matchMedia !== "function")
130
+ return;
131
+ const mql = window.matchMedia(TOUCH_MEDIA_QUERY);
132
+ // If the browser doesn't understand the query there's nothing to listen
133
+ // for — the legacy probe result from above is our final answer.
134
+ if (mql.media === "not all")
135
+ return;
136
+ const onChange = (event) => {
137
+ setDeviceType(event.matches ? "touch" : "desktop");
138
+ };
139
+ mql.addEventListener("change", onChange);
140
+ return () => {
141
+ mql.removeEventListener("change", onChange);
142
+ };
143
+ }, []);
144
+ // Memoize the return object so consumers that destructure or pass the whole
145
+ // result into dependency arrays don't trigger spurious re-renders.
146
+ return useMemo(() => ({
147
+ deviceType,
148
+ isTouchDevice: deviceType === "touch",
149
+ recheck,
150
+ }), [deviceType, recheck]);
151
+ }
@@ -1,13 +1,15 @@
1
1
  import { useEffect, useState } from "react";
2
2
  export function useMediaQuery(query, options = {}) {
3
3
  const [matches, setMatches] = useState(() => {
4
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
4
+ if (typeof window === "undefined" ||
5
+ typeof window.matchMedia !== "function") {
5
6
  return options.defaultValue ?? false;
6
7
  }
7
8
  return window.matchMedia(query).matches;
8
9
  });
9
10
  useEffect(() => {
10
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
11
+ if (typeof window === "undefined" ||
12
+ typeof window.matchMedia !== "function") {
11
13
  return;
12
14
  }
13
15
  const mediaQueryList = window.matchMedia(query);
@@ -15,12 +17,8 @@ export function useMediaQuery(query, options = {}) {
15
17
  setMatches(event.matches);
16
18
  };
17
19
  setMatches(mediaQueryList.matches);
18
- if (mediaQueryList.addEventListener) {
19
- mediaQueryList.addEventListener("change", handler);
20
- return () => mediaQueryList.removeEventListener("change", handler);
21
- }
22
- mediaQueryList.addListener(handler);
23
- return () => mediaQueryList.removeListener(handler);
20
+ mediaQueryList.addEventListener("change", handler);
21
+ return () => mediaQueryList.removeEventListener("change", handler);
24
22
  }, [query]);
25
23
  return matches;
26
24
  }
@@ -1,13 +1,15 @@
1
1
  import { useEffect, useState } from "react";
2
2
  export function useMediaQuery(query, options = {}) {
3
3
  const [matches, setMatches] = useState(() => {
4
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
4
+ if (typeof window === "undefined" ||
5
+ typeof window.matchMedia !== "function") {
5
6
  return options.defaultValue ?? false;
6
7
  }
7
8
  return window.matchMedia(query).matches;
8
9
  });
9
10
  useEffect(() => {
10
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
11
+ if (typeof window === "undefined" ||
12
+ typeof window.matchMedia !== "function") {
11
13
  return;
12
14
  }
13
15
  const mediaQueryList = window.matchMedia(query);
@@ -15,12 +17,8 @@ export function useMediaQuery(query, options = {}) {
15
17
  setMatches(event.matches);
16
18
  };
17
19
  setMatches(mediaQueryList.matches);
18
- if (mediaQueryList.addEventListener) {
19
- mediaQueryList.addEventListener("change", handler);
20
- return () => mediaQueryList.removeEventListener("change", handler);
21
- }
22
- mediaQueryList.addListener(handler);
23
- return () => mediaQueryList.removeListener(handler);
20
+ mediaQueryList.addEventListener("change", handler);
21
+ return () => mediaQueryList.removeEventListener("change", handler);
24
22
  }, [query]);
25
23
  return matches;
26
24
  }
@@ -1,5 +1,23 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect.js";
3
+ // Debug mode - set to true to enable console logging
4
+ const DEBUG_CLICK_OUTSIDE = false;
5
+ /**
6
+ * Get the owner document for a given element.
7
+ * This is crucial for iframe support - returns the iframe's document if the element is inside one.
8
+ */
9
+ function getOwnerDocument(element) {
10
+ return element?.ownerDocument ?? document;
11
+ }
12
+ /**
13
+ * Detect if an element is inside an iframe by checking if its document is different from the top document
14
+ */
15
+ function isInIframe(element) {
16
+ if (!element)
17
+ return false;
18
+ const doc = getOwnerDocument(element);
19
+ return doc !== document;
20
+ }
3
21
  export function useOnClickOutside(ref, handler, eventType, options) {
4
22
  const handlerRef = useRef(handler);
5
23
  // Use useIsomorphicLayoutEffect to update handler ref synchronously
@@ -16,22 +34,80 @@ export function useOnClickOutside(ref, handler, eventType, options) {
16
34
  typeof window.PointerEvent !== "undefined";
17
35
  const resolvedEventType = eventType ?? (supportsPointerEvents ? "pointerdown" : "mousedown");
18
36
  const refs = Array.isArray(ref) ? ref : [ref];
37
+ // Detect the correct document to attach listeners to
38
+ // If any ref is inside an iframe, use that iframe's document
39
+ let targetDocument = document;
40
+ let inIframe = false;
41
+ for (const currentRef of refs) {
42
+ if (currentRef.current) {
43
+ const refDocument = getOwnerDocument(currentRef.current);
44
+ if (refDocument !== document) {
45
+ targetDocument = refDocument;
46
+ inIframe = true;
47
+ break;
48
+ }
49
+ }
50
+ }
51
+ if (DEBUG_CLICK_OUTSIDE) {
52
+ console.log("[useOnClickOutside] Setup:", {
53
+ eventType: resolvedEventType,
54
+ inIframe,
55
+ documentLocation: inIframe ? "iframe" : "parent",
56
+ refsCount: refs.length,
57
+ refsWithNodes: refs.filter((r) => r.current).length,
58
+ });
59
+ }
19
60
  const listener = (event) => {
20
61
  const target = event.target;
62
+ if (DEBUG_CLICK_OUTSIDE) {
63
+ console.log("[useOnClickOutside] Event fired:", {
64
+ eventType: event.type,
65
+ targetTag: target instanceof Element ? target.tagName : "unknown",
66
+ targetInIframe: target instanceof Node ? isInIframe(target) : false,
67
+ });
68
+ }
21
69
  if (typeof Node === "undefined" || !(target instanceof Node)) {
70
+ if (DEBUG_CLICK_OUTSIDE) {
71
+ console.log("[useOnClickOutside] Early return: target not a Node");
72
+ }
22
73
  return;
23
74
  }
24
75
  const clickedInside = refs.some((currentRef) => {
25
76
  const node = currentRef.current;
26
- return node ? node.contains(target) : false;
77
+ const contains = node ? node.contains(target) : false;
78
+ if (DEBUG_CLICK_OUTSIDE && node) {
79
+ console.log("[useOnClickOutside] Checking ref:", {
80
+ refTag: node.tagName,
81
+ contains,
82
+ nodeInIframe: isInIframe(node),
83
+ });
84
+ }
85
+ return contains;
27
86
  });
87
+ if (DEBUG_CLICK_OUTSIDE) {
88
+ console.log("[useOnClickOutside] Click result:", {
89
+ clickedInside,
90
+ willCallHandler: !clickedInside,
91
+ });
92
+ }
28
93
  if (!clickedInside) {
29
94
  handlerRef.current(event);
30
95
  }
31
96
  };
32
- document.addEventListener(resolvedEventType, listener, options);
97
+ targetDocument.addEventListener(resolvedEventType, listener, options);
98
+ if (DEBUG_CLICK_OUTSIDE) {
99
+ console.log("[useOnClickOutside] Listener attached to:", {
100
+ documentType: inIframe ? "iframe document" : "parent document",
101
+ eventType: resolvedEventType,
102
+ });
103
+ }
33
104
  return () => {
34
- document.removeEventListener(resolvedEventType, listener, options);
105
+ targetDocument.removeEventListener(resolvedEventType, listener, options);
106
+ if (DEBUG_CLICK_OUTSIDE) {
107
+ console.log("[useOnClickOutside] Listener removed from:", {
108
+ documentType: inIframe ? "iframe document" : "parent document",
109
+ });
110
+ }
35
111
  };
36
112
  }, [eventType, options, ref]);
37
113
  }