@versini/ui-hooks 5.1.0 → 5.2.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/dist/index.d.ts +259 -246
- package/dist/index.js +826 -30
- package/package.json +9 -9
- package/dist/hooks/useClickOutside.js +0 -26
- package/dist/hooks/useHaptic.js +0 -49
- package/dist/hooks/useHotkeys.js +0 -56
- package/dist/hooks/useInViewport.js +0 -12
- package/dist/hooks/useInterval.js +0 -12
- package/dist/hooks/useIsMounted.js +0 -10
- package/dist/hooks/useLocalStorage.js +0 -42
- package/dist/hooks/useMergeRefs.js +0 -12
- package/dist/hooks/useResizeObserver.js +0 -26
- package/dist/hooks/useUncontrolled.js +0 -23
- package/dist/hooks/useUniqueId.js +0 -15
- package/dist/hooks/useViewportSize.js +0 -21
- package/dist/hooks/useVisualViewportSize.js +0 -22
package/dist/index.js
CHANGED
|
@@ -1,34 +1,830 @@
|
|
|
1
|
-
import { useClickOutside as r } from "./hooks/useClickOutside.js";
|
|
2
|
-
import { useHaptic as s } from "./hooks/useHaptic.js";
|
|
3
|
-
import { getHotkeyHandler as p, shouldFireEvent as f, useHotkeys as m } from "./hooks/useHotkeys.js";
|
|
4
|
-
import { useInterval as i } from "./hooks/useInterval.js";
|
|
5
|
-
import { useInViewport as n } from "./hooks/useInViewport.js";
|
|
6
|
-
import { useIsMounted as d } from "./hooks/useIsMounted.js";
|
|
7
|
-
import { useLocalStorage as H } from "./hooks/useLocalStorage.js";
|
|
8
|
-
import { useMergeRefs as V } from "./hooks/useMergeRefs.js";
|
|
9
|
-
import { useResizeObserver as k } from "./hooks/useResizeObserver.js";
|
|
10
|
-
import { useUncontrolled as w } from "./hooks/useUncontrolled.js";
|
|
11
|
-
import { useUniqueId as S } from "./hooks/useUniqueId.js";
|
|
12
|
-
import { useViewportSize as M } from "./hooks/useViewportSize.js";
|
|
13
|
-
import { useVisualViewportSize as R } from "./hooks/useVisualViewportSize.js";
|
|
14
1
|
/*!
|
|
15
|
-
@versini/ui-hooks v5.
|
|
2
|
+
@versini/ui-hooks v5.2.0
|
|
16
3
|
© 2025 gizmette.com
|
|
17
4
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
5
|
+
try {
|
|
6
|
+
if (!window.__VERSINI_UI_HOOKS__) {
|
|
7
|
+
window.__VERSINI_UI_HOOKS__ = {
|
|
8
|
+
version: "5.2.0",
|
|
9
|
+
buildTime: "11/04/2025 03:42 PM EST",
|
|
10
|
+
homepage: "https://github.com/aversini/ui-components",
|
|
11
|
+
license: "MIT",
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
} catch (error) {
|
|
15
|
+
// nothing to declare officer
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
19
|
+
|
|
20
|
+
;// CONCATENATED MODULE: external "react"
|
|
21
|
+
|
|
22
|
+
;// CONCATENATED MODULE: ./src/hooks/useClickOutside.tsx
|
|
23
|
+
|
|
24
|
+
const DEFAULT_EVENTS = [
|
|
25
|
+
"mousedown",
|
|
26
|
+
"touchstart"
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Custom hooks that triggers a callback when a click is detected
|
|
30
|
+
* outside the target element.
|
|
31
|
+
*
|
|
32
|
+
* @param handler - Function to be called when clicked outside
|
|
33
|
+
* @param events - Array of events to listen to
|
|
34
|
+
* @param nodes - Array of nodes to check against
|
|
35
|
+
* @returns Ref to be attached to the target element
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* const ref = useClickOutside(() => {
|
|
39
|
+
* console.log('Clicked outside!');
|
|
40
|
+
* });
|
|
41
|
+
* <div ref={ref}>Click me!</div>
|
|
42
|
+
*
|
|
43
|
+
*/ function useClickOutside(handler, events, nodes) {
|
|
44
|
+
const ref = useRef(null);
|
|
45
|
+
useEffect(()=>{
|
|
46
|
+
const listener = (event)=>{
|
|
47
|
+
/* v8 ignore next 1 */ const target = event ? event.target : undefined;
|
|
48
|
+
if (Array.isArray(nodes)) {
|
|
49
|
+
/* v8 ignore next 2 */ const shouldIgnore = !document.body.contains(target) && target.tagName !== "HTML";
|
|
50
|
+
const shouldTrigger = nodes.every((node)=>!!node && !event.composedPath().includes(node));
|
|
51
|
+
shouldTrigger && !shouldIgnore && handler();
|
|
52
|
+
} else if (ref.current && !ref.current.contains(target)) {
|
|
53
|
+
handler();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
(events || DEFAULT_EVENTS).forEach((fn)=>document.addEventListener(fn, listener));
|
|
57
|
+
return ()=>{
|
|
58
|
+
(events || DEFAULT_EVENTS).forEach((fn)=>document.removeEventListener(fn, listener));
|
|
59
|
+
};
|
|
60
|
+
}, [
|
|
61
|
+
handler,
|
|
62
|
+
nodes,
|
|
63
|
+
events
|
|
64
|
+
]);
|
|
65
|
+
return ref;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
;// CONCATENATED MODULE: ./src/hooks/useHaptic.ts
|
|
69
|
+
|
|
70
|
+
const HAPTIC_DURATION_MS = 50;
|
|
71
|
+
const HAPTIC_INTERVAL_MS = 120;
|
|
72
|
+
/**
|
|
73
|
+
* Singleton state for the haptic element shared across all hook instances. This
|
|
74
|
+
* ensures only one DOM element is created regardless of how many components use
|
|
75
|
+
* the hook.
|
|
76
|
+
*/ let sharedLabelElement = null;
|
|
77
|
+
let refCount = 0;
|
|
78
|
+
/**
|
|
79
|
+
* Creates the shared haptic element if it doesn't exist. This function is
|
|
80
|
+
* idempotent - calling it multiple times is safe. Checks DOM directly to handle
|
|
81
|
+
* React Strict Mode double-mounting.
|
|
82
|
+
*/ const ensureHapticElement = ()=>{
|
|
83
|
+
/* c8 ignore next 3 */ if (typeof window === "undefined") {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// First check: do we have valid references that exist in the DOM?
|
|
87
|
+
if (sharedLabelElement && document.body.contains(sharedLabelElement)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Second check: does an element already exist in the DOM from a previous
|
|
92
|
+
* mount?
|
|
93
|
+
*/ const existingLabel = document.querySelector('label[data-haptic-singleton="true"]');
|
|
94
|
+
if (existingLabel) {
|
|
95
|
+
sharedLabelElement = existingLabel;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Clear stale references.
|
|
99
|
+
sharedLabelElement = null;
|
|
100
|
+
// Create new elements.
|
|
101
|
+
const input = document.createElement("input");
|
|
102
|
+
input.type = "checkbox";
|
|
103
|
+
input.setAttribute("switch", "");
|
|
104
|
+
input.style.display = "none";
|
|
105
|
+
input.setAttribute("aria-hidden", "true");
|
|
106
|
+
input.dataset.hapticSingleton = "true";
|
|
107
|
+
const label = document.createElement("label");
|
|
108
|
+
label.style.display = "none";
|
|
109
|
+
label.setAttribute("aria-hidden", "true");
|
|
110
|
+
label.dataset.hapticSingleton = "true";
|
|
111
|
+
label.appendChild(input);
|
|
112
|
+
document.body.appendChild(label);
|
|
113
|
+
sharedLabelElement = label;
|
|
34
114
|
};
|
|
115
|
+
/**
|
|
116
|
+
* Removes the shared haptic element from the DOM and clears references. Only
|
|
117
|
+
* called when the last component using the hook unmounts.
|
|
118
|
+
*/ const cleanupHapticElement = ()=>{
|
|
119
|
+
if (sharedLabelElement && document.body && document.body.contains(sharedLabelElement)) {
|
|
120
|
+
document.body.removeChild(sharedLabelElement);
|
|
121
|
+
}
|
|
122
|
+
sharedLabelElement = null;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Custom hook providing imperative haptic feedback for mobile devices. Uses
|
|
126
|
+
* navigator.vibrate when available, falls back to iOS switch element trick for
|
|
127
|
+
* Safari on iOS devices that don't support the Vibration API.
|
|
128
|
+
*
|
|
129
|
+
* This hook uses a singleton pattern - only one haptic element is created in
|
|
130
|
+
* the DOM regardless of how many components use this hook. The element is
|
|
131
|
+
* automatically cleaned up when the last component unmounts.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```tsx
|
|
135
|
+
* const { haptic } = useHaptic();
|
|
136
|
+
*
|
|
137
|
+
* // Trigger a single haptic pulse
|
|
138
|
+
* haptic(1);
|
|
139
|
+
*
|
|
140
|
+
* // Trigger two rapid haptic pulses
|
|
141
|
+
* haptic(2);
|
|
142
|
+
* ```
|
|
143
|
+
*
|
|
144
|
+
*/ const useHaptic = ()=>{
|
|
145
|
+
const timeoutsRef = useRef(new Set());
|
|
146
|
+
useEffect(()=>{
|
|
147
|
+
// Increment reference count and create element if needed.
|
|
148
|
+
refCount++;
|
|
149
|
+
try {
|
|
150
|
+
ensureHapticElement();
|
|
151
|
+
} catch (error) {
|
|
152
|
+
/**
|
|
153
|
+
* If element creation fails, we need to decrement refCount immediately since
|
|
154
|
+
* the cleanup function won't be registered. This prevents refCount from
|
|
155
|
+
* getting out of sync.
|
|
156
|
+
*/ refCount--;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
return ()=>{
|
|
160
|
+
/**
|
|
161
|
+
* Cleanup function: clear all pending timeouts to prevent haptics from
|
|
162
|
+
* firing after unmount, and decrement the reference count. Only remove the
|
|
163
|
+
* DOM element when the last component unmounts.
|
|
164
|
+
*/ for (const timeoutId of timeoutsRef.current){
|
|
165
|
+
clearTimeout(timeoutId);
|
|
166
|
+
}
|
|
167
|
+
timeoutsRef.current.clear();
|
|
168
|
+
refCount--;
|
|
169
|
+
if (refCount === 0) {
|
|
170
|
+
cleanupHapticElement();
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}, []);
|
|
174
|
+
/**
|
|
175
|
+
* Triggers a single haptic pulse using either the Vibration API or the switch
|
|
176
|
+
* element trick.
|
|
177
|
+
*/ const triggerSingleHaptic = useCallback(()=>{
|
|
178
|
+
try {
|
|
179
|
+
if (navigator?.vibrate) {
|
|
180
|
+
navigator.vibrate(HAPTIC_DURATION_MS);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
sharedLabelElement?.click();
|
|
184
|
+
} catch {
|
|
185
|
+
// Silently fail if haptics are not supported.
|
|
186
|
+
}
|
|
187
|
+
}, []);
|
|
188
|
+
/**
|
|
189
|
+
* Triggers haptic feedback with the specified number of pulses.
|
|
190
|
+
*
|
|
191
|
+
* @param count - Number of haptic pulses to trigger (default: 1). For count > 1,
|
|
192
|
+
* pulses are triggered in rapid succession with a 120ms interval between each
|
|
193
|
+
* pulse.
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* ```tsx
|
|
197
|
+
* const { haptic } = useHaptic();
|
|
198
|
+
*
|
|
199
|
+
* // Single haptic
|
|
200
|
+
* haptic(1);
|
|
201
|
+
*
|
|
202
|
+
* // Two rapid haptics
|
|
203
|
+
* haptic(2);
|
|
204
|
+
*
|
|
205
|
+
* // Three rapid haptics
|
|
206
|
+
* haptic(3);
|
|
207
|
+
* ```
|
|
208
|
+
*
|
|
209
|
+
*/ const haptic = useCallback((count = 1)=>{
|
|
210
|
+
/* c8 ignore next 3 */ if (typeof window === "undefined") {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (count < 1) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (navigator?.vibrate && count > 1) {
|
|
217
|
+
/**
|
|
218
|
+
* For multi-haptic patterns with navigator.vibrate, create an array pattern
|
|
219
|
+
* like [50, 120, 50, 120, 50] for better timing control.
|
|
220
|
+
*/ const pattern = [];
|
|
221
|
+
for(let i = 0; i < count; i++){
|
|
222
|
+
pattern.push(HAPTIC_DURATION_MS);
|
|
223
|
+
if (i < count - 1) {
|
|
224
|
+
pattern.push(HAPTIC_INTERVAL_MS - HAPTIC_DURATION_MS);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
navigator.vibrate(pattern);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// For switch element or single vibration, trigger sequentially.
|
|
231
|
+
for(let i = 0; i < count; i++){
|
|
232
|
+
const timeoutId = setTimeout(()=>{
|
|
233
|
+
triggerSingleHaptic();
|
|
234
|
+
timeoutsRef.current.delete(timeoutId);
|
|
235
|
+
}, i * HAPTIC_INTERVAL_MS);
|
|
236
|
+
timeoutsRef.current.add(timeoutId);
|
|
237
|
+
}
|
|
238
|
+
}, [
|
|
239
|
+
triggerSingleHaptic
|
|
240
|
+
]);
|
|
241
|
+
return {
|
|
242
|
+
haptic
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
;// CONCATENATED MODULE: ./src/hooks/utilities.ts
|
|
247
|
+
function parseHotkey(hotkey) {
|
|
248
|
+
const keys = hotkey.toLowerCase().split("+").map((part)=>part.trim());
|
|
249
|
+
const modifiers = {
|
|
250
|
+
alt: keys.includes("alt"),
|
|
251
|
+
ctrl: keys.includes("ctrl"),
|
|
252
|
+
meta: keys.includes("meta"),
|
|
253
|
+
mod: keys.includes("mod"),
|
|
254
|
+
shift: keys.includes("shift")
|
|
255
|
+
};
|
|
256
|
+
const reservedKeys = [
|
|
257
|
+
"alt",
|
|
258
|
+
"ctrl",
|
|
259
|
+
"meta",
|
|
260
|
+
"shift",
|
|
261
|
+
"mod"
|
|
262
|
+
];
|
|
263
|
+
const freeKey = keys.find((key)=>!reservedKeys.includes(key));
|
|
264
|
+
return {
|
|
265
|
+
...modifiers,
|
|
266
|
+
key: freeKey
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function isExactHotkey(hotkey, event) {
|
|
270
|
+
const { alt, ctrl, meta, mod, shift, key } = hotkey;
|
|
271
|
+
const { altKey, ctrlKey, metaKey, shiftKey, key: pressedKey } = event;
|
|
272
|
+
if (alt !== altKey) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
if (mod) {
|
|
276
|
+
if (!ctrlKey && !metaKey) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
if (ctrl !== ctrlKey) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
if (meta !== metaKey) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (shift !== shiftKey) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
if (key && (pressedKey.toLowerCase() === key.toLowerCase() || event.code.replace("Key", "").toLowerCase() === key.toLowerCase())) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
function getHotkeyMatcher(hotkey) {
|
|
296
|
+
return (event)=>isExactHotkey(parseHotkey(hotkey), event);
|
|
297
|
+
}
|
|
298
|
+
function getHotkeyHandler(hotkeys) {
|
|
299
|
+
return (event)=>{
|
|
300
|
+
const _event = "nativeEvent" in event ? event.nativeEvent : event;
|
|
301
|
+
hotkeys.forEach(([hotkey, handler, options = {
|
|
302
|
+
preventDefault: true
|
|
303
|
+
}])=>{
|
|
304
|
+
if (getHotkeyMatcher(hotkey)(_event)) {
|
|
305
|
+
if (options.preventDefault) {
|
|
306
|
+
event.preventDefault();
|
|
307
|
+
}
|
|
308
|
+
handler(_event);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
;// CONCATENATED MODULE: ./src/hooks/useHotkeys.ts
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
function shouldFireEvent(event, tagsToIgnore, triggerOnContentEditable = false) {
|
|
319
|
+
if (event.target instanceof HTMLElement) {
|
|
320
|
+
if (triggerOnContentEditable) {
|
|
321
|
+
return !tagsToIgnore.includes(event.target.tagName);
|
|
322
|
+
}
|
|
323
|
+
return !event.target.isContentEditable && !tagsToIgnore.includes(event.target.tagName);
|
|
324
|
+
}
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
function useHotkeys(hotkeys, tagsToIgnore = [
|
|
328
|
+
"INPUT",
|
|
329
|
+
"TEXTAREA",
|
|
330
|
+
"SELECT"
|
|
331
|
+
], triggerOnContentEditable = false) {
|
|
332
|
+
useEffect(()=>{
|
|
333
|
+
const keydownListener = (event)=>{
|
|
334
|
+
hotkeys.forEach(([hotkey, handler, options = {
|
|
335
|
+
preventDefault: true
|
|
336
|
+
}])=>{
|
|
337
|
+
if (getHotkeyMatcher(hotkey)(event) && shouldFireEvent(event, tagsToIgnore, triggerOnContentEditable)) {
|
|
338
|
+
if (options.preventDefault) {
|
|
339
|
+
event.preventDefault();
|
|
340
|
+
}
|
|
341
|
+
handler(event);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
};
|
|
345
|
+
document.documentElement.addEventListener("keydown", keydownListener);
|
|
346
|
+
return ()=>document.documentElement.removeEventListener("keydown", keydownListener);
|
|
347
|
+
}, [
|
|
348
|
+
hotkeys,
|
|
349
|
+
tagsToIgnore,
|
|
350
|
+
triggerOnContentEditable
|
|
351
|
+
]);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
;// CONCATENATED MODULE: ./src/hooks/useInterval.ts
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Custom hook to call a function within a given interval.
|
|
358
|
+
*
|
|
359
|
+
* @param fn Callback function to be executed at each interval
|
|
360
|
+
* @param interval Interval time in milliseconds
|
|
361
|
+
* @returns An object containing start, stop, and active state
|
|
362
|
+
*
|
|
363
|
+
* @example
|
|
364
|
+
* const { start, stop, active } = useInterval(() => {
|
|
365
|
+
* console.log("Interval executed");
|
|
366
|
+
* }, 1000);
|
|
367
|
+
* start(); // To start the interval
|
|
368
|
+
* stop(); // To stop the interval
|
|
369
|
+
* console.log(active); // To check if the interval is active
|
|
370
|
+
*
|
|
371
|
+
*/ function useInterval(fn, interval) {
|
|
372
|
+
const [active, setActive] = useState(false);
|
|
373
|
+
const intervalRef = useRef(null);
|
|
374
|
+
const fnRef = useRef(null);
|
|
375
|
+
const start = useCallback(()=>{
|
|
376
|
+
setActive((old)=>{
|
|
377
|
+
if (!old && (!intervalRef.current || intervalRef.current === -1)) {
|
|
378
|
+
intervalRef.current = window.setInterval(fnRef.current, interval);
|
|
379
|
+
}
|
|
380
|
+
return true;
|
|
381
|
+
});
|
|
382
|
+
}, [
|
|
383
|
+
interval
|
|
384
|
+
]);
|
|
385
|
+
const stop = useCallback(()=>{
|
|
386
|
+
setActive(false);
|
|
387
|
+
window.clearInterval(intervalRef.current || -1);
|
|
388
|
+
intervalRef.current = -1;
|
|
389
|
+
}, []);
|
|
390
|
+
useEffect(()=>{
|
|
391
|
+
fnRef.current = fn;
|
|
392
|
+
active && start();
|
|
393
|
+
return stop;
|
|
394
|
+
}, [
|
|
395
|
+
fn,
|
|
396
|
+
active,
|
|
397
|
+
start,
|
|
398
|
+
stop
|
|
399
|
+
]);
|
|
400
|
+
return {
|
|
401
|
+
start,
|
|
402
|
+
stop,
|
|
403
|
+
active
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
;// CONCATENATED MODULE: ./src/hooks/useInViewport.ts
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Hook that checks if an element is visible in the viewport.
|
|
411
|
+
* @returns
|
|
412
|
+
* ref: React ref object to attach to the element you want to monitor.
|
|
413
|
+
* inViewport: Boolean indicating if the element is in the viewport.
|
|
414
|
+
*/ /* v8 ignore next 24 */ function useInViewport() {
|
|
415
|
+
const observer = useRef(null);
|
|
416
|
+
const [inViewport, setInViewport] = useState(false);
|
|
417
|
+
const ref = useCallback((node)=>{
|
|
418
|
+
if (typeof IntersectionObserver !== "undefined") {
|
|
419
|
+
if (node && !observer.current) {
|
|
420
|
+
observer.current = new IntersectionObserver((entries)=>setInViewport(entries.some((entry)=>entry.isIntersecting)));
|
|
421
|
+
} else {
|
|
422
|
+
observer.current?.disconnect();
|
|
423
|
+
}
|
|
424
|
+
if (node) {
|
|
425
|
+
observer.current?.observe(node);
|
|
426
|
+
} else {
|
|
427
|
+
setInViewport(false);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}, []);
|
|
431
|
+
return {
|
|
432
|
+
ref,
|
|
433
|
+
inViewport
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
;// CONCATENATED MODULE: ./src/hooks/useIsMounted.ts
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Custom hook that returns a function indicating whether the component
|
|
441
|
+
* is mounted or not.
|
|
442
|
+
*
|
|
443
|
+
* @returns A function that returns a boolean value indicating whether
|
|
444
|
+
* the component is mounted.
|
|
445
|
+
*
|
|
446
|
+
* @example
|
|
447
|
+
* const isMounted = useIsMounted();
|
|
448
|
+
* console.log(isMounted()); // true
|
|
449
|
+
*
|
|
450
|
+
*/ function useIsMounted() {
|
|
451
|
+
const isMounted = useRef(false);
|
|
452
|
+
useEffect(()=>{
|
|
453
|
+
isMounted.current = true;
|
|
454
|
+
return ()=>{
|
|
455
|
+
isMounted.current = false;
|
|
456
|
+
};
|
|
457
|
+
}, []);
|
|
458
|
+
return useCallback(()=>isMounted.current, []);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
;// CONCATENATED MODULE: ./src/hooks/useLocalStorage.ts
|
|
462
|
+
|
|
463
|
+
function dispatchStorageEvent(key, newValue) {
|
|
464
|
+
window.dispatchEvent(new StorageEvent("storage", {
|
|
465
|
+
key,
|
|
466
|
+
newValue
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
const setLocalStorageItem = (key, value)=>{
|
|
470
|
+
const stringifiedValue = JSON.stringify(typeof value === "function" ? value() : value);
|
|
471
|
+
window.localStorage.setItem(key, stringifiedValue);
|
|
472
|
+
dispatchStorageEvent(key, stringifiedValue);
|
|
473
|
+
};
|
|
474
|
+
const removeLocalStorageItem = (key)=>{
|
|
475
|
+
window.localStorage.removeItem(key);
|
|
476
|
+
dispatchStorageEvent(key, null);
|
|
477
|
+
};
|
|
478
|
+
const getLocalStorageItem = (key)=>{
|
|
479
|
+
return window.localStorage.getItem(key);
|
|
480
|
+
};
|
|
481
|
+
const useLocalStorageSubscribe = (callback)=>{
|
|
482
|
+
window.addEventListener("storage", callback);
|
|
483
|
+
return ()=>window.removeEventListener("storage", callback);
|
|
484
|
+
};
|
|
485
|
+
/**
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* import { useLocalStorage } from '@versini/ui-hooks';
|
|
489
|
+
* const [value, setValue, resetValue, removeValue] = useLocalStorage({
|
|
490
|
+
* key: 'gpt-model',
|
|
491
|
+
* initialValue: 'gpt-3',
|
|
492
|
+
* });
|
|
493
|
+
*
|
|
494
|
+
* setValue('gpt-4'); ==> "gpt-4"
|
|
495
|
+
* setValue((current) => (current === 'gpt-3' ? 'gpt-4' : 'gpt-3'));
|
|
496
|
+
* resetValue(); ==> "gpt-3"
|
|
497
|
+
* removeValue(); ==> null
|
|
498
|
+
*/ function useLocalStorage({ key, initialValue }) {
|
|
499
|
+
const getSnapshot = ()=>getLocalStorageItem(key);
|
|
500
|
+
const store = useSyncExternalStore(useLocalStorageSubscribe, getSnapshot);
|
|
501
|
+
const setValue = useCallback((v)=>{
|
|
502
|
+
try {
|
|
503
|
+
const nextState = typeof v === "function" ? v(JSON.parse(store)) : v;
|
|
504
|
+
if (nextState === undefined || nextState === null) {
|
|
505
|
+
removeLocalStorageItem(key);
|
|
506
|
+
} else {
|
|
507
|
+
setLocalStorageItem(key, nextState);
|
|
508
|
+
}
|
|
509
|
+
/* v8 ignore next 3 */ } catch (e) {
|
|
510
|
+
console.warn(e);
|
|
511
|
+
}
|
|
512
|
+
}, [
|
|
513
|
+
key,
|
|
514
|
+
store
|
|
515
|
+
]);
|
|
516
|
+
const resetValue = useCallback(()=>{
|
|
517
|
+
setValue(initialValue);
|
|
518
|
+
}, [
|
|
519
|
+
initialValue,
|
|
520
|
+
setValue
|
|
521
|
+
]);
|
|
522
|
+
const removeValue = useCallback(()=>{
|
|
523
|
+
setValue(null);
|
|
524
|
+
}, [
|
|
525
|
+
setValue
|
|
526
|
+
]);
|
|
527
|
+
useEffect(()=>{
|
|
528
|
+
try {
|
|
529
|
+
if (getLocalStorageItem(key) === null && typeof initialValue !== "undefined") {
|
|
530
|
+
setLocalStorageItem(key, initialValue);
|
|
531
|
+
}
|
|
532
|
+
/* v8 ignore next 3 */ } catch (e) {
|
|
533
|
+
console.warn(e);
|
|
534
|
+
}
|
|
535
|
+
}, [
|
|
536
|
+
key,
|
|
537
|
+
initialValue
|
|
538
|
+
]);
|
|
539
|
+
return [
|
|
540
|
+
store ? JSON.parse(store) : null,
|
|
541
|
+
setValue,
|
|
542
|
+
resetValue,
|
|
543
|
+
removeValue
|
|
544
|
+
];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
;// CONCATENATED MODULE: ./src/hooks/useMergeRefs.ts
|
|
548
|
+
/**
|
|
549
|
+
* React utility to merge refs.
|
|
550
|
+
*
|
|
551
|
+
* When developing low level UI components, it is common to have to use a local
|
|
552
|
+
* ref but also support an external one using React.forwardRef. Natively, React
|
|
553
|
+
* does not offer a way to set two refs inside the ref property.
|
|
554
|
+
*
|
|
555
|
+
* @param Array of refs (object, function, etc.)
|
|
556
|
+
*
|
|
557
|
+
* @example
|
|
558
|
+
*
|
|
559
|
+
* const Example = React.forwardRef(function Example(props, ref) {
|
|
560
|
+
* const localRef = React.useRef();
|
|
561
|
+
* const mergedRefs = useMergeRefs([localRef, ref]);
|
|
562
|
+
*
|
|
563
|
+
* return <div ref={mergedRefs} />;
|
|
564
|
+
* });
|
|
565
|
+
*
|
|
566
|
+
*/
|
|
567
|
+
function useMergeRefs(refs) {
|
|
568
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: refs array is used as dependency but spread for proper comparison
|
|
569
|
+
return useMemo(()=>{
|
|
570
|
+
if (refs.every((ref)=>ref == null)) {
|
|
571
|
+
return ()=>{};
|
|
572
|
+
}
|
|
573
|
+
return (value)=>{
|
|
574
|
+
refs.forEach((ref)=>{
|
|
575
|
+
if (typeof ref === "function") {
|
|
576
|
+
ref(value);
|
|
577
|
+
} else if (ref != null) {
|
|
578
|
+
ref.current = value;
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
};
|
|
582
|
+
}, [
|
|
583
|
+
...refs
|
|
584
|
+
]);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
;// CONCATENATED MODULE: ./src/hooks/useResizeObserver.ts
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
const defaultState = {
|
|
591
|
+
x: 0,
|
|
592
|
+
y: 0,
|
|
593
|
+
width: 0,
|
|
594
|
+
height: 0,
|
|
595
|
+
top: 0,
|
|
596
|
+
left: 0,
|
|
597
|
+
bottom: 0,
|
|
598
|
+
right: 0
|
|
599
|
+
};
|
|
600
|
+
/**
|
|
601
|
+
* A custom hook that uses the ResizeObserver API to track the size changes of a DOM element.
|
|
602
|
+
*
|
|
603
|
+
* @template T - The type of the DOM element being observed.
|
|
604
|
+
* @param {ResizeObserverOptions} [options] - The options to configure the ResizeObserver.
|
|
605
|
+
* @returns {[React.RefObject<T>, ObserverRect]} - A tuple containing the ref object and
|
|
606
|
+
* the observed rectangle.
|
|
607
|
+
* @example
|
|
608
|
+
*
|
|
609
|
+
* const [rightElementRef, rect] = useResizeObserver<HTMLDivElement>();
|
|
610
|
+
* <div ref={componentRef}>
|
|
611
|
+
* Observed: <code>{JSON.stringify(rect)}</code>
|
|
612
|
+
* </div>
|
|
613
|
+
*/ function useResizeObserver(options) {
|
|
614
|
+
const isMounted = useIsMounted();
|
|
615
|
+
const frameID = useRef(0);
|
|
616
|
+
const ref = useRef(null);
|
|
617
|
+
const [rect, setRect] = useState(defaultState);
|
|
618
|
+
const observer = useMemo(()=>{
|
|
619
|
+
/* c8 ignore next 3 */ if (typeof ResizeObserver === "undefined") {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
return new ResizeObserver((entries)=>{
|
|
623
|
+
const entry = entries[0];
|
|
624
|
+
if (entry) {
|
|
625
|
+
cancelAnimationFrame(frameID.current);
|
|
626
|
+
frameID.current = requestAnimationFrame(()=>{
|
|
627
|
+
if (ref.current && isMounted()) {
|
|
628
|
+
setRect(entry.contentRect);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
}, [
|
|
634
|
+
isMounted
|
|
635
|
+
]);
|
|
636
|
+
useEffect(()=>{
|
|
637
|
+
/* c8 ignore next 3 */ if (ref.current) {
|
|
638
|
+
observer?.observe(ref.current, options);
|
|
639
|
+
}
|
|
640
|
+
return ()=>{
|
|
641
|
+
observer?.disconnect();
|
|
642
|
+
/* c8 ignore next 3 */ if (frameID.current) {
|
|
643
|
+
cancelAnimationFrame(frameID.current);
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
}, [
|
|
647
|
+
observer,
|
|
648
|
+
options
|
|
649
|
+
]);
|
|
650
|
+
return [
|
|
651
|
+
ref,
|
|
652
|
+
rect
|
|
653
|
+
];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
;// CONCATENATED MODULE: ./src/hooks/useUncontrolled.ts
|
|
657
|
+
|
|
658
|
+
function useUncontrolled({ value, defaultValue, finalValue, onChange = ()=>{}, initialControlledDelay = 0 }) {
|
|
659
|
+
const [initialDelayDone, setInitialDelayDone] = useState(false);
|
|
660
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue !== undefined ? defaultValue : finalValue);
|
|
661
|
+
const handleUncontrolledChange = (val)=>{
|
|
662
|
+
setUncontrolledValue(val);
|
|
663
|
+
onChange?.(val);
|
|
664
|
+
};
|
|
665
|
+
useEffect(()=>{
|
|
666
|
+
(async ()=>{
|
|
667
|
+
/**
|
|
668
|
+
* If initialControlledDelay is provided, wait for the delay.
|
|
669
|
+
*/ if (value !== undefined) {
|
|
670
|
+
/* c8 ignore start */ if (!initialDelayDone && initialControlledDelay > 0) {
|
|
671
|
+
await new Promise((resolve)=>setTimeout(resolve, initialControlledDelay));
|
|
672
|
+
setInitialDelayDone(true);
|
|
673
|
+
}
|
|
674
|
+
/* c8 ignore end */ }
|
|
675
|
+
})();
|
|
676
|
+
}, [
|
|
677
|
+
value,
|
|
678
|
+
initialControlledDelay,
|
|
679
|
+
initialDelayDone
|
|
680
|
+
]);
|
|
681
|
+
/**
|
|
682
|
+
* If value is provided, return the controlled value.
|
|
683
|
+
* If there is a delay, we need to wait for the delay: we need to first send
|
|
684
|
+
* back a value of an empty string, then after the delay
|
|
685
|
+
* we can send the actual value.
|
|
686
|
+
*/ if (value !== undefined) {
|
|
687
|
+
if (!initialDelayDone && initialControlledDelay > 0) {
|
|
688
|
+
return [
|
|
689
|
+
"",
|
|
690
|
+
onChange,
|
|
691
|
+
true
|
|
692
|
+
];
|
|
693
|
+
} else {
|
|
694
|
+
return [
|
|
695
|
+
value,
|
|
696
|
+
onChange,
|
|
697
|
+
true
|
|
698
|
+
];
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* If value is not provided, return the uncontrolled value.
|
|
703
|
+
*/ return [
|
|
704
|
+
uncontrolledValue,
|
|
705
|
+
handleUncontrolledChange,
|
|
706
|
+
false
|
|
707
|
+
];
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
;// CONCATENATED MODULE: ./src/hooks/useUniqueId.ts
|
|
711
|
+
|
|
712
|
+
function useUniqueId(options) {
|
|
713
|
+
const generatedId = useId();
|
|
714
|
+
if (!options) {
|
|
715
|
+
return generatedId;
|
|
716
|
+
}
|
|
717
|
+
if (typeof options === "number" || typeof options === "string") {
|
|
718
|
+
return `${options}${generatedId}`;
|
|
719
|
+
}
|
|
720
|
+
if (typeof options === "object") {
|
|
721
|
+
const { id, prefix = "" } = options;
|
|
722
|
+
if (typeof id === "number" || typeof id === "string") {
|
|
723
|
+
return `${prefix}${id}`;
|
|
724
|
+
}
|
|
725
|
+
return `${prefix}${generatedId}`;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
;// CONCATENATED MODULE: ./src/hooks/useViewportSize.tsx
|
|
730
|
+
|
|
731
|
+
function useWindowEvent(type, listener) {
|
|
732
|
+
useEffect(()=>{
|
|
733
|
+
window.addEventListener(type, listener, {
|
|
734
|
+
passive: true
|
|
735
|
+
});
|
|
736
|
+
return ()=>window.removeEventListener(type, listener);
|
|
737
|
+
}, [
|
|
738
|
+
type,
|
|
739
|
+
listener
|
|
740
|
+
]);
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Custom hook that returns the current viewport size. It will update
|
|
744
|
+
* when the window is resized or the orientation changes.
|
|
745
|
+
*
|
|
746
|
+
* @returns The current viewport size
|
|
747
|
+
*
|
|
748
|
+
* @example
|
|
749
|
+
* const { width, height } = useViewportSize();
|
|
750
|
+
*/ function useViewportSize() {
|
|
751
|
+
const [windowSize, setWindowSize] = useState({
|
|
752
|
+
width: 0,
|
|
753
|
+
height: 0
|
|
754
|
+
});
|
|
755
|
+
const setSize = useCallback(()=>{
|
|
756
|
+
setWindowSize({
|
|
757
|
+
width: window.innerWidth || 0,
|
|
758
|
+
height: window.innerHeight || 0
|
|
759
|
+
});
|
|
760
|
+
}, []);
|
|
761
|
+
useWindowEvent("resize", setSize);
|
|
762
|
+
useWindowEvent("orientationchange", setSize);
|
|
763
|
+
useEffect(setSize, []);
|
|
764
|
+
return windowSize;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
;// CONCATENATED MODULE: ./src/hooks/useVisualViewportSize.tsx
|
|
768
|
+
/* v8 ignore start */
|
|
769
|
+
/**
|
|
770
|
+
* Custom hook that returns the current visual viewport size. It will update
|
|
771
|
+
* when the window is resized (zoom, virtual keyboard displayed, etc.) or
|
|
772
|
+
* the orientation changes.
|
|
773
|
+
*
|
|
774
|
+
* @returns The current visual viewport size
|
|
775
|
+
*
|
|
776
|
+
* @example
|
|
777
|
+
* const { width, height } = useVisualViewportSize();
|
|
778
|
+
*/ function useVisualViewportSize() {
|
|
779
|
+
const [windowSize, setWindowSize] = useState({
|
|
780
|
+
width: 0,
|
|
781
|
+
height: 0
|
|
782
|
+
});
|
|
783
|
+
// Define setSize function once and never recreate it
|
|
784
|
+
const setSize = useCallback(()=>{
|
|
785
|
+
setWindowSize({
|
|
786
|
+
width: window?.visualViewport?.width || window.innerWidth || 0,
|
|
787
|
+
height: window?.visualViewport?.height || window.innerHeight || 0
|
|
788
|
+
});
|
|
789
|
+
}, []); // Empty dependency array is correct here
|
|
790
|
+
useEffect(()=>{
|
|
791
|
+
// Initial size setup
|
|
792
|
+
setSize();
|
|
793
|
+
// Set up event listeners
|
|
794
|
+
if (window.visualViewport) {
|
|
795
|
+
window.visualViewport.addEventListener("resize", setSize, {
|
|
796
|
+
passive: true
|
|
797
|
+
});
|
|
798
|
+
window.visualViewport.addEventListener("orientationchange", setSize, {
|
|
799
|
+
passive: true
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
// Cleanup
|
|
803
|
+
return ()=>{
|
|
804
|
+
if (window.visualViewport) {
|
|
805
|
+
window.visualViewport.removeEventListener("resize", setSize);
|
|
806
|
+
window.visualViewport.removeEventListener("orientationchange", setSize);
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
}, [
|
|
810
|
+
setSize
|
|
811
|
+
]);
|
|
812
|
+
return windowSize;
|
|
813
|
+
} /* v8 ignore end */
|
|
814
|
+
|
|
815
|
+
;// CONCATENATED MODULE: ./src/hooks/index.ts
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
export { getHotkeyHandler, shouldFireEvent, useClickOutside, useHaptic, useHotkeys, useInViewport, useInterval, useIsMounted, useLocalStorage, useMergeRefs, useResizeObserver, useUncontrolled, useUniqueId, useViewportSize, useVisualViewportSize };
|