react-hook-toolkit 4.0.0 → 5.0.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 +289 -36
- package/dist/chunk1516/chunk1617.d.ts +190 -0
- package/dist/chunk1516/chunk1617.js +408 -0
- package/dist/chunk1516/chunk1718.d.ts +324 -0
- package/dist/chunk1516/chunk1718.js +783 -0
- package/dist/chunk1516/chunk1819.d.ts +90 -0
- package/dist/chunk1516/chunk1819.js +235 -0
- package/dist/chunk1516/chunk1920.d.ts +104 -0
- package/dist/chunk1516/chunk1920.js +274 -0
- package/dist/chunk1516/chunk2021.d.ts +219 -0
- package/dist/chunk1516/chunk2021.js +406 -0
- package/dist/chunk1516/chunk2122.d.ts +77 -0
- package/dist/chunk1516/chunk2122.js +193 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Returns a function that scrolls the window (or a given element) to the top.
|
|
4
|
+
* Also returns a boolean indicating whether the user has scrolled down enough
|
|
5
|
+
* that a "back to top" button should be shown (default threshold: 300px).
|
|
6
|
+
*/
|
|
7
|
+
export function useScrollToTop(threshold = 300, options = {}) {
|
|
8
|
+
const { behavior = 'smooth', element } = options;
|
|
9
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
var _a;
|
|
12
|
+
const target = (_a = element === null || element === void 0 ? void 0 : element.current) !== null && _a !== void 0 ? _a : window;
|
|
13
|
+
const getY = () => (element === null || element === void 0 ? void 0 : element.current) ? element.current.scrollTop : window.scrollY;
|
|
14
|
+
const handler = () => setIsVisible(getY() > threshold);
|
|
15
|
+
target.addEventListener('scroll', handler, { passive: true });
|
|
16
|
+
return () => target.removeEventListener('scroll', handler);
|
|
17
|
+
}, [threshold, element]);
|
|
18
|
+
const scrollToTop = useCallback(() => {
|
|
19
|
+
var _a, _b, _c;
|
|
20
|
+
const target = (_a = element === null || element === void 0 ? void 0 : element.current) !== null && _a !== void 0 ? _a : window;
|
|
21
|
+
(_c = (_b = target).scrollTo) === null || _c === void 0 ? void 0 : _c.call(_b, { top: 0, behavior });
|
|
22
|
+
if (element === null || element === void 0 ? void 0 : element.current)
|
|
23
|
+
element.current.scrollTo({ top: 0, behavior });
|
|
24
|
+
}, [behavior, element]);
|
|
25
|
+
return [isVisible, scrollToTop];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Returns a ref and a function that scrolls the ref'd element into the viewport.
|
|
29
|
+
*/
|
|
30
|
+
export function useScrollIntoView(defaultOptions = { behavior: 'smooth', block: 'center' }) {
|
|
31
|
+
const ref = useRef(null);
|
|
32
|
+
const scrollIntoView = useCallback((options) => {
|
|
33
|
+
var _a;
|
|
34
|
+
(_a = ref.current) === null || _a === void 0 ? void 0 : _a.scrollIntoView(Object.assign(Object.assign({}, defaultOptions), options));
|
|
35
|
+
}, [defaultOptions]);
|
|
36
|
+
return { ref, scrollIntoView };
|
|
37
|
+
}
|
|
38
|
+
// ─── useFocusTrap ─────────────────────────────────────────────────────────────
|
|
39
|
+
const FOCUSABLE = [
|
|
40
|
+
'a[href]', 'area[href]', 'input:not([disabled])', 'select:not([disabled])',
|
|
41
|
+
'textarea:not([disabled])', 'button:not([disabled])', 'iframe', 'object',
|
|
42
|
+
'embed', '[tabindex]:not([tabindex="-1"])', '[contenteditable]',
|
|
43
|
+
].join(',');
|
|
44
|
+
/**
|
|
45
|
+
* Traps keyboard focus within a container element. Tab/Shift+Tab cycle only
|
|
46
|
+
* through focusable elements inside the container while the trap is active.
|
|
47
|
+
* Essential for accessible modals, drawers, and dialogs.
|
|
48
|
+
*/
|
|
49
|
+
export function useFocusTrap(initiallyActive = false) {
|
|
50
|
+
const ref = useRef(null);
|
|
51
|
+
const [isActive, setIsActive] = useState(initiallyActive);
|
|
52
|
+
const previousFocusRef = useRef(null);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
var _a;
|
|
55
|
+
if (!isActive || !ref.current)
|
|
56
|
+
return;
|
|
57
|
+
previousFocusRef.current = document.activeElement;
|
|
58
|
+
const getFocusable = () => Array.from(ref.current.querySelectorAll(FOCUSABLE));
|
|
59
|
+
const focusable = getFocusable();
|
|
60
|
+
(_a = focusable[0]) === null || _a === void 0 ? void 0 : _a.focus();
|
|
61
|
+
const handler = (e) => {
|
|
62
|
+
if (e.key !== 'Tab')
|
|
63
|
+
return;
|
|
64
|
+
const elements = getFocusable();
|
|
65
|
+
const first = elements[0];
|
|
66
|
+
const last = elements[elements.length - 1];
|
|
67
|
+
if (e.shiftKey) {
|
|
68
|
+
if (document.activeElement === first) {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
last === null || last === void 0 ? void 0 : last.focus();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
if (document.activeElement === last) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
first === null || first === void 0 ? void 0 : first.focus();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
document.addEventListener('keydown', handler);
|
|
81
|
+
return () => {
|
|
82
|
+
var _a;
|
|
83
|
+
document.removeEventListener('keydown', handler);
|
|
84
|
+
(_a = previousFocusRef.current) === null || _a === void 0 ? void 0 : _a.focus();
|
|
85
|
+
};
|
|
86
|
+
}, [isActive]);
|
|
87
|
+
const activate = useCallback(() => setIsActive(true), []);
|
|
88
|
+
const deactivate = useCallback(() => setIsActive(false), []);
|
|
89
|
+
return { ref, activate, deactivate, isActive };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Windowed (virtual) list rendering. Only renders items visible in the
|
|
93
|
+
* scroll container plus an overscan buffer. Dramatically reduces DOM nodes for
|
|
94
|
+
* large lists. Requires a fixed itemHeight.
|
|
95
|
+
*/
|
|
96
|
+
export function useVirtualList(items, options) {
|
|
97
|
+
const { itemHeight, overscan = 3 } = options;
|
|
98
|
+
const containerRef = useRef(null);
|
|
99
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
100
|
+
const [containerHeight, setContainerHeight] = useState(0);
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const el = containerRef.current;
|
|
103
|
+
if (!el)
|
|
104
|
+
return;
|
|
105
|
+
setContainerHeight(el.clientHeight);
|
|
106
|
+
const onScroll = () => setScrollTop(el.scrollTop);
|
|
107
|
+
const observer = new ResizeObserver(() => setContainerHeight(el.clientHeight));
|
|
108
|
+
el.addEventListener('scroll', onScroll, { passive: true });
|
|
109
|
+
observer.observe(el);
|
|
110
|
+
return () => { el.removeEventListener('scroll', onScroll); observer.disconnect(); };
|
|
111
|
+
}, []);
|
|
112
|
+
const virtualItems = useMemo(() => {
|
|
113
|
+
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
|
114
|
+
const end = Math.min(items.length - 1, Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan);
|
|
115
|
+
const result = [];
|
|
116
|
+
for (let i = start; i <= end; i++) {
|
|
117
|
+
result.push({ item: items[i], index: i, offsetTop: i * itemHeight });
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}, [items, scrollTop, containerHeight, itemHeight, overscan]);
|
|
121
|
+
return { containerRef, virtualItems, totalHeight: items.length * itemHeight };
|
|
122
|
+
}
|
|
123
|
+
const BREAKPOINTS = {
|
|
124
|
+
xs: 0, sm: 640, md: 768, lg: 1024, xl: 1280, '2xl': 1536,
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Returns the current named breakpoint based on window width.
|
|
128
|
+
* Uses Tailwind-compatible breakpoints by default.
|
|
129
|
+
*/
|
|
130
|
+
export function useBreakpoint() {
|
|
131
|
+
const getBreakpoint = () => {
|
|
132
|
+
var _a;
|
|
133
|
+
const w = typeof window !== 'undefined' ? window.innerWidth : 0;
|
|
134
|
+
const keys = Object.keys(BREAKPOINTS).reverse();
|
|
135
|
+
return (_a = keys.find(k => w >= BREAKPOINTS[k])) !== null && _a !== void 0 ? _a : 'xs';
|
|
136
|
+
};
|
|
137
|
+
const [breakpoint, setBreakpoint] = useState(getBreakpoint);
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const update = () => setBreakpoint(getBreakpoint());
|
|
140
|
+
window.addEventListener('resize', update, { passive: true });
|
|
141
|
+
return () => window.removeEventListener('resize', update);
|
|
142
|
+
}, []);
|
|
143
|
+
return breakpoint;
|
|
144
|
+
}
|
|
145
|
+
const THEME_KEY = 'rht:theme';
|
|
146
|
+
/**
|
|
147
|
+
* Manages a light/dark/system theme with persistence to localStorage.
|
|
148
|
+
* Applies a `data-theme` attribute to `<html>` and resolves the system preference.
|
|
149
|
+
*/
|
|
150
|
+
export function useTheme() {
|
|
151
|
+
const [theme, setThemeState] = useState(() => { var _a; return (_a = (typeof localStorage !== 'undefined' ? localStorage.getItem(THEME_KEY) : null)) !== null && _a !== void 0 ? _a : 'system'; });
|
|
152
|
+
const getResolved = (t) => {
|
|
153
|
+
if (t !== 'system')
|
|
154
|
+
return t;
|
|
155
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
156
|
+
};
|
|
157
|
+
const [resolvedTheme, setResolvedTheme] = useState(() => getResolved(theme));
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
const resolved = getResolved(theme);
|
|
160
|
+
setResolvedTheme(resolved);
|
|
161
|
+
document.documentElement.setAttribute('data-theme', resolved);
|
|
162
|
+
document.documentElement.classList.toggle('dark', resolved === 'dark');
|
|
163
|
+
localStorage.setItem(THEME_KEY, theme);
|
|
164
|
+
}, [theme]);
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (theme !== 'system')
|
|
167
|
+
return;
|
|
168
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
169
|
+
const update = () => setResolvedTheme(mq.matches ? 'dark' : 'light');
|
|
170
|
+
mq.addEventListener('change', update);
|
|
171
|
+
return () => mq.removeEventListener('change', update);
|
|
172
|
+
}, [theme]);
|
|
173
|
+
const setTheme = useCallback((t) => setThemeState(t), []);
|
|
174
|
+
const toggleTheme = useCallback(() => setThemeState(prev => prev === 'dark' ? 'light' : 'dark'), []);
|
|
175
|
+
return { theme, resolvedTheme, setTheme, toggleTheme };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Material-style ripple effect. Spread the returned props onto a button/div.
|
|
179
|
+
* Render each ripple as an absolutely-positioned circular element using the
|
|
180
|
+
* provided x/y/size values.
|
|
181
|
+
*/
|
|
182
|
+
export function useRipple(duration = 600) {
|
|
183
|
+
const [ripples, setRipples] = useState([]);
|
|
184
|
+
const idRef = useRef(0);
|
|
185
|
+
const onMouseDown = useCallback((e) => {
|
|
186
|
+
const el = e.currentTarget;
|
|
187
|
+
const rect = el.getBoundingClientRect();
|
|
188
|
+
const size = Math.max(rect.width, rect.height) * 2;
|
|
189
|
+
const x = e.clientX - rect.left - size / 2;
|
|
190
|
+
const y = e.clientY - rect.top - size / 2;
|
|
191
|
+
const id = ++idRef.current;
|
|
192
|
+
setRipples(prev => [...prev, { id, x, y, size }]);
|
|
193
|
+
setTimeout(() => setRipples(prev => prev.filter(r => r.id !== id)), duration);
|
|
194
|
+
}, [duration]);
|
|
195
|
+
return { ripples, onMouseDown };
|
|
196
|
+
}
|
|
197
|
+
let toastId = 0;
|
|
198
|
+
/**
|
|
199
|
+
* Manages a list of toast notifications with automatic dismissal after a duration.
|
|
200
|
+
*/
|
|
201
|
+
export function useToast() {
|
|
202
|
+
const [toasts, setToasts] = useState([]);
|
|
203
|
+
const dismiss = useCallback((id) => setToasts(prev => prev.filter(t => t.id !== id)), []);
|
|
204
|
+
const show = useCallback((message, type = 'info', duration = 4000) => {
|
|
205
|
+
const id = ++toastId;
|
|
206
|
+
setToasts(prev => [...prev, { id, message, type, duration }]);
|
|
207
|
+
if (duration > 0)
|
|
208
|
+
setTimeout(() => dismiss(id), duration);
|
|
209
|
+
}, [dismiss]);
|
|
210
|
+
const clear = useCallback(() => setToasts([]), []);
|
|
211
|
+
return { toasts, show, dismiss, clear };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Manages modal open/close state. Automatically restores body scroll on close.
|
|
215
|
+
*/
|
|
216
|
+
export function useModal(initialOpen = false) {
|
|
217
|
+
const [isOpen, setIsOpen] = useState(initialOpen);
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (isOpen) {
|
|
220
|
+
const prev = document.body.style.overflow;
|
|
221
|
+
document.body.style.overflow = 'hidden';
|
|
222
|
+
return () => { document.body.style.overflow = prev; };
|
|
223
|
+
}
|
|
224
|
+
}, [isOpen]);
|
|
225
|
+
const open = useCallback(() => setIsOpen(true), []);
|
|
226
|
+
const close = useCallback(() => setIsOpen(false), []);
|
|
227
|
+
const toggle = useCallback(() => setIsOpen(v => !v), []);
|
|
228
|
+
return { isOpen, open, close, toggle };
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Captures right-click position to display a custom context menu at the cursor.
|
|
232
|
+
* Closes on outside click or Escape.
|
|
233
|
+
*/
|
|
234
|
+
export function useContextMenu() {
|
|
235
|
+
const [state, setState] = useState({ isVisible: false, x: 0, y: 0 });
|
|
236
|
+
const close = useCallback(() => setState(s => (Object.assign(Object.assign({}, s), { isVisible: false }))), []);
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (!state.isVisible)
|
|
239
|
+
return;
|
|
240
|
+
const onKey = (e) => { if (e.key === 'Escape')
|
|
241
|
+
close(); };
|
|
242
|
+
document.addEventListener('click', close);
|
|
243
|
+
document.addEventListener('keydown', onKey);
|
|
244
|
+
return () => { document.removeEventListener('click', close); document.removeEventListener('keydown', onKey); };
|
|
245
|
+
}, [state.isVisible, close]);
|
|
246
|
+
const targetProps = {
|
|
247
|
+
onContextMenu: useCallback((e) => {
|
|
248
|
+
e.preventDefault();
|
|
249
|
+
setState({ isVisible: true, x: e.clientX, y: e.clientY });
|
|
250
|
+
}, []),
|
|
251
|
+
};
|
|
252
|
+
return { state, targetProps, close };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Controlled input hook. Spread `bind` onto any input/textarea/select element.
|
|
256
|
+
* Returns value, reset, and direct setter.
|
|
257
|
+
*/
|
|
258
|
+
export function useInput(initialValue) {
|
|
259
|
+
const [value, setValue] = useState(initialValue);
|
|
260
|
+
const onChange = useCallback((e) => {
|
|
261
|
+
const el = e.target;
|
|
262
|
+
const next = (el.type === 'checkbox' ? el.checked : el.value);
|
|
263
|
+
setValue(next);
|
|
264
|
+
}, []);
|
|
265
|
+
const reset = useCallback(() => setValue(initialValue), [initialValue]);
|
|
266
|
+
const set = useCallback((v) => setValue(v), []);
|
|
267
|
+
return { value, onChange, reset, set, bind: { value, onChange } };
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Standalone field validation hook. Pass an array of validator functions;
|
|
271
|
+
* returns the first error message or null.
|
|
272
|
+
*/
|
|
273
|
+
export function useValidation(validators) {
|
|
274
|
+
const [error, setError] = useState(null);
|
|
275
|
+
const validate = useCallback((value) => {
|
|
276
|
+
for (const fn of validators) {
|
|
277
|
+
const msg = fn(value);
|
|
278
|
+
if (msg) {
|
|
279
|
+
setError(msg);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
setError(null);
|
|
284
|
+
return true;
|
|
285
|
+
}, [validators]);
|
|
286
|
+
const reset = useCallback(() => setError(null), []);
|
|
287
|
+
return { error, validate, reset };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Controlled checkbox state with a toggle function and spread-ready bind props.
|
|
291
|
+
*/
|
|
292
|
+
export function useCheckbox(initialChecked = false) {
|
|
293
|
+
const [checked, setChecked] = useState(initialChecked);
|
|
294
|
+
const toggle = useCallback(() => setChecked(v => !v), []);
|
|
295
|
+
return { checked, toggle, setChecked, bind: { checked, onChange: toggle } };
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Filters an array of items using a search query and a key extractor function.
|
|
299
|
+
* Returns the query state and the filtered results.
|
|
300
|
+
*/
|
|
301
|
+
export function useSearch(items, getSearchableText, debounceMs = 0) {
|
|
302
|
+
const [query, setQueryState] = useState('');
|
|
303
|
+
const [debouncedQuery, setDebouncedQuery] = useState('');
|
|
304
|
+
const timerRef = useRef();
|
|
305
|
+
const setQuery = useCallback((q) => {
|
|
306
|
+
setQueryState(q);
|
|
307
|
+
clearTimeout(timerRef.current);
|
|
308
|
+
if (debounceMs > 0) {
|
|
309
|
+
timerRef.current = setTimeout(() => setDebouncedQuery(q), debounceMs);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
setDebouncedQuery(q);
|
|
313
|
+
}
|
|
314
|
+
}, [debounceMs]);
|
|
315
|
+
useEffect(() => () => clearTimeout(timerRef.current), []);
|
|
316
|
+
const activeQuery = debounceMs > 0 ? debouncedQuery : query;
|
|
317
|
+
const results = useMemo(() => {
|
|
318
|
+
if (!activeQuery.trim())
|
|
319
|
+
return items;
|
|
320
|
+
const lower = activeQuery.toLowerCase();
|
|
321
|
+
return items.filter(item => getSearchableText(item).toLowerCase().includes(lower));
|
|
322
|
+
}, [items, activeQuery, getSearchableText]);
|
|
323
|
+
const clear = useCallback(() => { setQueryState(''); setDebouncedQuery(''); }, []);
|
|
324
|
+
return { query, setQuery, results, clear };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Filters an array with a predicate function. Returns the filtered list,
|
|
328
|
+
* a setter for the predicate, and a clear function.
|
|
329
|
+
*/
|
|
330
|
+
export function useFilter(items) {
|
|
331
|
+
const [filter, setFilterFn] = useState(null);
|
|
332
|
+
const filtered = useMemo(() => filter ? items.filter(filter) : items, [items, filter]);
|
|
333
|
+
const setFilter = useCallback((fn) => {
|
|
334
|
+
setFilterFn(() => fn);
|
|
335
|
+
}, []);
|
|
336
|
+
const clearFilter = useCallback(() => setFilterFn(null), []);
|
|
337
|
+
return { filtered, setFilter, clearFilter, isFiltered: filter !== null };
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Sorts an array by a key, toggling direction on repeated calls with the same key.
|
|
341
|
+
*/
|
|
342
|
+
export function useSort(items) {
|
|
343
|
+
const [sortKey, setSortKey] = useState(null);
|
|
344
|
+
const [direction, setDirection] = useState('asc');
|
|
345
|
+
const setSort = useCallback((key) => {
|
|
346
|
+
setDirection(prev => sortKey === key && prev === 'asc' ? 'desc' : 'asc');
|
|
347
|
+
setSortKey(key);
|
|
348
|
+
}, [sortKey]);
|
|
349
|
+
const clearSort = useCallback(() => { setSortKey(null); setDirection('asc'); }, []);
|
|
350
|
+
const sorted = useMemo(() => {
|
|
351
|
+
if (!sortKey)
|
|
352
|
+
return items;
|
|
353
|
+
return [...items].sort((a, b) => {
|
|
354
|
+
const av = a[sortKey];
|
|
355
|
+
const bv = b[sortKey];
|
|
356
|
+
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
|
|
357
|
+
return direction === 'asc' ? cmp : -cmp;
|
|
358
|
+
});
|
|
359
|
+
}, [items, sortKey, direction]);
|
|
360
|
+
return { sorted, sortKey, direction, setSort, clearSort };
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Autocomplete suggestions list with keyboard navigation (Up/Down/Enter/Escape).
|
|
364
|
+
*/
|
|
365
|
+
export function useAutocomplete(getSuggestions, onSelect, getLabel) {
|
|
366
|
+
const [query, setQuery] = useState('');
|
|
367
|
+
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
368
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
369
|
+
const suggestions = useMemo(() => query.trim() ? getSuggestions(query) : [], [query, getSuggestions]);
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
setIsOpen(suggestions.length > 0);
|
|
372
|
+
setSelectedIndex(-1);
|
|
373
|
+
}, [suggestions.length]);
|
|
374
|
+
const select = useCallback((item) => {
|
|
375
|
+
setQuery(getLabel ? getLabel(item) : String(item));
|
|
376
|
+
setIsOpen(false);
|
|
377
|
+
onSelect === null || onSelect === void 0 ? void 0 : onSelect(item);
|
|
378
|
+
}, [onSelect, getLabel]);
|
|
379
|
+
const clear = useCallback(() => { setQuery(''); setIsOpen(false); }, []);
|
|
380
|
+
const inputProps = {
|
|
381
|
+
value: query,
|
|
382
|
+
onChange: useCallback((e) => {
|
|
383
|
+
setQuery(e.target.value);
|
|
384
|
+
}, []),
|
|
385
|
+
onKeyDown: useCallback((e) => {
|
|
386
|
+
if (!isOpen)
|
|
387
|
+
return;
|
|
388
|
+
if (e.key === 'ArrowDown') {
|
|
389
|
+
e.preventDefault();
|
|
390
|
+
setSelectedIndex(i => Math.min(i + 1, suggestions.length - 1));
|
|
391
|
+
}
|
|
392
|
+
else if (e.key === 'ArrowUp') {
|
|
393
|
+
e.preventDefault();
|
|
394
|
+
setSelectedIndex(i => Math.max(i - 1, -1));
|
|
395
|
+
}
|
|
396
|
+
else if (e.key === 'Enter' && selectedIndex >= 0) {
|
|
397
|
+
e.preventDefault();
|
|
398
|
+
select(suggestions[selectedIndex]);
|
|
399
|
+
}
|
|
400
|
+
else if (e.key === 'Escape') {
|
|
401
|
+
setIsOpen(false);
|
|
402
|
+
}
|
|
403
|
+
}, [isOpen, selectedIndex, suggestions, select]),
|
|
404
|
+
};
|
|
405
|
+
return { query, suggestions, selectedIndex, isOpen, inputProps, select, clear };
|
|
406
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
type QueryParamValue = string | null;
|
|
2
|
+
interface UseQueryParamsReturn {
|
|
3
|
+
params: URLSearchParams;
|
|
4
|
+
get: (key: string) => QueryParamValue;
|
|
5
|
+
set: (key: string, value: string) => void;
|
|
6
|
+
delete: (key: string) => void;
|
|
7
|
+
clear: () => void;
|
|
8
|
+
toString: () => string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Reads and writes URL search parameters reactively.
|
|
12
|
+
* Updates the browser URL (without navigation) when params change.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useQueryParams(): UseQueryParamsReturn;
|
|
15
|
+
/**
|
|
16
|
+
* Extracts named dynamic route parameters from the current URL path given a
|
|
17
|
+
* pattern like "/users/:id/posts/:postId".
|
|
18
|
+
* Returns null for each key when the pattern does not match the current path.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useParams<T extends Record<string, string>>(pattern: string): Partial<T>;
|
|
21
|
+
type NotificationPermission = 'default' | 'denied' | 'granted';
|
|
22
|
+
interface UseNotificationReturn {
|
|
23
|
+
permission: NotificationPermission;
|
|
24
|
+
isSupported: boolean;
|
|
25
|
+
requestPermission: () => Promise<void>;
|
|
26
|
+
show: (title: string, options?: NotificationOptions) => Notification | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Web Notifications API wrapper. Requests permission and shows notifications.
|
|
30
|
+
*/
|
|
31
|
+
export declare function useNotification(): UseNotificationReturn;
|
|
32
|
+
interface UseVibrationReturn {
|
|
33
|
+
isSupported: boolean;
|
|
34
|
+
vibrate: (pattern: number | number[]) => void;
|
|
35
|
+
stop: () => void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Wraps the Vibration API. Triggers device vibration on supported mobile devices.
|
|
39
|
+
* Pattern can be a single duration (ms) or an alternating [vibrate, pause] array.
|
|
40
|
+
*/
|
|
41
|
+
export declare function useVibration(): UseVibrationReturn;
|
|
42
|
+
interface UseWakeLockReturn {
|
|
43
|
+
isSupported: boolean;
|
|
44
|
+
isActive: boolean;
|
|
45
|
+
request: () => Promise<void>;
|
|
46
|
+
release: () => Promise<void>;
|
|
47
|
+
error: Error | null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Screen Wake Lock API — prevents the device screen from sleeping while active.
|
|
51
|
+
* Automatically re-acquires the lock when the page becomes visible again.
|
|
52
|
+
*/
|
|
53
|
+
export declare function useWakeLock(): UseWakeLockReturn;
|
|
54
|
+
interface UseShareSheetReturn {
|
|
55
|
+
isSupported: boolean;
|
|
56
|
+
share: (data: ShareData) => Promise<void>;
|
|
57
|
+
error: Error | null;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Web Share API wrapper. Triggers the native OS share sheet.
|
|
61
|
+
* Falls back gracefully when the API is not available.
|
|
62
|
+
*/
|
|
63
|
+
export declare function useShareSheet(): UseShareSheetReturn;
|
|
64
|
+
type EventHandler = (...args: any[]) => void;
|
|
65
|
+
interface UseEventEmitterReturn {
|
|
66
|
+
on: (event: string, handler: EventHandler) => void;
|
|
67
|
+
off: (event: string, handler: EventHandler) => void;
|
|
68
|
+
emit: (event: string, ...args: any[]) => void;
|
|
69
|
+
once: (event: string, handler: EventHandler) => void;
|
|
70
|
+
clear: (event?: string) => void;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* In-component pub/sub event emitter. Useful for decoupled sibling communication
|
|
74
|
+
* without lifting state or using a global store.
|
|
75
|
+
*/
|
|
76
|
+
export declare function useEventEmitter(): UseEventEmitterReturn;
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Reads and writes URL search parameters reactively.
|
|
4
|
+
* Updates the browser URL (without navigation) when params change.
|
|
5
|
+
*/
|
|
6
|
+
export function useQueryParams() {
|
|
7
|
+
const [params, setParams] = useState(() => new URLSearchParams(window.location.search));
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const onPop = () => setParams(new URLSearchParams(window.location.search));
|
|
10
|
+
window.addEventListener('popstate', onPop);
|
|
11
|
+
return () => window.removeEventListener('popstate', onPop);
|
|
12
|
+
}, []);
|
|
13
|
+
const update = useCallback((next) => {
|
|
14
|
+
const url = `${window.location.pathname}?${next.toString()}${window.location.hash}`;
|
|
15
|
+
window.history.pushState({}, '', url);
|
|
16
|
+
setParams(next);
|
|
17
|
+
}, []);
|
|
18
|
+
const set = useCallback((key, value) => {
|
|
19
|
+
const next = new URLSearchParams(params.toString());
|
|
20
|
+
next.set(key, value);
|
|
21
|
+
update(next);
|
|
22
|
+
}, [params, update]);
|
|
23
|
+
const del = useCallback((key) => {
|
|
24
|
+
const next = new URLSearchParams(params.toString());
|
|
25
|
+
next.delete(key);
|
|
26
|
+
update(next);
|
|
27
|
+
}, [params, update]);
|
|
28
|
+
const clear = useCallback(() => update(new URLSearchParams()), [update]);
|
|
29
|
+
return {
|
|
30
|
+
params,
|
|
31
|
+
get: (key) => params.get(key),
|
|
32
|
+
set,
|
|
33
|
+
delete: del,
|
|
34
|
+
clear,
|
|
35
|
+
toString: () => params.toString(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// ─── useParams ────────────────────────────────────────────────────────────────
|
|
39
|
+
/**
|
|
40
|
+
* Extracts named dynamic route parameters from the current URL path given a
|
|
41
|
+
* pattern like "/users/:id/posts/:postId".
|
|
42
|
+
* Returns null for each key when the pattern does not match the current path.
|
|
43
|
+
*/
|
|
44
|
+
export function useParams(pattern) {
|
|
45
|
+
const extract = (path) => {
|
|
46
|
+
const keys = [];
|
|
47
|
+
const regexStr = pattern
|
|
48
|
+
.replace(/:([^/]+)/g, (_, key) => { keys.push(key); return '([^/]+)'; })
|
|
49
|
+
.replace(/\//g, '\\/');
|
|
50
|
+
const match = path.match(new RegExp(`^${regexStr}$`));
|
|
51
|
+
if (!match)
|
|
52
|
+
return {};
|
|
53
|
+
return keys.reduce((acc, key, i) => (Object.assign(Object.assign({}, acc), { [key]: match[i + 1] })), {});
|
|
54
|
+
};
|
|
55
|
+
const [params, setParams] = useState(() => extract(window.location.pathname));
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const update = () => setParams(extract(window.location.pathname));
|
|
58
|
+
window.addEventListener('popstate', update);
|
|
59
|
+
return () => window.removeEventListener('popstate', update);
|
|
60
|
+
}, [pattern]);
|
|
61
|
+
return params;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Web Notifications API wrapper. Requests permission and shows notifications.
|
|
65
|
+
*/
|
|
66
|
+
export function useNotification() {
|
|
67
|
+
const isSupported = typeof window !== 'undefined' && 'Notification' in window;
|
|
68
|
+
const [permission, setPermission] = useState(isSupported ? Notification.permission : 'denied');
|
|
69
|
+
const requestPermission = useCallback(async () => {
|
|
70
|
+
if (!isSupported)
|
|
71
|
+
return;
|
|
72
|
+
const result = await Notification.requestPermission();
|
|
73
|
+
setPermission(result);
|
|
74
|
+
}, [isSupported]);
|
|
75
|
+
const show = useCallback((title, options) => {
|
|
76
|
+
if (!isSupported || permission !== 'granted')
|
|
77
|
+
return null;
|
|
78
|
+
return new Notification(title, options);
|
|
79
|
+
}, [isSupported, permission]);
|
|
80
|
+
return { permission, isSupported, requestPermission, show };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Wraps the Vibration API. Triggers device vibration on supported mobile devices.
|
|
84
|
+
* Pattern can be a single duration (ms) or an alternating [vibrate, pause] array.
|
|
85
|
+
*/
|
|
86
|
+
export function useVibration() {
|
|
87
|
+
const isSupported = typeof navigator !== 'undefined' && 'vibrate' in navigator;
|
|
88
|
+
const vibrate = useCallback((pattern) => {
|
|
89
|
+
if (isSupported)
|
|
90
|
+
navigator.vibrate(pattern);
|
|
91
|
+
}, [isSupported]);
|
|
92
|
+
const stop = useCallback(() => {
|
|
93
|
+
if (isSupported)
|
|
94
|
+
navigator.vibrate(0);
|
|
95
|
+
}, [isSupported]);
|
|
96
|
+
return { isSupported, vibrate, stop };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Screen Wake Lock API — prevents the device screen from sleeping while active.
|
|
100
|
+
* Automatically re-acquires the lock when the page becomes visible again.
|
|
101
|
+
*/
|
|
102
|
+
export function useWakeLock() {
|
|
103
|
+
const isSupported = typeof navigator !== 'undefined' && 'wakeLock' in navigator;
|
|
104
|
+
const [isActive, setIsActive] = useState(false);
|
|
105
|
+
const [error, setError] = useState(null);
|
|
106
|
+
const lockRef = useRef(null);
|
|
107
|
+
const request = useCallback(async () => {
|
|
108
|
+
var _a;
|
|
109
|
+
if (!isSupported)
|
|
110
|
+
return;
|
|
111
|
+
try {
|
|
112
|
+
lockRef.current = await navigator.wakeLock.request('screen');
|
|
113
|
+
setIsActive(true);
|
|
114
|
+
setError(null);
|
|
115
|
+
(_a = lockRef.current) === null || _a === void 0 ? void 0 : _a.addEventListener('release', () => setIsActive(false));
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
setError(err instanceof Error ? err : new Error('WakeLock failed'));
|
|
119
|
+
}
|
|
120
|
+
}, [isSupported]);
|
|
121
|
+
const release = useCallback(async () => {
|
|
122
|
+
var _a;
|
|
123
|
+
await ((_a = lockRef.current) === null || _a === void 0 ? void 0 : _a.release());
|
|
124
|
+
lockRef.current = null;
|
|
125
|
+
setIsActive(false);
|
|
126
|
+
}, []);
|
|
127
|
+
// Re-acquire after page regains visibility
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!isSupported)
|
|
130
|
+
return;
|
|
131
|
+
const onVisible = () => { if (document.visibilityState === 'visible' && isActive)
|
|
132
|
+
request(); };
|
|
133
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
134
|
+
return () => document.removeEventListener('visibilitychange', onVisible);
|
|
135
|
+
}, [isSupported, isActive, request]);
|
|
136
|
+
useEffect(() => () => { var _a; (_a = lockRef.current) === null || _a === void 0 ? void 0 : _a.release().catch(() => { }); }, []);
|
|
137
|
+
return { isSupported, isActive, request, release, error };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Web Share API wrapper. Triggers the native OS share sheet.
|
|
141
|
+
* Falls back gracefully when the API is not available.
|
|
142
|
+
*/
|
|
143
|
+
export function useShareSheet() {
|
|
144
|
+
const isSupported = typeof navigator !== 'undefined' && 'share' in navigator;
|
|
145
|
+
const [error, setError] = useState(null);
|
|
146
|
+
const share = useCallback(async (data) => {
|
|
147
|
+
if (!isSupported) {
|
|
148
|
+
setError(new Error('Web Share API not supported'));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
setError(null);
|
|
153
|
+
await navigator.share(data);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
if (err.name !== 'AbortError') {
|
|
157
|
+
setError(err instanceof Error ? err : new Error('Share failed'));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}, [isSupported]);
|
|
161
|
+
return { isSupported, share, error };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* In-component pub/sub event emitter. Useful for decoupled sibling communication
|
|
165
|
+
* without lifting state or using a global store.
|
|
166
|
+
*/
|
|
167
|
+
export function useEventEmitter() {
|
|
168
|
+
const listenersRef = useRef(new Map());
|
|
169
|
+
const on = useCallback((event, handler) => {
|
|
170
|
+
if (!listenersRef.current.has(event))
|
|
171
|
+
listenersRef.current.set(event, new Set());
|
|
172
|
+
listenersRef.current.get(event).add(handler);
|
|
173
|
+
}, []);
|
|
174
|
+
const off = useCallback((event, handler) => {
|
|
175
|
+
var _a;
|
|
176
|
+
(_a = listenersRef.current.get(event)) === null || _a === void 0 ? void 0 : _a.delete(handler);
|
|
177
|
+
}, []);
|
|
178
|
+
const emit = useCallback((event, ...args) => {
|
|
179
|
+
var _a;
|
|
180
|
+
(_a = listenersRef.current.get(event)) === null || _a === void 0 ? void 0 : _a.forEach(fn => fn(...args));
|
|
181
|
+
}, []);
|
|
182
|
+
const once = useCallback((event, handler) => {
|
|
183
|
+
const wrapper = (...args) => { handler(...args); off(event, wrapper); };
|
|
184
|
+
on(event, wrapper);
|
|
185
|
+
}, [on, off]);
|
|
186
|
+
const clear = useCallback((event) => {
|
|
187
|
+
if (event)
|
|
188
|
+
listenersRef.current.delete(event);
|
|
189
|
+
else
|
|
190
|
+
listenersRef.current.clear();
|
|
191
|
+
}, []);
|
|
192
|
+
return { on, off, emit, once, clear };
|
|
193
|
+
}
|