@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.
- package/README.md +2 -0
- package/dist/browser/opensite-hooks.umd.cjs +1 -1
- package/dist/browser/opensite-hooks.umd.js +1 -1
- package/dist/browser/opensite-hooks.umd.js.map +1 -1
- package/dist/core/index.cjs +2 -0
- package/dist/core/index.d.ts +5 -1
- package/dist/core/index.js +2 -0
- package/dist/core/useIsTouchDevice.cjs +151 -0
- package/dist/core/useIsTouchDevice.d.ts +104 -0
- package/dist/core/useIsTouchDevice.js +151 -0
- package/dist/core/useMediaQuery.cjs +6 -8
- package/dist/core/useMediaQuery.js +6 -8
- package/dist/core/useOnClickOutside.cjs +79 -3
- package/dist/core/useOnClickOutside.js +79 -3
- package/dist/core/useScreen.cjs +205 -0
- package/dist/core/useScreen.d.ts +155 -0
- package/dist/core/useScreen.js +205 -0
- package/package.json +11 -1
|
@@ -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" ||
|
|
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" ||
|
|
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
|
-
|
|
19
|
-
|
|
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" ||
|
|
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" ||
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|