@vitus-labs/hooks 2.6.2 → 2.7.1
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 +1 -1
- package/lib/index.d.ts +103 -3
- package/lib/index.js +263 -14
- package/lib/vitus-labs-hooks.native.js +1 -31
- package/package.json +5 -5
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.
|
|
3
|
+
28 React hooks for UI interactions, state management, DOM observation, accessibility, and theming. ~3.4 KB gzipped.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@vitus-labs/hooks)
|
|
6
6
|
[](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 =
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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 =
|
|
391
|
+
let focusable = [];
|
|
269
392
|
const refresh = () => {
|
|
270
|
-
|
|
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" ||
|
|
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 (
|
|
284
|
-
|
|
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
|
-
}, [
|
|
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,
|
|
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.
|
|
3
|
+
"version": "2.7.1",
|
|
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.
|
|
54
|
+
"@vitus-labs/core": "^2.7.1",
|
|
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.
|
|
66
|
-
"@vitus-labs/tools-storybook": "2.
|
|
67
|
-
"@vitus-labs/tools-typescript": "2.
|
|
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
|
}
|