@pyreon/hooks 0.3.0 → 0.11.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/lib/index.d.ts +170 -1
- package/lib/index.js +354 -2
- package/package.json +14 -12
package/lib/index.d.ts
CHANGED
|
@@ -13,6 +13,33 @@ declare function useBreakpoint(breakpoints?: BreakpointMap): () => string;
|
|
|
13
13
|
*/
|
|
14
14
|
declare function useClickOutside(getEl: () => HTMLElement | null, handler: () => void): void;
|
|
15
15
|
//#endregion
|
|
16
|
+
//#region src/useClipboard.d.ts
|
|
17
|
+
interface UseClipboardResult {
|
|
18
|
+
/** Copy text to clipboard. Returns true on success. */
|
|
19
|
+
copy: (text: string) => Promise<boolean>;
|
|
20
|
+
/** Whether the last copy succeeded (resets after timeout). */
|
|
21
|
+
copied: () => boolean;
|
|
22
|
+
/** The last successfully copied text. */
|
|
23
|
+
text: () => string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Reactive clipboard access — copy text and track copied state.
|
|
27
|
+
*
|
|
28
|
+
* @param options.timeout - ms before `copied` resets to false (default: 2000)
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* const { copy, copied } = useClipboard()
|
|
33
|
+
*
|
|
34
|
+
* <button onClick={() => copy("hello")}>
|
|
35
|
+
* {() => copied() ? "Copied!" : "Copy"}
|
|
36
|
+
* </button>
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
declare function useClipboard(options?: {
|
|
40
|
+
timeout?: number;
|
|
41
|
+
}): UseClipboardResult;
|
|
42
|
+
//#endregion
|
|
16
43
|
//#region src/useColorScheme.d.ts
|
|
17
44
|
/**
|
|
18
45
|
* Returns the OS color scheme preference as 'light' or 'dark'.
|
|
@@ -57,6 +84,39 @@ declare const useDebouncedCallback: UseDebouncedCallback;
|
|
|
57
84
|
*/
|
|
58
85
|
declare function useDebouncedValue<T>(getter: () => T, delayMs: number): () => T;
|
|
59
86
|
//#endregion
|
|
87
|
+
//#region src/useDialog.d.ts
|
|
88
|
+
interface UseDialogResult {
|
|
89
|
+
/** Whether the dialog is currently open. */
|
|
90
|
+
open: () => boolean;
|
|
91
|
+
/** Open the dialog. */
|
|
92
|
+
show: () => void;
|
|
93
|
+
/** Open as modal (with backdrop, traps focus). */
|
|
94
|
+
showModal: () => void;
|
|
95
|
+
/** Close the dialog. */
|
|
96
|
+
close: () => void;
|
|
97
|
+
/** Toggle open/closed state. */
|
|
98
|
+
toggle: () => void;
|
|
99
|
+
/** Ref callback — pass to `ref` prop on a `<dialog>` element. */
|
|
100
|
+
ref: (el: HTMLDialogElement) => void;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Signal-driven dialog management for the native `<dialog>` element.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```tsx
|
|
107
|
+
* const dialog = useDialog()
|
|
108
|
+
*
|
|
109
|
+
* <button onClick={dialog.showModal}>Open</button>
|
|
110
|
+
* <dialog ref={dialog.ref}>
|
|
111
|
+
* <p>Modal content</p>
|
|
112
|
+
* <button onClick={dialog.close}>Close</button>
|
|
113
|
+
* </dialog>
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
declare function useDialog(options?: {
|
|
117
|
+
onClose?: () => void;
|
|
118
|
+
}): UseDialogResult;
|
|
119
|
+
//#endregion
|
|
60
120
|
//#region src/useElementSize.d.ts
|
|
61
121
|
interface Size {
|
|
62
122
|
width: number;
|
|
@@ -67,6 +127,25 @@ interface Size {
|
|
|
67
127
|
*/
|
|
68
128
|
declare function useElementSize(getEl: () => HTMLElement | null): () => Size;
|
|
69
129
|
//#endregion
|
|
130
|
+
//#region src/useEventListener.d.ts
|
|
131
|
+
/**
|
|
132
|
+
* Attach an event listener with automatic cleanup on unmount.
|
|
133
|
+
* Works with Window, Document, HTMLElement, or any EventTarget.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* ```tsx
|
|
137
|
+
* useEventListener("keydown", (e) => {
|
|
138
|
+
* if (e.key === "Escape") close()
|
|
139
|
+
* })
|
|
140
|
+
*
|
|
141
|
+
* useEventListener("scroll", handleScroll, { passive: true })
|
|
142
|
+
*
|
|
143
|
+
* // On a specific element:
|
|
144
|
+
* useEventListener("click", handler, {}, () => buttonRef.current)
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
declare function useEventListener<K extends keyof WindowEventMap>(event: K, handler: (e: WindowEventMap[K]) => void, options?: boolean | AddEventListenerOptions, target?: () => EventTarget | null | undefined): void;
|
|
148
|
+
//#endregion
|
|
70
149
|
//#region src/useFocus.d.ts
|
|
71
150
|
interface UseFocusResult {
|
|
72
151
|
focused: () => boolean;
|
|
@@ -105,6 +184,50 @@ interface UseHoverResult {
|
|
|
105
184
|
*/
|
|
106
185
|
declare function useHover(): UseHoverResult;
|
|
107
186
|
//#endregion
|
|
187
|
+
//#region src/useInfiniteScroll.d.ts
|
|
188
|
+
interface UseInfiniteScrollOptions {
|
|
189
|
+
/** Distance from bottom (px) to trigger load. Default: 100 */
|
|
190
|
+
threshold?: number;
|
|
191
|
+
/** Whether loading is in progress (prevents duplicate calls). */
|
|
192
|
+
loading?: () => boolean;
|
|
193
|
+
/** Whether there's more data to load. Default: true */
|
|
194
|
+
hasMore?: () => boolean;
|
|
195
|
+
/** Scroll direction. Default: "down" */
|
|
196
|
+
direction?: "up" | "down";
|
|
197
|
+
}
|
|
198
|
+
interface UseInfiniteScrollResult {
|
|
199
|
+
/** Attach to the scroll container element. */
|
|
200
|
+
ref: (el: HTMLElement | null) => void;
|
|
201
|
+
/** Whether the sentinel is currently visible. */
|
|
202
|
+
triggered: () => boolean;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Signal-driven infinite scroll using IntersectionObserver.
|
|
206
|
+
* Calls `onLoadMore` when the user scrolls near the end of the container.
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```tsx
|
|
210
|
+
* const items = signal<Item[]>([])
|
|
211
|
+
* const loading = signal(false)
|
|
212
|
+
* const hasMore = signal(true)
|
|
213
|
+
*
|
|
214
|
+
* const { ref } = useInfiniteScroll(() => {
|
|
215
|
+
* loading.set(true)
|
|
216
|
+
* const next = await fetchMore()
|
|
217
|
+
* items.update(prev => [...prev, ...next])
|
|
218
|
+
* hasMore.set(next.length > 0)
|
|
219
|
+
* loading.set(false)
|
|
220
|
+
* }, { loading, hasMore })
|
|
221
|
+
*
|
|
222
|
+
* <div ref={ref} style={{ overflowY: "auto", height: "400px" }}>
|
|
223
|
+
* <For each={items()} by={i => i.id}>
|
|
224
|
+
* {item => <div>{item.name}</div>}
|
|
225
|
+
* </For>
|
|
226
|
+
* </div>
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
declare function useInfiniteScroll(onLoadMore: () => void | Promise<void>, options?: UseInfiniteScrollOptions): UseInfiniteScrollResult;
|
|
230
|
+
//#endregion
|
|
108
231
|
//#region src/useIntersection.d.ts
|
|
109
232
|
/**
|
|
110
233
|
* Observe element intersection reactively.
|
|
@@ -178,6 +301,21 @@ type UseMergedRef = <T>(...refs: (Ref<T> | undefined)[]) => (node: T | null) =>
|
|
|
178
301
|
*/
|
|
179
302
|
declare const useMergedRef: <T>(...refs: (Ref<T> | undefined)[]) => ((node: T | null) => void);
|
|
180
303
|
//#endregion
|
|
304
|
+
//#region src/useOnline.d.ts
|
|
305
|
+
/**
|
|
306
|
+
* Reactive online/offline status.
|
|
307
|
+
* Tracks `navigator.onLine` and updates on connectivity changes.
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* ```tsx
|
|
311
|
+
* const online = useOnline()
|
|
312
|
+
* <Show when={!online()} fallback={<App />}>
|
|
313
|
+
* <OfflineBanner />
|
|
314
|
+
* </Show>
|
|
315
|
+
* ```
|
|
316
|
+
*/
|
|
317
|
+
declare function useOnline(): () => boolean;
|
|
318
|
+
//#endregion
|
|
181
319
|
//#region src/usePrevious.d.ts
|
|
182
320
|
/**
|
|
183
321
|
* Track the previous value of a reactive getter.
|
|
@@ -261,6 +399,37 @@ type UseThrottledCallback = <T extends (...args: any[]) => any>(callback: T, del
|
|
|
261
399
|
*/
|
|
262
400
|
declare const useThrottledCallback: UseThrottledCallback;
|
|
263
401
|
//#endregion
|
|
402
|
+
//#region src/useTimeAgo.d.ts
|
|
403
|
+
type TimeUnit = "second" | "minute" | "hour" | "day" | "week" | "month" | "year";
|
|
404
|
+
interface UseTimeAgoOptions {
|
|
405
|
+
/** Custom formatter. Receives the value, unit, and whether it's in the past. */
|
|
406
|
+
formatter?: (value: number, unit: TimeUnit, isPast: boolean) => string;
|
|
407
|
+
/** Update interval override in ms. If not set, adapts based on age. */
|
|
408
|
+
interval?: number;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Reactive relative time that auto-updates.
|
|
412
|
+
* Returns a signal that displays "2 minutes ago", "just now", etc.
|
|
413
|
+
*
|
|
414
|
+
* @param date - Date object, timestamp, or reactive getter
|
|
415
|
+
*
|
|
416
|
+
* @example
|
|
417
|
+
* ```tsx
|
|
418
|
+
* const timeAgo = useTimeAgo(post.createdAt)
|
|
419
|
+
* <span>{timeAgo}</span>
|
|
420
|
+
* // Renders: "5 minutes ago" → "6 minutes ago" (auto-updates)
|
|
421
|
+
*
|
|
422
|
+
* // With reactive date:
|
|
423
|
+
* const timeAgo = useTimeAgo(() => selectedPost().createdAt)
|
|
424
|
+
*
|
|
425
|
+
* // With custom formatter (e.g. for i18n):
|
|
426
|
+
* const timeAgo = useTimeAgo(date, {
|
|
427
|
+
* formatter: (value, unit, isPast) => t('time.' + unit, { count: value })
|
|
428
|
+
* })
|
|
429
|
+
* ```
|
|
430
|
+
*/
|
|
431
|
+
declare function useTimeAgo(date: Date | number | (() => Date | number), options?: UseTimeAgoOptions): () => string;
|
|
432
|
+
//#endregion
|
|
264
433
|
//#region src/useTimeout.d.ts
|
|
265
434
|
type UseTimeout = (callback: () => void, delay: number | null) => {
|
|
266
435
|
reset: () => void;
|
|
@@ -308,5 +477,5 @@ interface WindowSize {
|
|
|
308
477
|
*/
|
|
309
478
|
declare function useWindowResize(throttleMs?: number): () => WindowSize;
|
|
310
479
|
//#endregion
|
|
311
|
-
export { type BreakpointMap, type Size, type UseControllableState, type UseDebouncedCallback, type UseFocusResult, type UseHoverResult, type UseInterval, type UseIsomorphicLayoutEffect, type UseLatest, type UseMergedRef, type UseRootSize, type UseSpacing, type UseThemeValue, type UseThrottledCallback, type UseTimeout, type UseToggleResult, type UseUpdateEffect, type WindowSize, 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 };
|
|
480
|
+
export { type BreakpointMap, type Size, type UseClipboardResult, type UseControllableState, type UseDebouncedCallback, type UseDialogResult, type UseFocusResult, type UseHoverResult, type UseInfiniteScrollOptions, type UseInfiniteScrollResult, type UseInterval, type UseIsomorphicLayoutEffect, type UseLatest, type UseMergedRef, type UseRootSize, type UseSpacing, type UseThemeValue, type UseThrottledCallback, type UseTimeAgoOptions, type UseTimeout, type UseToggleResult, type UseUpdateEffect, type WindowSize, useBreakpoint, useClickOutside, useClipboard, useColorScheme, useControllableState, useDebouncedCallback, useDebouncedValue, useDialog, useElementSize, useEventListener, useFocus, useFocusTrap, useHover, useInfiniteScroll, useIntersection, useInterval, useIsomorphicLayoutEffect, useKeyboard, useLatest, useMediaQuery, useMergedRef, useOnline, usePrevious, useReducedMotion, useRootSize, useScrollLock, useSpacing, useThemeValue, useThrottledCallback, useTimeAgo, useTimeout, useToggle, useUpdateEffect, useWindowResize };
|
|
312
481
|
//# sourceMappingURL=index2.d.ts.map
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { onMount, onUnmount } from "@pyreon/core";
|
|
2
|
-
import { computed, effect, signal, watch } from "@pyreon/reactivity";
|
|
2
|
+
import { computed, effect, onCleanup, signal, watch } from "@pyreon/reactivity";
|
|
3
3
|
import { useTheme } from "@pyreon/styler";
|
|
4
4
|
import { get, throttle } from "@pyreon/ui-core";
|
|
5
5
|
|
|
@@ -64,6 +64,50 @@ function useClickOutside(getEl, handler) {
|
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/useClipboard.ts
|
|
69
|
+
/**
|
|
70
|
+
* Reactive clipboard access — copy text and track copied state.
|
|
71
|
+
*
|
|
72
|
+
* @param options.timeout - ms before `copied` resets to false (default: 2000)
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```tsx
|
|
76
|
+
* const { copy, copied } = useClipboard()
|
|
77
|
+
*
|
|
78
|
+
* <button onClick={() => copy("hello")}>
|
|
79
|
+
* {() => copied() ? "Copied!" : "Copy"}
|
|
80
|
+
* </button>
|
|
81
|
+
* ```
|
|
82
|
+
*/
|
|
83
|
+
function useClipboard(options) {
|
|
84
|
+
const timeout = options?.timeout ?? 2e3;
|
|
85
|
+
const copied = signal(false);
|
|
86
|
+
const text = signal("");
|
|
87
|
+
let timer;
|
|
88
|
+
const copy = async (value) => {
|
|
89
|
+
try {
|
|
90
|
+
await navigator.clipboard.writeText(value);
|
|
91
|
+
text.set(value);
|
|
92
|
+
copied.set(true);
|
|
93
|
+
if (timer) clearTimeout(timer);
|
|
94
|
+
timer = setTimeout(() => copied.set(false), timeout);
|
|
95
|
+
return true;
|
|
96
|
+
} catch {
|
|
97
|
+
copied.set(false);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
onCleanup(() => {
|
|
102
|
+
if (timer) clearTimeout(timer);
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
copy,
|
|
106
|
+
copied,
|
|
107
|
+
text
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
67
111
|
//#endregion
|
|
68
112
|
//#region src/useMediaQuery.ts
|
|
69
113
|
/**
|
|
@@ -184,6 +228,64 @@ function useDebouncedValue(getter, delayMs) {
|
|
|
184
228
|
return debounced;
|
|
185
229
|
}
|
|
186
230
|
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/useDialog.ts
|
|
233
|
+
/**
|
|
234
|
+
* Signal-driven dialog management for the native `<dialog>` element.
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* ```tsx
|
|
238
|
+
* const dialog = useDialog()
|
|
239
|
+
*
|
|
240
|
+
* <button onClick={dialog.showModal}>Open</button>
|
|
241
|
+
* <dialog ref={dialog.ref}>
|
|
242
|
+
* <p>Modal content</p>
|
|
243
|
+
* <button onClick={dialog.close}>Close</button>
|
|
244
|
+
* </dialog>
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
function useDialog(options) {
|
|
248
|
+
const open = signal(false);
|
|
249
|
+
let dialogEl = null;
|
|
250
|
+
let closeHandler = null;
|
|
251
|
+
const show = () => {
|
|
252
|
+
dialogEl?.show();
|
|
253
|
+
open.set(true);
|
|
254
|
+
};
|
|
255
|
+
const showModal = () => {
|
|
256
|
+
dialogEl?.showModal();
|
|
257
|
+
open.set(true);
|
|
258
|
+
};
|
|
259
|
+
const close = () => {
|
|
260
|
+
dialogEl?.close();
|
|
261
|
+
open.set(false);
|
|
262
|
+
};
|
|
263
|
+
const toggle = () => {
|
|
264
|
+
if (open()) close();
|
|
265
|
+
else showModal();
|
|
266
|
+
};
|
|
267
|
+
const ref = (el) => {
|
|
268
|
+
if (dialogEl && closeHandler) dialogEl.removeEventListener("close", closeHandler);
|
|
269
|
+
dialogEl = el;
|
|
270
|
+
closeHandler = () => {
|
|
271
|
+
open.set(false);
|
|
272
|
+
options?.onClose?.();
|
|
273
|
+
};
|
|
274
|
+
el.addEventListener("close", closeHandler);
|
|
275
|
+
};
|
|
276
|
+
onCleanup(() => {
|
|
277
|
+
if (dialogEl && closeHandler) dialogEl.removeEventListener("close", closeHandler);
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
open,
|
|
281
|
+
show,
|
|
282
|
+
showModal,
|
|
283
|
+
close,
|
|
284
|
+
toggle,
|
|
285
|
+
ref
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
187
289
|
//#endregion
|
|
188
290
|
//#region src/useElementSize.ts
|
|
189
291
|
/**
|
|
@@ -219,6 +321,31 @@ function useElementSize(getEl) {
|
|
|
219
321
|
return size;
|
|
220
322
|
}
|
|
221
323
|
|
|
324
|
+
//#endregion
|
|
325
|
+
//#region src/useEventListener.ts
|
|
326
|
+
/**
|
|
327
|
+
* Attach an event listener with automatic cleanup on unmount.
|
|
328
|
+
* Works with Window, Document, HTMLElement, or any EventTarget.
|
|
329
|
+
*
|
|
330
|
+
* @example
|
|
331
|
+
* ```tsx
|
|
332
|
+
* useEventListener("keydown", (e) => {
|
|
333
|
+
* if (e.key === "Escape") close()
|
|
334
|
+
* })
|
|
335
|
+
*
|
|
336
|
+
* useEventListener("scroll", handleScroll, { passive: true })
|
|
337
|
+
*
|
|
338
|
+
* // On a specific element:
|
|
339
|
+
* useEventListener("click", handler, {}, () => buttonRef.current)
|
|
340
|
+
* ```
|
|
341
|
+
*/
|
|
342
|
+
function useEventListener(event, handler, options, target) {
|
|
343
|
+
if (!(typeof window !== "undefined")) return;
|
|
344
|
+
const el = target?.() ?? window;
|
|
345
|
+
el.addEventListener(event, handler, options);
|
|
346
|
+
onCleanup(() => el.removeEventListener(event, handler, options));
|
|
347
|
+
}
|
|
348
|
+
|
|
222
349
|
//#endregion
|
|
223
350
|
//#region src/useFocus.ts
|
|
224
351
|
/**
|
|
@@ -288,6 +415,88 @@ function useHover() {
|
|
|
288
415
|
};
|
|
289
416
|
}
|
|
290
417
|
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region src/useInfiniteScroll.ts
|
|
420
|
+
/**
|
|
421
|
+
* Signal-driven infinite scroll using IntersectionObserver.
|
|
422
|
+
* Calls `onLoadMore` when the user scrolls near the end of the container.
|
|
423
|
+
*
|
|
424
|
+
* @example
|
|
425
|
+
* ```tsx
|
|
426
|
+
* const items = signal<Item[]>([])
|
|
427
|
+
* const loading = signal(false)
|
|
428
|
+
* const hasMore = signal(true)
|
|
429
|
+
*
|
|
430
|
+
* const { ref } = useInfiniteScroll(() => {
|
|
431
|
+
* loading.set(true)
|
|
432
|
+
* const next = await fetchMore()
|
|
433
|
+
* items.update(prev => [...prev, ...next])
|
|
434
|
+
* hasMore.set(next.length > 0)
|
|
435
|
+
* loading.set(false)
|
|
436
|
+
* }, { loading, hasMore })
|
|
437
|
+
*
|
|
438
|
+
* <div ref={ref} style={{ overflowY: "auto", height: "400px" }}>
|
|
439
|
+
* <For each={items()} by={i => i.id}>
|
|
440
|
+
* {item => <div>{item.name}</div>}
|
|
441
|
+
* </For>
|
|
442
|
+
* </div>
|
|
443
|
+
* ```
|
|
444
|
+
*/
|
|
445
|
+
function useInfiniteScroll(onLoadMore, options) {
|
|
446
|
+
const threshold = options?.threshold ?? 100;
|
|
447
|
+
const direction = options?.direction ?? "down";
|
|
448
|
+
const triggered = signal(false);
|
|
449
|
+
let observer = null;
|
|
450
|
+
let sentinel = null;
|
|
451
|
+
let containerEl = null;
|
|
452
|
+
const handleIntersect = (entries) => {
|
|
453
|
+
const entry = entries[0];
|
|
454
|
+
if (!entry) return;
|
|
455
|
+
triggered.set(entry.isIntersecting);
|
|
456
|
+
if (entry.isIntersecting) {
|
|
457
|
+
if (options?.loading?.()) return;
|
|
458
|
+
if (options?.hasMore && !options.hasMore()) return;
|
|
459
|
+
onLoadMore();
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
const setup = (el) => {
|
|
463
|
+
cleanup();
|
|
464
|
+
containerEl = el;
|
|
465
|
+
sentinel = document.createElement("div");
|
|
466
|
+
sentinel.style.height = "1px";
|
|
467
|
+
sentinel.style.width = "100%";
|
|
468
|
+
sentinel.style.pointerEvents = "none";
|
|
469
|
+
sentinel.setAttribute("aria-hidden", "true");
|
|
470
|
+
if (direction === "down") el.appendChild(sentinel);
|
|
471
|
+
else el.insertBefore(sentinel, el.firstChild);
|
|
472
|
+
observer = new IntersectionObserver(handleIntersect, {
|
|
473
|
+
root: el,
|
|
474
|
+
rootMargin: direction === "down" ? `0px 0px ${threshold}px 0px` : `${threshold}px 0px 0px 0px`,
|
|
475
|
+
threshold: 0
|
|
476
|
+
});
|
|
477
|
+
observer.observe(sentinel);
|
|
478
|
+
};
|
|
479
|
+
const cleanup = () => {
|
|
480
|
+
if (observer) {
|
|
481
|
+
observer.disconnect();
|
|
482
|
+
observer = null;
|
|
483
|
+
}
|
|
484
|
+
if (sentinel && containerEl) {
|
|
485
|
+
sentinel.remove();
|
|
486
|
+
sentinel = null;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
const ref = (el) => {
|
|
490
|
+
if (el) setup(el);
|
|
491
|
+
else cleanup();
|
|
492
|
+
};
|
|
493
|
+
onCleanup(cleanup);
|
|
494
|
+
return {
|
|
495
|
+
ref,
|
|
496
|
+
triggered
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
291
500
|
//#endregion
|
|
292
501
|
//#region src/useIntersection.ts
|
|
293
502
|
/**
|
|
@@ -388,6 +597,36 @@ const useMergedRef = (...refs) => {
|
|
|
388
597
|
};
|
|
389
598
|
};
|
|
390
599
|
|
|
600
|
+
//#endregion
|
|
601
|
+
//#region src/useOnline.ts
|
|
602
|
+
/**
|
|
603
|
+
* Reactive online/offline status.
|
|
604
|
+
* Tracks `navigator.onLine` and updates on connectivity changes.
|
|
605
|
+
*
|
|
606
|
+
* @example
|
|
607
|
+
* ```tsx
|
|
608
|
+
* const online = useOnline()
|
|
609
|
+
* <Show when={!online()} fallback={<App />}>
|
|
610
|
+
* <OfflineBanner />
|
|
611
|
+
* </Show>
|
|
612
|
+
* ```
|
|
613
|
+
*/
|
|
614
|
+
function useOnline() {
|
|
615
|
+
const isBrowser = typeof window !== "undefined";
|
|
616
|
+
const online = signal(isBrowser ? navigator.onLine : true);
|
|
617
|
+
if (isBrowser) {
|
|
618
|
+
const setOnline = () => online.set(true);
|
|
619
|
+
const setOffline = () => online.set(false);
|
|
620
|
+
window.addEventListener("online", setOnline);
|
|
621
|
+
window.addEventListener("offline", setOffline);
|
|
622
|
+
onCleanup(() => {
|
|
623
|
+
window.removeEventListener("online", setOnline);
|
|
624
|
+
window.removeEventListener("offline", setOffline);
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
return online;
|
|
628
|
+
}
|
|
629
|
+
|
|
391
630
|
//#endregion
|
|
392
631
|
//#region src/usePrevious.ts
|
|
393
632
|
/**
|
|
@@ -519,6 +758,119 @@ const useThrottledCallback = (callback, delay) => {
|
|
|
519
758
|
return throttled;
|
|
520
759
|
};
|
|
521
760
|
|
|
761
|
+
//#endregion
|
|
762
|
+
//#region src/useTimeAgo.ts
|
|
763
|
+
const INTERVALS = [
|
|
764
|
+
{
|
|
765
|
+
unit: "year",
|
|
766
|
+
seconds: 31536e3
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
unit: "month",
|
|
770
|
+
seconds: 2592e3
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
unit: "week",
|
|
774
|
+
seconds: 604800
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
unit: "day",
|
|
778
|
+
seconds: 86400
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
unit: "hour",
|
|
782
|
+
seconds: 3600
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
unit: "minute",
|
|
786
|
+
seconds: 60
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
unit: "second",
|
|
790
|
+
seconds: 1
|
|
791
|
+
}
|
|
792
|
+
];
|
|
793
|
+
/**
|
|
794
|
+
* Determine how often to update based on the age of the timestamp.
|
|
795
|
+
* Recent times update more frequently.
|
|
796
|
+
*/
|
|
797
|
+
function getRefreshInterval(diffSeconds) {
|
|
798
|
+
if (diffSeconds < 60) return 1e3;
|
|
799
|
+
if (diffSeconds < 3600) return 3e4;
|
|
800
|
+
if (diffSeconds < 86400) return 3e5;
|
|
801
|
+
return 36e5;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Default English formatter using Intl.RelativeTimeFormat.
|
|
805
|
+
*/
|
|
806
|
+
const defaultFormatter = (() => {
|
|
807
|
+
const rtf = typeof Intl !== "undefined" ? new Intl.RelativeTimeFormat("en", { numeric: "auto" }) : void 0;
|
|
808
|
+
return (value, unit, isPast) => {
|
|
809
|
+
if (rtf) return rtf.format(isPast ? -value : value, unit);
|
|
810
|
+
const label = value === 1 ? unit : `${unit}s`;
|
|
811
|
+
return isPast ? `${value} ${label} ago` : `in ${value} ${label}`;
|
|
812
|
+
};
|
|
813
|
+
})();
|
|
814
|
+
/**
|
|
815
|
+
* Compute the relative time string for a given timestamp.
|
|
816
|
+
*/
|
|
817
|
+
function computeTimeAgo(date, formatter) {
|
|
818
|
+
const now = Date.now();
|
|
819
|
+
const target = typeof date === "number" ? date : date.getTime();
|
|
820
|
+
const diff = Math.abs(now - target);
|
|
821
|
+
const diffSeconds = Math.floor(diff / 1e3);
|
|
822
|
+
const isPast = target < now;
|
|
823
|
+
if (diffSeconds < 5) return "just now";
|
|
824
|
+
for (const { unit, seconds } of INTERVALS) {
|
|
825
|
+
const value = Math.floor(diffSeconds / seconds);
|
|
826
|
+
if (value >= 1) return formatter(value, unit, isPast);
|
|
827
|
+
}
|
|
828
|
+
return "just now";
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Reactive relative time that auto-updates.
|
|
832
|
+
* Returns a signal that displays "2 minutes ago", "just now", etc.
|
|
833
|
+
*
|
|
834
|
+
* @param date - Date object, timestamp, or reactive getter
|
|
835
|
+
*
|
|
836
|
+
* @example
|
|
837
|
+
* ```tsx
|
|
838
|
+
* const timeAgo = useTimeAgo(post.createdAt)
|
|
839
|
+
* <span>{timeAgo}</span>
|
|
840
|
+
* // Renders: "5 minutes ago" → "6 minutes ago" (auto-updates)
|
|
841
|
+
*
|
|
842
|
+
* // With reactive date:
|
|
843
|
+
* const timeAgo = useTimeAgo(() => selectedPost().createdAt)
|
|
844
|
+
*
|
|
845
|
+
* // With custom formatter (e.g. for i18n):
|
|
846
|
+
* const timeAgo = useTimeAgo(date, {
|
|
847
|
+
* formatter: (value, unit, isPast) => t('time.' + unit, { count: value })
|
|
848
|
+
* })
|
|
849
|
+
* ```
|
|
850
|
+
*/
|
|
851
|
+
function useTimeAgo(date, options) {
|
|
852
|
+
const formatter = options?.formatter ?? defaultFormatter;
|
|
853
|
+
const resolveDate = typeof date === "function" ? date : () => date;
|
|
854
|
+
const result = signal(computeTimeAgo(resolveDate(), formatter));
|
|
855
|
+
let timer;
|
|
856
|
+
let disposed = false;
|
|
857
|
+
function tick() {
|
|
858
|
+
if (disposed) return;
|
|
859
|
+
const d = resolveDate();
|
|
860
|
+
result.set(computeTimeAgo(d, formatter));
|
|
861
|
+
const target = typeof d === "number" ? d : d.getTime();
|
|
862
|
+
const diffSeconds = Math.floor(Math.abs(Date.now() - target) / 1e3);
|
|
863
|
+
const interval = options?.interval ?? getRefreshInterval(diffSeconds);
|
|
864
|
+
timer = setTimeout(tick, interval);
|
|
865
|
+
}
|
|
866
|
+
timer = setTimeout(tick, 0);
|
|
867
|
+
onCleanup(() => {
|
|
868
|
+
disposed = true;
|
|
869
|
+
if (timer) clearTimeout(timer);
|
|
870
|
+
});
|
|
871
|
+
return result;
|
|
872
|
+
}
|
|
873
|
+
|
|
522
874
|
//#endregion
|
|
523
875
|
//#region src/useTimeout.ts
|
|
524
876
|
/**
|
|
@@ -613,5 +965,5 @@ function useWindowResize(throttleMs = 200) {
|
|
|
613
965
|
}
|
|
614
966
|
|
|
615
967
|
//#endregion
|
|
616
|
-
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 };
|
|
968
|
+
export { useBreakpoint, useClickOutside, useClipboard, useColorScheme, useControllableState, useDebouncedCallback, useDebouncedValue, useDialog, useElementSize, useEventListener, useFocus, useFocusTrap, useHover, useInfiniteScroll, useIntersection, useInterval, useIsomorphicLayoutEffect, useKeyboard, useLatest, useMediaQuery, useMergedRef, useOnline, usePrevious, useReducedMotion, useRootSize, useScrollLock, useSpacing, useThemeValue, useThrottledCallback, useTimeAgo, useTimeout, useToggle, useUpdateEffect, useWindowResize };
|
|
617
969
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
|
-
"url": "https://github.com/pyreon/
|
|
7
|
-
"directory": "packages/hooks"
|
|
6
|
+
"url": "https://github.com/pyreon/pyreon",
|
|
7
|
+
"directory": "packages/fundamentals/hooks"
|
|
8
8
|
},
|
|
9
9
|
"description": "Signal-based reactive utilities for Pyreon",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"type": "module",
|
|
12
12
|
"sideEffects": false,
|
|
13
13
|
"exports": {
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
".": {
|
|
15
|
+
"bun": "./src/index.ts",
|
|
16
|
+
"import": "./lib/index.js",
|
|
17
|
+
"types": "./lib/index.d.ts"
|
|
18
|
+
}
|
|
17
19
|
},
|
|
18
20
|
"types": "./lib/index.d.ts",
|
|
19
21
|
"main": "./lib/index.js",
|
|
@@ -25,7 +27,7 @@
|
|
|
25
27
|
"LICENSE"
|
|
26
28
|
],
|
|
27
29
|
"engines": {
|
|
28
|
-
"node": ">=
|
|
30
|
+
"node": ">= 22"
|
|
29
31
|
},
|
|
30
32
|
"publishConfig": {
|
|
31
33
|
"access": "public"
|
|
@@ -41,13 +43,13 @@
|
|
|
41
43
|
"typecheck": "tsc --noEmit"
|
|
42
44
|
},
|
|
43
45
|
"peerDependencies": {
|
|
44
|
-
"@pyreon/core": "
|
|
45
|
-
"@pyreon/reactivity": "
|
|
46
|
-
"@pyreon/styler": "
|
|
47
|
-
"@pyreon/ui-core": "
|
|
46
|
+
"@pyreon/core": "^0.11.1",
|
|
47
|
+
"@pyreon/reactivity": "^0.11.1",
|
|
48
|
+
"@pyreon/styler": "^0.11.1",
|
|
49
|
+
"@pyreon/ui-core": "^0.11.1"
|
|
48
50
|
},
|
|
49
51
|
"devDependencies": {
|
|
50
52
|
"@vitus-labs/tools-rolldown": "^1.15.3",
|
|
51
|
-
"@pyreon/typescript": "^0.
|
|
53
|
+
"@pyreon/typescript": "^0.11.1"
|
|
52
54
|
}
|
|
53
55
|
}
|