@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.
@@ -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
  }
@@ -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
+ }