@vitus-labs/hooks 2.6.2 → 2.7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @vitus-labs/hooks
2
2
 
3
- 28 React hooks for UI interactions, state management, DOM observation, accessibility, and theming. 2.15KB gzipped.
3
+ 28 React hooks for UI interactions, state management, DOM observation, accessibility, and theming. ~3.4 KB gzipped.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@vitus-labs/hooks)](https://www.npmjs.com/package/@vitus-labs/hooks)
6
6
  [![license](https://img.shields.io/npm/l/@vitus-labs/hooks)](https://github.com/vitus-labs/ui-system/blob/main/LICENSE)
package/lib/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { DependencyList, EffectCallback, Ref, useLayoutEffect } from "react";
1
+ import { DependencyList, EffectCallback, Ref, RefObject, useLayoutEffect } from "react";
2
2
 
3
3
  //#region src/useBreakpoint.d.ts
4
4
  type UseBreakpoint = () => string | undefined;
@@ -47,6 +47,24 @@ type UseControllableState = <T>(options: UseControllableStateOptions<T>) => [T,
47
47
  */
48
48
  declare const useControllableState: UseControllableState;
49
49
  //#endregion
50
+ //#region src/useCopyToClipboard.d.ts
51
+ type UseCopyToClipboardReturn = readonly [copied: boolean, copy: (text: string) => Promise<boolean>, reset: () => void];
52
+ type UseCopyToClipboard = (resetMs?: number) => UseCopyToClipboardReturn;
53
+ /**
54
+ * Copy text to the clipboard with a transient "copied" flag (auto-resets
55
+ * after `resetMs`, default 2000). Falls back to the legacy
56
+ * `document.execCommand('copy')` path on browsers without the async
57
+ * Clipboard API (or when invoked outside a user gesture / on an HTTP
58
+ * origin where the API rejects).
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * const [copied, copy] = useCopyToClipboard()
63
+ * <button onClick={() => copy('hello')}>{copied ? '✓ Copied' : 'Copy'}</button>
64
+ * ```
65
+ */
66
+ declare const useCopyToClipboard: UseCopyToClipboard;
67
+ //#endregion
50
68
  //#region src/useDebouncedCallback.d.ts
51
69
  type DebouncedFn<T extends (...args: any[]) => any> = {
52
70
  (...args: Parameters<T>): void;
@@ -88,6 +106,28 @@ type UseElementSize = () => [(node: Element | null) => void, Size];
88
106
  */
89
107
  declare const useElementSize: UseElementSize;
90
108
  //#endregion
109
+ //#region src/useEventListener.d.ts
110
+ type EventTargetLike = EventTarget | {
111
+ current: EventTarget | null;
112
+ } | null;
113
+ type UseEventListener = <K extends keyof WindowEventMap>(event: K, handler: (e: WindowEventMap[K]) => void, target?: EventTargetLike, options?: AddEventListenerOptions | boolean) => void;
114
+ /**
115
+ * Subscribe to a DOM event with the right typing and cleanup. The
116
+ * handler is captured via a ref so the registered listener stays stable
117
+ * even when consumers pass a fresh closure each render — no re-attach
118
+ * thrash.
119
+ *
120
+ * `target` defaults to `window` and may also be a ref (the ref is read
121
+ * lazily inside the effect so it can change between renders).
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * useEventListener('keydown', (e) => { if (e.key === 'Escape') onClose() })
126
+ * useEventListener('click', onClickOutside, popoverRef)
127
+ * ```
128
+ */
129
+ declare const useEventListener: UseEventListener;
130
+ //#endregion
91
131
  //#region src/useFocus.d.ts
92
132
  type UseFocus = (initialValue?: boolean) => {
93
133
  focused: boolean;
@@ -97,18 +137,36 @@ type UseFocus = (initialValue?: boolean) => {
97
137
  /**
98
138
  * Simple focus-state hook that returns a boolean plus stable
99
139
  * `onFocus`/`onBlur` handlers ready to spread onto an element.
140
+ *
141
+ * **Web only.** Not re-exported on React Native — RN's `onFocus`/`onBlur`
142
+ * only exist on focusable components (TextInput, Pressable) and the
143
+ * shape varies enough that a single generic hook is misleading. Use the
144
+ * RN component's own focus props directly.
100
145
  */
101
146
  declare const useFocus: UseFocus;
102
147
  //#endregion
103
148
  //#region src/useFocusTrap.d.ts
149
+ type UseFocusTrapOptions = {
150
+ /**
151
+ * If true, on enable: move focus to the first focusable element inside
152
+ * the container (or the container itself when none exist). Restores focus
153
+ * to the previously-active element on disable. Default `true`.
154
+ */
155
+ autoFocus?: boolean;
156
+ };
104
157
  type UseFocusTrap = (ref: {
105
158
  current: HTMLElement | null;
106
- }, enabled?: boolean) => void;
159
+ }, enabled?: boolean, options?: UseFocusTrapOptions) => void;
107
160
  /**
108
161
  * Traps keyboard focus within the referenced container.
109
162
  * Tab and Shift+Tab cycle through focusable elements inside.
110
163
  * Useful for modals, dialogs, and dropdown menus.
111
164
  *
165
+ * With `autoFocus` (default `true`), focus moves INTO the container on
166
+ * enable and is restored to the previously-focused element on disable —
167
+ * matching the Dialog WAI-ARIA pattern. Pair with `useScrollLock` for full
168
+ * modal a11y.
169
+ *
112
170
  * Focusable elements are cached and only re-queried when DOM mutations
113
171
  * inside the container add/remove nodes — not on every Tab keypress.
114
172
  */
@@ -123,6 +181,10 @@ type UseHover = (initialValue?: boolean) => {
123
181
  /**
124
182
  * Simple hover-state hook that returns a boolean plus stable
125
183
  * `onMouseEnter`/`onMouseLeave` handlers ready to spread onto an element.
184
+ *
185
+ * **Web only.** Not re-exported on React Native — `onMouseEnter`/
186
+ * `onMouseLeave` are never fired by any RN component. For RN hover state
187
+ * use Pressable's `onHoverIn` / `onHoverOut` props directly.
126
188
  */
127
189
  declare const useHover: UseHover;
128
190
  //#endregion
@@ -180,6 +242,23 @@ type UseLatest = <T>(value: T) => {
180
242
  */
181
243
  declare const useLatest: UseLatest;
182
244
  //#endregion
245
+ //#region src/useLocalStorage.d.ts
246
+ type UseLocalStorage = <T>(key: string, initialValue: T) => [T, (value: T | ((prev: T) => T)) => void, () => void];
247
+ /**
248
+ * State synced with `localStorage`. SSR-safe (returns `initialValue` on
249
+ * the server) and tolerant of private-mode / quota-exceeded errors.
250
+ *
251
+ * Returns `[value, setValue, remove]`. `setValue` accepts either a new
252
+ * value or an updater function; `remove` clears the entry and resets the
253
+ * in-memory state to `initialValue`.
254
+ *
255
+ * @example
256
+ * ```ts
257
+ * const [theme, setTheme, clear] = useLocalStorage('theme', 'dark')
258
+ * ```
259
+ */
260
+ declare const useLocalStorage: UseLocalStorage;
261
+ //#endregion
183
262
  //#region src/useMediaQuery.d.ts
184
263
  type UseMediaQuery = (query: string) => boolean;
185
264
  /**
@@ -212,6 +291,27 @@ type UseReducedMotion = () => boolean;
212
291
  */
213
292
  declare const useReducedMotion: UseReducedMotion;
214
293
  //#endregion
294
+ //#region src/useResizeObserver.d.ts
295
+ type UseResizeObserver = (ref: RefObject<HTMLElement | null>) => DOMRectReadOnly | null;
296
+ /**
297
+ * Returns the latest `contentRect` of the referenced element via a
298
+ * shared `ResizeObserver`. Reads `null` until the first observation.
299
+ * Safe to render with — uses state so the component re-renders when
300
+ * dimensions change.
301
+ *
302
+ * Lower-level than `useElementSize` (which returns `{width, height}`
303
+ * numbers); this hook exposes the full DOMRect so consumers can read
304
+ * top/left for absolute positioning.
305
+ *
306
+ * @example
307
+ * ```tsx
308
+ * const ref = useRef<HTMLDivElement>(null)
309
+ * const rect = useResizeObserver(ref)
310
+ * return <div ref={ref}>w: {rect?.width ?? 0}</div>
311
+ * ```
312
+ */
313
+ declare const useResizeObserver: UseResizeObserver;
314
+ //#endregion
215
315
  //#region src/useRootSize.d.ts
216
316
  type RootSizeResult = {
217
317
  rootSize: number;
@@ -324,5 +424,5 @@ type UseWindowResize = (params?: Partial<{
324
424
  */
325
425
  declare const useWindowResize: UseWindowResize;
326
426
  //#endregion
327
- export { type UseBreakpoint, type UseClickOutside, type UseColorScheme, type UseControllableState, type UseDebouncedCallback, type UseDebouncedValue, type UseElementSize, type UseFocus, type UseFocusTrap, type UseHover, type UseIntersection, type UseInterval, type UseIsomorphicLayoutEffect, type UseKeyboard, type UseLatest, type UseMediaQuery, type UseMergedRef, type UsePrevious, type UseReducedMotion, type UseRootSize, type UseScrollLock, type UseSpacing, type UseThemeValue, type UseThrottledCallback, type UseTimeout, type UseToggle, type UseUpdateEffect, type UseWindowResize, useBreakpoint, useClickOutside, useColorScheme, useControllableState, useDebouncedCallback, useDebouncedValue, useElementSize, useFocus, useFocusTrap, useHover, useIntersection, useInterval, useIsomorphicLayoutEffect, useKeyboard, useLatest, useMediaQuery, useMergedRef, usePrevious, useReducedMotion, useRootSize, useScrollLock, useSpacing, useThemeValue, useThrottledCallback, useTimeout, useToggle, useUpdateEffect, useWindowResize };
427
+ export { type UseBreakpoint, type UseClickOutside, type UseColorScheme, type UseControllableState, type UseCopyToClipboard, type UseDebouncedCallback, type UseDebouncedValue, type UseElementSize, type UseEventListener, type UseFocus, type UseFocusTrap, type UseHover, type UseIntersection, type UseInterval, type UseIsomorphicLayoutEffect, type UseKeyboard, type UseLatest, type UseLocalStorage, type UseMediaQuery, type UseMergedRef, type UsePrevious, type UseReducedMotion, type UseResizeObserver, type UseRootSize, type UseScrollLock, type UseSpacing, type UseThemeValue, type UseThrottledCallback, type UseTimeout, type UseToggle, type UseUpdateEffect, type UseWindowResize, useBreakpoint, useClickOutside, useColorScheme, useControllableState, useCopyToClipboard, useDebouncedCallback, useDebouncedValue, useElementSize, useEventListener, useFocus, useFocusTrap, useHover, useIntersection, useInterval, useIsomorphicLayoutEffect, useKeyboard, useLatest, useLocalStorage, useMediaQuery, useMergedRef, usePrevious, useReducedMotion, useResizeObserver, useRootSize, useScrollLock, useSpacing, useThemeValue, useThrottledCallback, useTimeout, useToggle, useUpdateEffect, useWindowResize };
328
428
  //# sourceMappingURL=index2.d.ts.map
package/lib/index.js CHANGED
@@ -127,6 +127,71 @@ const useControllableState = ({ value, defaultValue, onChange }) => {
127
127
  }, [current, isControlled])];
128
128
  };
129
129
 
130
+ //#endregion
131
+ //#region src/useCopyToClipboard.ts
132
+ /**
133
+ * Copy text to the clipboard with a transient "copied" flag (auto-resets
134
+ * after `resetMs`, default 2000). Falls back to the legacy
135
+ * `document.execCommand('copy')` path on browsers without the async
136
+ * Clipboard API (or when invoked outside a user gesture / on an HTTP
137
+ * origin where the API rejects).
138
+ *
139
+ * @example
140
+ * ```tsx
141
+ * const [copied, copy] = useCopyToClipboard()
142
+ * <button onClick={() => copy('hello')}>{copied ? '✓ Copied' : 'Copy'}</button>
143
+ * ```
144
+ */
145
+ const useCopyToClipboard = (resetMs = 2e3) => {
146
+ const [copied, setCopied] = useState(false);
147
+ const timerRef = useRef(null);
148
+ const reset = useCallback(() => {
149
+ if (timerRef.current !== null) {
150
+ clearTimeout(timerRef.current);
151
+ timerRef.current = null;
152
+ }
153
+ setCopied(false);
154
+ }, []);
155
+ const copy = useCallback(async (text) => {
156
+ let ok = false;
157
+ try {
158
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
159
+ await navigator.clipboard.writeText(text);
160
+ ok = true;
161
+ } else if (typeof document !== "undefined") {
162
+ const ta = document.createElement("textarea");
163
+ ta.value = text;
164
+ ta.setAttribute("readonly", "");
165
+ ta.style.position = "absolute";
166
+ ta.style.left = "-9999px";
167
+ document.body.appendChild(ta);
168
+ ta.select();
169
+ ok = document.execCommand?.("copy") === true;
170
+ document.body.removeChild(ta);
171
+ }
172
+ } catch {
173
+ ok = false;
174
+ }
175
+ if (ok) {
176
+ setCopied(true);
177
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
178
+ if (resetMs > 0) timerRef.current = setTimeout(() => {
179
+ timerRef.current = null;
180
+ setCopied(false);
181
+ }, resetMs);
182
+ }
183
+ return ok;
184
+ }, [resetMs]);
185
+ useEffect(() => () => {
186
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
187
+ }, []);
188
+ return [
189
+ copied,
190
+ copy,
191
+ reset
192
+ ];
193
+ };
194
+
130
195
  //#endregion
131
196
  //#region src/useDebouncedCallback.ts
132
197
  /**
@@ -229,11 +294,56 @@ const useElementSize = () => {
229
294
  }, []), size];
230
295
  };
231
296
 
297
+ //#endregion
298
+ //#region src/useEventListener.ts
299
+ /**
300
+ * Subscribe to a DOM event with the right typing and cleanup. The
301
+ * handler is captured via a ref so the registered listener stays stable
302
+ * even when consumers pass a fresh closure each render — no re-attach
303
+ * thrash.
304
+ *
305
+ * `target` defaults to `window` and may also be a ref (the ref is read
306
+ * lazily inside the effect so it can change between renders).
307
+ *
308
+ * @example
309
+ * ```ts
310
+ * useEventListener('keydown', (e) => { if (e.key === 'Escape') onClose() })
311
+ * useEventListener('click', onClickOutside, popoverRef)
312
+ * ```
313
+ */
314
+ const useEventListener = (event, handler, target, options) => {
315
+ const handlerRef = useRef(handler);
316
+ handlerRef.current = handler;
317
+ useEffect(() => {
318
+ if (typeof window === "undefined") return void 0;
319
+ const node = resolveTarget(target);
320
+ if (!node || typeof node.addEventListener !== "function") return void 0;
321
+ const listener = (e) => handlerRef.current(e);
322
+ node.addEventListener(event, listener, options);
323
+ return () => node.removeEventListener(event, listener, options);
324
+ }, [
325
+ event,
326
+ target,
327
+ options
328
+ ]);
329
+ };
330
+ const resolveTarget = (target) => {
331
+ if (target === void 0) return typeof window === "undefined" ? null : window;
332
+ if (target === null) return null;
333
+ if ("current" in target) return target.current;
334
+ return target;
335
+ };
336
+
232
337
  //#endregion
233
338
  //#region src/useFocus.ts
234
339
  /**
235
340
  * Simple focus-state hook that returns a boolean plus stable
236
341
  * `onFocus`/`onBlur` handlers ready to spread onto an element.
342
+ *
343
+ * **Web only.** Not re-exported on React Native — RN's `onFocus`/`onBlur`
344
+ * only exist on focusable components (TextInput, Pressable) and the
345
+ * shape varies enough that a single generic hook is misleading. Use the
346
+ * RN component's own focus props directly.
237
347
  */
238
348
  const useFocus = (initial = false) => {
239
349
  const [focused, setFocused] = useState(initial);
@@ -246,28 +356,44 @@ const useFocus = (initial = false) => {
246
356
 
247
357
  //#endregion
248
358
  //#region src/useFocusTrap.ts
249
- const FOCUSABLE = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
250
- const wrapFocus = (e, target) => {
251
- if (!target) return;
252
- e.preventDefault();
253
- target.focus();
254
- };
359
+ const FOCUSABLE = [
360
+ "a[href]",
361
+ "button:not([disabled])",
362
+ "input:not([disabled])",
363
+ "select:not([disabled])",
364
+ "textarea:not([disabled])",
365
+ "[contenteditable]:not([contenteditable=\"false\"])",
366
+ "video[controls]",
367
+ "audio[controls]",
368
+ "summary",
369
+ "[tabindex]:not([tabindex=\"-1\"])"
370
+ ].join(",");
371
+ const isTabbable = (el) => el.getAttribute("aria-disabled") !== "true";
255
372
  /**
256
373
  * Traps keyboard focus within the referenced container.
257
374
  * Tab and Shift+Tab cycle through focusable elements inside.
258
375
  * Useful for modals, dialogs, and dropdown menus.
259
376
  *
377
+ * With `autoFocus` (default `true`), focus moves INTO the container on
378
+ * enable and is restored to the previously-focused element on disable —
379
+ * matching the Dialog WAI-ARIA pattern. Pair with `useScrollLock` for full
380
+ * modal a11y.
381
+ *
260
382
  * Focusable elements are cached and only re-queried when DOM mutations
261
383
  * inside the container add/remove nodes — not on every Tab keypress.
262
384
  */
263
- const useFocusTrap = (ref, enabled = true) => {
385
+ const useFocusTrap = (ref, enabled = true, options) => {
386
+ const autoFocus = options?.autoFocus !== false;
264
387
  useEffect(() => {
265
388
  if (!enabled) return void 0;
266
389
  const container = ref.current;
267
390
  if (!container) return void 0;
268
- let focusable = null;
391
+ let focusable = [];
269
392
  const refresh = () => {
270
- focusable = container.querySelectorAll(FOCUSABLE);
393
+ const all = container.querySelectorAll(FOCUSABLE);
394
+ const result = [];
395
+ for (const el of all) if (isTabbable(el)) result.push(el);
396
+ focusable = result;
271
397
  };
272
398
  refresh();
273
399
  const observer = new MutationObserver(refresh);
@@ -275,20 +401,41 @@ const useFocusTrap = (ref, enabled = true) => {
275
401
  childList: true,
276
402
  subtree: true
277
403
  });
404
+ const prevFocus = document.activeElement;
405
+ if (autoFocus) if (focusable.length > 0) focusable[0].focus();
406
+ else {
407
+ if (container.tabIndex < 0) container.tabIndex = -1;
408
+ container.focus();
409
+ }
278
410
  const handler = (e) => {
279
- if (e.key !== "Tab" || !focusable || focusable.length === 0) return;
411
+ if (e.key !== "Tab" || focusable.length === 0) return;
280
412
  const first = focusable[0];
281
413
  const last = focusable[focusable.length - 1];
282
414
  const active = document.activeElement;
283
- if (e.shiftKey && active === first) wrapFocus(e, last);
284
- else if (!e.shiftKey && active === last) wrapFocus(e, first);
415
+ if (active && !container.contains(active)) {
416
+ e.preventDefault();
417
+ first.focus();
418
+ return;
419
+ }
420
+ if (e.shiftKey && active === first) {
421
+ e.preventDefault();
422
+ last.focus();
423
+ } else if (!e.shiftKey && active === last) {
424
+ e.preventDefault();
425
+ first.focus();
426
+ }
285
427
  };
286
428
  document.addEventListener("keydown", handler);
287
429
  return () => {
288
430
  observer.disconnect();
289
431
  document.removeEventListener("keydown", handler);
432
+ if (autoFocus && prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
290
433
  };
291
- }, [ref, enabled]);
434
+ }, [
435
+ ref,
436
+ enabled,
437
+ autoFocus
438
+ ]);
292
439
  };
293
440
 
294
441
  //#endregion
@@ -296,6 +443,10 @@ const useFocusTrap = (ref, enabled = true) => {
296
443
  /**
297
444
  * Simple hover-state hook that returns a boolean plus stable
298
445
  * `onMouseEnter`/`onMouseLeave` handlers ready to spread onto an element.
446
+ *
447
+ * **Web only.** Not re-exported on React Native — `onMouseEnter`/
448
+ * `onMouseLeave` are never fired by any RN component. For RN hover state
449
+ * use Pressable's `onHoverIn` / `onHoverOut` props directly.
299
450
  */
300
451
  const useHover = (initial = false) => {
301
452
  const [hover, handleHover] = useState(initial);
@@ -399,6 +550,69 @@ const useLatest = (value) => {
399
550
  return ref;
400
551
  };
401
552
 
553
+ //#endregion
554
+ //#region src/useLocalStorage.ts
555
+ const IS_SERVER = typeof window === "undefined";
556
+ /**
557
+ * State synced with `localStorage`. SSR-safe (returns `initialValue` on
558
+ * the server) and tolerant of private-mode / quota-exceeded errors.
559
+ *
560
+ * Returns `[value, setValue, remove]`. `setValue` accepts either a new
561
+ * value or an updater function; `remove` clears the entry and resets the
562
+ * in-memory state to `initialValue`.
563
+ *
564
+ * @example
565
+ * ```ts
566
+ * const [theme, setTheme, clear] = useLocalStorage('theme', 'dark')
567
+ * ```
568
+ */
569
+ const useLocalStorage = (key, initialValue) => {
570
+ const [value, setValue] = useState(() => {
571
+ if (IS_SERVER) return initialValue;
572
+ try {
573
+ const raw = window.localStorage.getItem(key);
574
+ return raw === null ? initialValue : JSON.parse(raw);
575
+ } catch {
576
+ return initialValue;
577
+ }
578
+ });
579
+ const update = useCallback((next) => {
580
+ setValue((prev) => {
581
+ const resolved = typeof next === "function" ? next(prev) : next;
582
+ if (!IS_SERVER) try {
583
+ window.localStorage.setItem(key, JSON.stringify(resolved));
584
+ } catch {}
585
+ return resolved;
586
+ });
587
+ }, [key]);
588
+ const remove = useCallback(() => {
589
+ if (!IS_SERVER) try {
590
+ window.localStorage.removeItem(key);
591
+ } catch {}
592
+ setValue(initialValue);
593
+ }, [initialValue, key]);
594
+ useEffect(() => {
595
+ if (IS_SERVER) return void 0;
596
+ const onStorage = (e) => {
597
+ if (e.key !== key || e.storageArea !== window.localStorage) return;
598
+ if (e.newValue === null) {
599
+ setValue(initialValue);
600
+ return;
601
+ }
602
+ try {
603
+ setValue(JSON.parse(e.newValue));
604
+ } catch {}
605
+ };
606
+ window.addEventListener("storage", onStorage);
607
+ return () => window.removeEventListener("storage", onStorage);
608
+ }, [key, initialValue]);
609
+ return [
610
+ value,
611
+ update,
612
+ remove
613
+ ];
614
+ };
615
+
402
616
  //#endregion
403
617
  //#region src/useMergedRef.ts
404
618
  /**
@@ -437,6 +651,41 @@ const usePrevious = (value) => {
437
651
  */
438
652
  const useReducedMotion = () => useMediaQuery("(prefers-reduced-motion: reduce)");
439
653
 
654
+ //#endregion
655
+ //#region src/useResizeObserver.ts
656
+ /**
657
+ * Returns the latest `contentRect` of the referenced element via a
658
+ * shared `ResizeObserver`. Reads `null` until the first observation.
659
+ * Safe to render with — uses state so the component re-renders when
660
+ * dimensions change.
661
+ *
662
+ * Lower-level than `useElementSize` (which returns `{width, height}`
663
+ * numbers); this hook exposes the full DOMRect so consumers can read
664
+ * top/left for absolute positioning.
665
+ *
666
+ * @example
667
+ * ```tsx
668
+ * const ref = useRef<HTMLDivElement>(null)
669
+ * const rect = useResizeObserver(ref)
670
+ * return <div ref={ref}>w: {rect?.width ?? 0}</div>
671
+ * ```
672
+ */
673
+ const useResizeObserver = (ref) => {
674
+ const [rect, setRect] = useState(null);
675
+ useEffect(() => {
676
+ if (typeof ResizeObserver === "undefined") return void 0;
677
+ const node = ref.current;
678
+ if (!node) return void 0;
679
+ const observer = new ResizeObserver((entries) => {
680
+ const entry = entries[0];
681
+ if (entry) setRect(entry.contentRect);
682
+ });
683
+ observer.observe(node);
684
+ return () => observer.disconnect();
685
+ }, [ref]);
686
+ return rect;
687
+ };
688
+
440
689
  //#endregion
441
690
  //#region src/useRootSize.ts
442
691
  /**
@@ -637,5 +886,5 @@ const useWindowResize = ({ throttleDelay = 200, onChange } = {}, { width = 0, he
637
886
  };
638
887
 
639
888
  //#endregion
640
- export { useBreakpoint, useClickOutside, useColorScheme, useControllableState, useDebouncedCallback, useDebouncedValue, useElementSize, useFocus, useFocusTrap, useHover, useIntersection, useInterval, useIsomorphicLayoutEffect, useKeyboard, useLatest, useMediaQuery, useMergedRef, usePrevious, useReducedMotion, useRootSize, useScrollLock, useSpacing, useThemeValue, useThrottledCallback, useTimeout, useToggle, useUpdateEffect, useWindowResize };
889
+ export { useBreakpoint, useClickOutside, useColorScheme, useControllableState, useCopyToClipboard, useDebouncedCallback, useDebouncedValue, useElementSize, useEventListener, useFocus, useFocusTrap, useHover, useIntersection, useInterval, useIsomorphicLayoutEffect, useKeyboard, useLatest, useLocalStorage, useMediaQuery, useMergedRef, usePrevious, useReducedMotion, useResizeObserver, useRootSize, useScrollLock, useSpacing, useThemeValue, useThrottledCallback, useTimeout, useToggle, useUpdateEffect, useWindowResize };
641
890
  //# sourceMappingURL=index.js.map
@@ -143,36 +143,6 @@ const useDebouncedValue = (value, delay) => {
143
143
  return debounced;
144
144
  };
145
145
 
146
- //#endregion
147
- //#region src/useFocus.ts
148
- /**
149
- * Simple focus-state hook that returns a boolean plus stable
150
- * `onFocus`/`onBlur` handlers ready to spread onto an element.
151
- */
152
- const useFocus = (initial = false) => {
153
- const [focused, setFocused] = useState(initial);
154
- return {
155
- focused,
156
- onFocus: useCallback(() => setFocused(true), []),
157
- onBlur: useCallback(() => setFocused(false), [])
158
- };
159
- };
160
-
161
- //#endregion
162
- //#region src/useHover.ts
163
- /**
164
- * Simple hover-state hook that returns a boolean plus stable
165
- * `onMouseEnter`/`onMouseLeave` handlers ready to spread onto an element.
166
- */
167
- const useHover = (initial = false) => {
168
- const [hover, handleHover] = useState(initial);
169
- return {
170
- hover,
171
- onMouseEnter: useCallback(() => handleHover(true), []),
172
- onMouseLeave: useCallback(() => handleHover(false), [])
173
- };
174
- };
175
-
176
146
  //#endregion
177
147
  //#region src/useInterval.ts
178
148
  /**
@@ -436,5 +406,5 @@ const useWindowResize = ({ onChange } = {}, { width, height } = {}) => {
436
406
  };
437
407
 
438
408
  //#endregion
439
- export { useBreakpoint, useColorScheme, useControllableState, useDebouncedCallback, useDebouncedValue, useFocus, useHover, useInterval, useIsomorphicLayoutEffect, useKeyboard, useLatest, useMergedRef, usePrevious, useReducedMotion, useRootSize, useSpacing, useThemeValue, useThrottledCallback, useTimeout, useToggle, useUpdateEffect, useWindowResize };
409
+ export { useBreakpoint, useColorScheme, useControllableState, useDebouncedCallback, useDebouncedValue, useInterval, useIsomorphicLayoutEffect, useKeyboard, useLatest, useMergedRef, usePrevious, useReducedMotion, useRootSize, useSpacing, useThemeValue, useThrottledCallback, useTimeout, useToggle, useUpdateEffect, useWindowResize };
440
410
  //# sourceMappingURL=vitus-labs-hooks.native.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitus-labs/hooks",
3
- "version": "2.6.2",
3
+ "version": "2.7.0",
4
4
  "license": "MIT",
5
5
  "author": "Vit Bokisch <vit@bokisch.cz>",
6
6
  "maintainers": [
@@ -51,7 +51,7 @@
51
51
  "node": ">= 18"
52
52
  },
53
53
  "peerDependencies": {
54
- "@vitus-labs/core": "^2.6.2",
54
+ "@vitus-labs/core": "^2.7.0",
55
55
  "react": ">= 19",
56
56
  "react-native": ">= 0.76"
57
57
  },
@@ -62,9 +62,9 @@
62
62
  },
63
63
  "devDependencies": {
64
64
  "@vitus-labs/core": "workspace:*",
65
- "@vitus-labs/tools-rolldown": "2.3.1",
66
- "@vitus-labs/tools-storybook": "2.3.1",
67
- "@vitus-labs/tools-typescript": "2.3.1",
65
+ "@vitus-labs/tools-rolldown": "2.5.0",
66
+ "@vitus-labs/tools-storybook": "2.5.0",
67
+ "@vitus-labs/tools-typescript": "2.5.0",
68
68
  "react-native": ">=0.84.1"
69
69
  }
70
70
  }