@opensite/hooks 2.0.8 → 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/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
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { useMediaQuery } from "./useMediaQuery.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Constants
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
/**
|
|
7
|
+
* Tailwind CSS v4 default breakpoints (min-width values in pixels).
|
|
8
|
+
* @see https://tailwindcss.com/docs/responsive-design
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_BREAKPOINTS = {
|
|
11
|
+
sm: 640,
|
|
12
|
+
md: 768,
|
|
13
|
+
lg: 1024,
|
|
14
|
+
xl: 1280,
|
|
15
|
+
"2xl": 1536,
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Default mapping of Tailwind breakpoints to semantic screen types.
|
|
19
|
+
*
|
|
20
|
+
* - default, sm → MOBILE
|
|
21
|
+
* - md → TABLET
|
|
22
|
+
* - lg, xl, 2xl → DESKTOP
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULT_SCREEN_TYPE_MAPPING = {
|
|
25
|
+
default: "MOBILE",
|
|
26
|
+
sm: "MOBILE",
|
|
27
|
+
md: "TABLET",
|
|
28
|
+
lg: "DESKTOP",
|
|
29
|
+
xl: "DESKTOP",
|
|
30
|
+
"2xl": "DESKTOP",
|
|
31
|
+
};
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Helpers
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
/**
|
|
36
|
+
* Get current viewport dimensions.
|
|
37
|
+
* Returns `{ width: 0, height: 0 }` during SSR.
|
|
38
|
+
*/
|
|
39
|
+
function getViewportDimensions() {
|
|
40
|
+
if (typeof window === "undefined") {
|
|
41
|
+
return { width: 0, height: 0 };
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
width: window.innerWidth,
|
|
45
|
+
height: window.innerHeight,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Determine the current Tailwind breakpoint based on viewport width.
|
|
50
|
+
* Uses mobile-first logic: returns the largest breakpoint that matches.
|
|
51
|
+
*/
|
|
52
|
+
function calculateTailwindSize(width, breakpoints) {
|
|
53
|
+
// Check breakpoints from largest to smallest (mobile-first)
|
|
54
|
+
if (width >= breakpoints["2xl"])
|
|
55
|
+
return "2xl";
|
|
56
|
+
if (width >= breakpoints.xl)
|
|
57
|
+
return "xl";
|
|
58
|
+
if (width >= breakpoints.lg)
|
|
59
|
+
return "lg";
|
|
60
|
+
if (width >= breakpoints.md)
|
|
61
|
+
return "md";
|
|
62
|
+
if (width >= breakpoints.sm)
|
|
63
|
+
return "sm";
|
|
64
|
+
return "default";
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Hook
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* Track viewport dimensions and compute Tailwind breakpoint / screen type.
|
|
71
|
+
*
|
|
72
|
+
* Provides real-time viewport width and height, along with derived values for
|
|
73
|
+
* the current Tailwind CSS breakpoint (`tailwindSize`) and a semantic screen
|
|
74
|
+
* type classification (`screenType`) for layout decisions.
|
|
75
|
+
*
|
|
76
|
+
* ### SSR Safety
|
|
77
|
+
*
|
|
78
|
+
* The hook returns safe defaults during SSR (`width: 0`, `height: 0`,
|
|
79
|
+
* `tailwindSize: "default"`, `screenType: "UNKNOWN"`) to prevent hydration
|
|
80
|
+
* mismatches. After mount, values update to reflect the actual viewport.
|
|
81
|
+
*
|
|
82
|
+
* ### Performance
|
|
83
|
+
*
|
|
84
|
+
* - Uses `useMediaQuery` internally for efficient breakpoint detection via
|
|
85
|
+
* CSS media queries (no polling).
|
|
86
|
+
* - Viewport dimensions update on window `resize` events.
|
|
87
|
+
* - The returned object is memoized; values must change for the reference
|
|
88
|
+
* to update.
|
|
89
|
+
*
|
|
90
|
+
* @param options - Optional configuration. See {@link UseScreenOptions}.
|
|
91
|
+
* @returns A memoized {@link UseScreenResult} object.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```tsx
|
|
95
|
+
* import { useScreen } from "@opensite/hooks/useScreen";
|
|
96
|
+
*
|
|
97
|
+
* function ResponsiveLayout() {
|
|
98
|
+
* const { screenType, tailwindSize, width } = useScreen();
|
|
99
|
+
*
|
|
100
|
+
* return (
|
|
101
|
+
* <div>
|
|
102
|
+
* <p>Viewport: {width}px ({tailwindSize})</p>
|
|
103
|
+
* {screenType === "MOBILE" && <MobileNav />}
|
|
104
|
+
* {screenType === "TABLET" && <TabletNav />}
|
|
105
|
+
* {screenType === "DESKTOP" && <DesktopNav />}
|
|
106
|
+
* </div>
|
|
107
|
+
* );
|
|
108
|
+
* }
|
|
109
|
+
* ```
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```tsx
|
|
113
|
+
* // With custom breakpoints
|
|
114
|
+
* const { screenType } = useScreen({
|
|
115
|
+
* breakpoints: { sm: 600, md: 900, lg: 1200, xl: 1400, "2xl": 1800 },
|
|
116
|
+
* });
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export function useScreen(options = {}) {
|
|
120
|
+
const { breakpoints: customBreakpoints, screenTypeMapping: customMapping, defaultScreenType = "UNKNOWN", defaultTailwindSize = "default", } = options;
|
|
121
|
+
// Merge custom config with defaults
|
|
122
|
+
const breakpoints = useMemo(() => ({ ...DEFAULT_BREAKPOINTS, ...customBreakpoints }), [customBreakpoints]);
|
|
123
|
+
const screenTypeMapping = useMemo(() => ({ ...DEFAULT_SCREEN_TYPE_MAPPING, ...customMapping }), [customMapping]);
|
|
124
|
+
// Track viewport dimensions
|
|
125
|
+
const [dimensions, setDimensions] = useState(() => getViewportDimensions());
|
|
126
|
+
// Use media queries for breakpoint detection (more reliable than width alone
|
|
127
|
+
// for edge cases and provides instant updates via matchMedia)
|
|
128
|
+
const isSm = useMediaQuery(`(min-width: ${breakpoints.sm}px)`);
|
|
129
|
+
const isMd = useMediaQuery(`(min-width: ${breakpoints.md}px)`);
|
|
130
|
+
const isLg = useMediaQuery(`(min-width: ${breakpoints.lg}px)`);
|
|
131
|
+
const isXl = useMediaQuery(`(min-width: ${breakpoints.xl}px)`);
|
|
132
|
+
const is2xl = useMediaQuery(`(min-width: ${breakpoints["2xl"]}px)`);
|
|
133
|
+
// Stable refresh callback
|
|
134
|
+
const refresh = useCallback(() => {
|
|
135
|
+
if (typeof window === "undefined")
|
|
136
|
+
return;
|
|
137
|
+
setDimensions(getViewportDimensions());
|
|
138
|
+
}, []);
|
|
139
|
+
// Subscribe to resize events for dimension tracking
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (typeof window === "undefined")
|
|
142
|
+
return;
|
|
143
|
+
// Initial measurement
|
|
144
|
+
setDimensions(getViewportDimensions());
|
|
145
|
+
const handleResize = () => {
|
|
146
|
+
setDimensions(getViewportDimensions());
|
|
147
|
+
};
|
|
148
|
+
window.addEventListener("resize", handleResize);
|
|
149
|
+
return () => window.removeEventListener("resize", handleResize);
|
|
150
|
+
}, []);
|
|
151
|
+
// Compute tailwindSize from media query results (most reliable)
|
|
152
|
+
const tailwindSize = useMemo(() => {
|
|
153
|
+
// During SSR, all media queries return false
|
|
154
|
+
if (!isSm && !isMd && !isLg && !isXl && !is2xl) {
|
|
155
|
+
// Check if we're on client with actual dimensions
|
|
156
|
+
if (dimensions.width > 0) {
|
|
157
|
+
return calculateTailwindSize(dimensions.width, breakpoints);
|
|
158
|
+
}
|
|
159
|
+
return defaultTailwindSize;
|
|
160
|
+
}
|
|
161
|
+
// Determine from media queries (largest matching breakpoint wins)
|
|
162
|
+
if (is2xl)
|
|
163
|
+
return "2xl";
|
|
164
|
+
if (isXl)
|
|
165
|
+
return "xl";
|
|
166
|
+
if (isLg)
|
|
167
|
+
return "lg";
|
|
168
|
+
if (isMd)
|
|
169
|
+
return "md";
|
|
170
|
+
if (isSm)
|
|
171
|
+
return "sm";
|
|
172
|
+
return "default";
|
|
173
|
+
}, [
|
|
174
|
+
isSm,
|
|
175
|
+
isMd,
|
|
176
|
+
isLg,
|
|
177
|
+
isXl,
|
|
178
|
+
is2xl,
|
|
179
|
+
dimensions.width,
|
|
180
|
+
breakpoints,
|
|
181
|
+
defaultTailwindSize,
|
|
182
|
+
]);
|
|
183
|
+
// Derive screen type from tailwind size
|
|
184
|
+
const screenType = useMemo(() => {
|
|
185
|
+
// During SSR before detection
|
|
186
|
+
if (tailwindSize === defaultTailwindSize && dimensions.width === 0) {
|
|
187
|
+
return defaultScreenType;
|
|
188
|
+
}
|
|
189
|
+
return screenTypeMapping[tailwindSize];
|
|
190
|
+
}, [
|
|
191
|
+
tailwindSize,
|
|
192
|
+
screenTypeMapping,
|
|
193
|
+
dimensions.width,
|
|
194
|
+
defaultScreenType,
|
|
195
|
+
defaultTailwindSize,
|
|
196
|
+
]);
|
|
197
|
+
// Memoize the return object for stable references
|
|
198
|
+
return useMemo(() => ({
|
|
199
|
+
width: dimensions.width,
|
|
200
|
+
height: dimensions.height,
|
|
201
|
+
tailwindSize,
|
|
202
|
+
screenType,
|
|
203
|
+
refresh,
|
|
204
|
+
}), [dimensions.width, dimensions.height, tailwindSize, screenType, refresh]);
|
|
205
|
+
}
|