@vitus-labs/hooks 2.6.1 → 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 +1 -1
- package/lib/index.d.ts +103 -3
- package/lib/index.js +269 -15
- package/lib/vitus-labs-hooks.native.js +7 -32
- 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
|
@@ -15,7 +15,12 @@ const useBreakpoint = () => {
|
|
|
15
15
|
const breakpoints = useContext(context)?.theme?.breakpoints;
|
|
16
16
|
const sorted = useMemo(() => {
|
|
17
17
|
if (!breakpoints) return [];
|
|
18
|
-
|
|
18
|
+
const tuples = [];
|
|
19
|
+
for (const name in breakpoints) {
|
|
20
|
+
const value = breakpoints[name];
|
|
21
|
+
if (typeof value === "number") tuples.push([name, value]);
|
|
22
|
+
}
|
|
23
|
+
return tuples.sort(([, a], [, b]) => a - b);
|
|
19
24
|
}, [breakpoints]);
|
|
20
25
|
const [current, setCurrent] = useState(() => {
|
|
21
26
|
if (sorted.length === 0) return void 0;
|
|
@@ -122,6 +127,71 @@ const useControllableState = ({ value, defaultValue, onChange }) => {
|
|
|
122
127
|
}, [current, isControlled])];
|
|
123
128
|
};
|
|
124
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
|
+
|
|
125
195
|
//#endregion
|
|
126
196
|
//#region src/useDebouncedCallback.ts
|
|
127
197
|
/**
|
|
@@ -224,11 +294,56 @@ const useElementSize = () => {
|
|
|
224
294
|
}, []), size];
|
|
225
295
|
};
|
|
226
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
|
+
|
|
227
337
|
//#endregion
|
|
228
338
|
//#region src/useFocus.ts
|
|
229
339
|
/**
|
|
230
340
|
* Simple focus-state hook that returns a boolean plus stable
|
|
231
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.
|
|
232
347
|
*/
|
|
233
348
|
const useFocus = (initial = false) => {
|
|
234
349
|
const [focused, setFocused] = useState(initial);
|
|
@@ -241,28 +356,44 @@ const useFocus = (initial = false) => {
|
|
|
241
356
|
|
|
242
357
|
//#endregion
|
|
243
358
|
//#region src/useFocusTrap.ts
|
|
244
|
-
const FOCUSABLE =
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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";
|
|
250
372
|
/**
|
|
251
373
|
* Traps keyboard focus within the referenced container.
|
|
252
374
|
* Tab and Shift+Tab cycle through focusable elements inside.
|
|
253
375
|
* Useful for modals, dialogs, and dropdown menus.
|
|
254
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
|
+
*
|
|
255
382
|
* Focusable elements are cached and only re-queried when DOM mutations
|
|
256
383
|
* inside the container add/remove nodes — not on every Tab keypress.
|
|
257
384
|
*/
|
|
258
|
-
const useFocusTrap = (ref, enabled = true) => {
|
|
385
|
+
const useFocusTrap = (ref, enabled = true, options) => {
|
|
386
|
+
const autoFocus = options?.autoFocus !== false;
|
|
259
387
|
useEffect(() => {
|
|
260
388
|
if (!enabled) return void 0;
|
|
261
389
|
const container = ref.current;
|
|
262
390
|
if (!container) return void 0;
|
|
263
|
-
let focusable =
|
|
391
|
+
let focusable = [];
|
|
264
392
|
const refresh = () => {
|
|
265
|
-
|
|
393
|
+
const all = container.querySelectorAll(FOCUSABLE);
|
|
394
|
+
const result = [];
|
|
395
|
+
for (const el of all) if (isTabbable(el)) result.push(el);
|
|
396
|
+
focusable = result;
|
|
266
397
|
};
|
|
267
398
|
refresh();
|
|
268
399
|
const observer = new MutationObserver(refresh);
|
|
@@ -270,20 +401,41 @@ const useFocusTrap = (ref, enabled = true) => {
|
|
|
270
401
|
childList: true,
|
|
271
402
|
subtree: true
|
|
272
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
|
+
}
|
|
273
410
|
const handler = (e) => {
|
|
274
|
-
if (e.key !== "Tab" ||
|
|
411
|
+
if (e.key !== "Tab" || focusable.length === 0) return;
|
|
275
412
|
const first = focusable[0];
|
|
276
413
|
const last = focusable[focusable.length - 1];
|
|
277
414
|
const active = document.activeElement;
|
|
278
|
-
if (
|
|
279
|
-
|
|
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
|
+
}
|
|
280
427
|
};
|
|
281
428
|
document.addEventListener("keydown", handler);
|
|
282
429
|
return () => {
|
|
283
430
|
observer.disconnect();
|
|
284
431
|
document.removeEventListener("keydown", handler);
|
|
432
|
+
if (autoFocus && prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
|
|
285
433
|
};
|
|
286
|
-
}, [
|
|
434
|
+
}, [
|
|
435
|
+
ref,
|
|
436
|
+
enabled,
|
|
437
|
+
autoFocus
|
|
438
|
+
]);
|
|
287
439
|
};
|
|
288
440
|
|
|
289
441
|
//#endregion
|
|
@@ -291,6 +443,10 @@ const useFocusTrap = (ref, enabled = true) => {
|
|
|
291
443
|
/**
|
|
292
444
|
* Simple hover-state hook that returns a boolean plus stable
|
|
293
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.
|
|
294
450
|
*/
|
|
295
451
|
const useHover = (initial = false) => {
|
|
296
452
|
const [hover, handleHover] = useState(initial);
|
|
@@ -394,6 +550,69 @@ const useLatest = (value) => {
|
|
|
394
550
|
return ref;
|
|
395
551
|
};
|
|
396
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
|
+
|
|
397
616
|
//#endregion
|
|
398
617
|
//#region src/useMergedRef.ts
|
|
399
618
|
/**
|
|
@@ -432,6 +651,41 @@ const usePrevious = (value) => {
|
|
|
432
651
|
*/
|
|
433
652
|
const useReducedMotion = () => useMediaQuery("(prefers-reduced-motion: reduce)");
|
|
434
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
|
+
|
|
435
689
|
//#endregion
|
|
436
690
|
//#region src/useRootSize.ts
|
|
437
691
|
/**
|
|
@@ -632,5 +886,5 @@ const useWindowResize = ({ throttleDelay = 200, onChange } = {}, { width = 0, he
|
|
|
632
886
|
};
|
|
633
887
|
|
|
634
888
|
//#endregion
|
|
635
|
-
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 };
|
|
636
890
|
//# sourceMappingURL=index.js.map
|
|
@@ -13,7 +13,12 @@ const useBreakpoint = () => {
|
|
|
13
13
|
const breakpoints = useContext(context)?.theme?.breakpoints;
|
|
14
14
|
const sorted = useMemo(() => {
|
|
15
15
|
if (!breakpoints) return [];
|
|
16
|
-
|
|
16
|
+
const tuples = [];
|
|
17
|
+
for (const name in breakpoints) {
|
|
18
|
+
const value = breakpoints[name];
|
|
19
|
+
if (typeof value === "number") tuples.push([name, value]);
|
|
20
|
+
}
|
|
21
|
+
return tuples.sort(([, a], [, b]) => a - b);
|
|
17
22
|
}, [breakpoints]);
|
|
18
23
|
const getMatch = useCallback((width) => {
|
|
19
24
|
let match = sorted[0]?.[0];
|
|
@@ -138,36 +143,6 @@ const useDebouncedValue = (value, delay) => {
|
|
|
138
143
|
return debounced;
|
|
139
144
|
};
|
|
140
145
|
|
|
141
|
-
//#endregion
|
|
142
|
-
//#region src/useFocus.ts
|
|
143
|
-
/**
|
|
144
|
-
* Simple focus-state hook that returns a boolean plus stable
|
|
145
|
-
* `onFocus`/`onBlur` handlers ready to spread onto an element.
|
|
146
|
-
*/
|
|
147
|
-
const useFocus = (initial = false) => {
|
|
148
|
-
const [focused, setFocused] = useState(initial);
|
|
149
|
-
return {
|
|
150
|
-
focused,
|
|
151
|
-
onFocus: useCallback(() => setFocused(true), []),
|
|
152
|
-
onBlur: useCallback(() => setFocused(false), [])
|
|
153
|
-
};
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
//#endregion
|
|
157
|
-
//#region src/useHover.ts
|
|
158
|
-
/**
|
|
159
|
-
* Simple hover-state hook that returns a boolean plus stable
|
|
160
|
-
* `onMouseEnter`/`onMouseLeave` handlers ready to spread onto an element.
|
|
161
|
-
*/
|
|
162
|
-
const useHover = (initial = false) => {
|
|
163
|
-
const [hover, handleHover] = useState(initial);
|
|
164
|
-
return {
|
|
165
|
-
hover,
|
|
166
|
-
onMouseEnter: useCallback(() => handleHover(true), []),
|
|
167
|
-
onMouseLeave: useCallback(() => handleHover(false), [])
|
|
168
|
-
};
|
|
169
|
-
};
|
|
170
|
-
|
|
171
146
|
//#endregion
|
|
172
147
|
//#region src/useInterval.ts
|
|
173
148
|
/**
|
|
@@ -431,5 +406,5 @@ const useWindowResize = ({ onChange } = {}, { width, height } = {}) => {
|
|
|
431
406
|
};
|
|
432
407
|
|
|
433
408
|
//#endregion
|
|
434
|
-
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 };
|
|
435
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.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.
|
|
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.
|
|
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
|
}
|