@pyreon/hooks 0.11.0 → 0.11.2
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/package.json +14 -12
- package/src/__tests__/useBreakpoint.test.ts +148 -0
- package/src/__tests__/useClickOutside.test.ts +144 -0
- package/src/__tests__/useColorScheme.test.ts +101 -0
- package/src/__tests__/useControllableState.test.ts +45 -0
- package/src/__tests__/useDebouncedCallback.test.ts +80 -0
- package/src/__tests__/useDebouncedValue.test.ts +112 -0
- package/src/__tests__/useElementSize.test.ts +190 -0
- package/src/__tests__/useFocus.test.ts +52 -0
- package/src/__tests__/useFocusTrap.test.ts +162 -0
- package/src/__tests__/useHover.test.ts +62 -0
- package/src/__tests__/useIntersection.test.ts +154 -0
- package/src/__tests__/useInterval.test.ts +48 -0
- package/src/__tests__/useIsomorphicLayoutEffect.test.ts +9 -0
- package/src/__tests__/useKeyboard.test.ts +144 -0
- package/src/__tests__/useLatest.test.ts +24 -0
- package/src/__tests__/useMediaQuery.test.ts +143 -0
- package/src/__tests__/useMergedRef.test.ts +48 -0
- package/src/__tests__/usePrevious.test.ts +70 -0
- package/src/__tests__/useReducedMotion.test.ts +96 -0
- package/src/__tests__/useRootSize.test.ts +27 -0
- package/src/__tests__/useScrollLock.test.ts +123 -0
- package/src/__tests__/useSpacing.test.ts +23 -0
- package/src/__tests__/useThemeValue.test.ts +12 -0
- package/src/__tests__/useThrottledCallback.test.ts +56 -0
- package/src/__tests__/useTimeout.test.ts +54 -0
- package/src/__tests__/useToggle.test.ts +82 -0
- package/src/__tests__/useUpdateEffect.test.ts +48 -0
- package/src/__tests__/useWindowResize.test.ts +139 -0
- package/src/index.ts +56 -0
- package/src/useBreakpoint.ts +52 -0
- package/src/useClickOutside.ts +23 -0
- package/src/useClipboard.ts +51 -0
- package/src/useColorScheme.ts +10 -0
- package/src/useControllableState.ts +39 -0
- package/src/useDebouncedCallback.ts +57 -0
- package/src/useDebouncedValue.ts +24 -0
- package/src/useDialog.ts +83 -0
- package/src/useElementSize.ts +38 -0
- package/src/useEventListener.ts +31 -0
- package/src/useFocus.ts +24 -0
- package/src/useFocusTrap.ts +42 -0
- package/src/useHover.ts +30 -0
- package/src/useInfiniteScroll.ts +115 -0
- package/src/useIntersection.ts +30 -0
- package/src/useInterval.ts +31 -0
- package/src/useIsomorphicLayoutEffect.ts +19 -0
- package/src/useKeyboard.ts +28 -0
- package/src/useLatest.ts +17 -0
- package/src/useMediaQuery.ts +27 -0
- package/src/useMergedRef.ts +24 -0
- package/src/useOnline.ts +31 -0
- package/src/usePrevious.ts +18 -0
- package/src/useReducedMotion.ts +8 -0
- package/src/useRootSize.ts +28 -0
- package/src/useScrollLock.ts +37 -0
- package/src/useSpacing.ts +26 -0
- package/src/useThemeValue.ts +21 -0
- package/src/useThrottledCallback.ts +30 -0
- package/src/useTimeAgo.ts +134 -0
- package/src/useTimeout.ts +42 -0
- package/src/useToggle.ts +22 -0
- package/src/useUpdateEffect.ts +24 -0
- package/src/useWindowResize.ts +39 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { onUnmount } from "@pyreon/core"
|
|
2
|
+
|
|
3
|
+
let lockCount = 0
|
|
4
|
+
let savedOverflow = ""
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Lock page scroll. Uses reference counting for concurrent locks.
|
|
8
|
+
* Returns an unlock function.
|
|
9
|
+
*/
|
|
10
|
+
export function useScrollLock(): { lock: () => void; unlock: () => void } {
|
|
11
|
+
let isLocked = false
|
|
12
|
+
|
|
13
|
+
const lock = () => {
|
|
14
|
+
if (isLocked) return
|
|
15
|
+
isLocked = true
|
|
16
|
+
if (lockCount === 0) {
|
|
17
|
+
savedOverflow = document.body.style.overflow
|
|
18
|
+
document.body.style.overflow = "hidden"
|
|
19
|
+
}
|
|
20
|
+
lockCount++
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const unlock = () => {
|
|
24
|
+
if (!isLocked) return
|
|
25
|
+
isLocked = false
|
|
26
|
+
lockCount--
|
|
27
|
+
if (lockCount === 0) {
|
|
28
|
+
document.body.style.overflow = savedOverflow
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
onUnmount(() => {
|
|
33
|
+
if (isLocked) unlock()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return { lock, unlock }
|
|
37
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import useRootSize from "./useRootSize"
|
|
2
|
+
|
|
3
|
+
export type UseSpacing = (base?: number | undefined) => (multiplier: number) => string
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns a `spacing(n)` function that computes spacing values
|
|
7
|
+
* based on `rootSize` from the theme.
|
|
8
|
+
*
|
|
9
|
+
* @param base - Base spacing unit in px (defaults to `rootSize / 2`, i.e. 8px)
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* const spacing = useSpacing()
|
|
14
|
+
* spacing(1) // "8px"
|
|
15
|
+
* spacing(2) // "16px"
|
|
16
|
+
* spacing(0.5) // "4px"
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export const useSpacing: UseSpacing = (base) => {
|
|
20
|
+
const { rootSize } = useRootSize()
|
|
21
|
+
const unit = base ?? rootSize / 2
|
|
22
|
+
|
|
23
|
+
return (multiplier: number) => `${unit * multiplier}px`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default useSpacing
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useTheme } from "@pyreon/styler"
|
|
2
|
+
import { get } from "@pyreon/ui-core"
|
|
3
|
+
|
|
4
|
+
export type UseThemeValue = <T = unknown>(path: string) => T | undefined
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Deep-reads a value from the current theme by dot-separated path.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* const primary = useThemeValue<string>('colors.primary')
|
|
12
|
+
* const columns = useThemeValue<number>('grid.columns')
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export const useThemeValue: UseThemeValue = (path) => {
|
|
16
|
+
const theme = useTheme()
|
|
17
|
+
if (!theme) return undefined
|
|
18
|
+
return get(theme, path)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default useThemeValue
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { onUnmount } from "@pyreon/core"
|
|
2
|
+
import { throttle } from "@pyreon/ui-core"
|
|
3
|
+
|
|
4
|
+
type ThrottledFn<T extends (...args: any[]) => any> = {
|
|
5
|
+
(...args: Parameters<T>): void
|
|
6
|
+
cancel: () => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type UseThrottledCallback = <T extends (...args: any[]) => any>(
|
|
10
|
+
callback: T,
|
|
11
|
+
delay: number,
|
|
12
|
+
) => ThrottledFn<T>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns a throttled version of the callback.
|
|
16
|
+
* Uses `throttle` from `@pyreon/ui-core`.
|
|
17
|
+
* Always calls the latest callback (no stale closures).
|
|
18
|
+
* Cleans up on unmount.
|
|
19
|
+
*/
|
|
20
|
+
export const useThrottledCallback: UseThrottledCallback = (callback, delay) => {
|
|
21
|
+
const currentCallback = callback
|
|
22
|
+
|
|
23
|
+
const throttled = throttle((...args: any[]) => currentCallback(...args), delay)
|
|
24
|
+
|
|
25
|
+
onUnmount(() => throttled.cancel())
|
|
26
|
+
|
|
27
|
+
return throttled as ThrottledFn<typeof callback>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default useThrottledCallback
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { onCleanup, signal } from "@pyreon/reactivity"
|
|
2
|
+
|
|
3
|
+
type TimeUnit = "second" | "minute" | "hour" | "day" | "week" | "month" | "year"
|
|
4
|
+
|
|
5
|
+
interface TimeInterval {
|
|
6
|
+
unit: TimeUnit
|
|
7
|
+
seconds: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const INTERVALS: TimeInterval[] = [
|
|
11
|
+
{ unit: "year", seconds: 31536000 },
|
|
12
|
+
{ unit: "month", seconds: 2592000 },
|
|
13
|
+
{ unit: "week", seconds: 604800 },
|
|
14
|
+
{ unit: "day", seconds: 86400 },
|
|
15
|
+
{ unit: "hour", seconds: 3600 },
|
|
16
|
+
{ unit: "minute", seconds: 60 },
|
|
17
|
+
{ unit: "second", seconds: 1 },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Determine how often to update based on the age of the timestamp.
|
|
22
|
+
* Recent times update more frequently.
|
|
23
|
+
*/
|
|
24
|
+
function getRefreshInterval(diffSeconds: number): number {
|
|
25
|
+
if (diffSeconds < 60) return 1000 // every second for <1min
|
|
26
|
+
if (diffSeconds < 3600) return 30_000 // every 30s for <1hr
|
|
27
|
+
if (diffSeconds < 86400) return 300_000 // every 5min for <1day
|
|
28
|
+
return 3600_000 // every hour for older
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UseTimeAgoOptions {
|
|
32
|
+
/** Custom formatter. Receives the value, unit, and whether it's in the past. */
|
|
33
|
+
formatter?: (value: number, unit: TimeUnit, isPast: boolean) => string
|
|
34
|
+
/** Update interval override in ms. If not set, adapts based on age. */
|
|
35
|
+
interval?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Default English formatter using Intl.RelativeTimeFormat.
|
|
40
|
+
*/
|
|
41
|
+
const defaultFormatter = (() => {
|
|
42
|
+
const rtf =
|
|
43
|
+
typeof Intl !== "undefined" ? new Intl.RelativeTimeFormat("en", { numeric: "auto" }) : undefined
|
|
44
|
+
|
|
45
|
+
return (value: number, unit: TimeUnit, isPast: boolean): string => {
|
|
46
|
+
if (rtf) return rtf.format(isPast ? -value : value, unit)
|
|
47
|
+
// Fallback for environments without Intl
|
|
48
|
+
const label = value === 1 ? unit : `${unit}s`
|
|
49
|
+
return isPast ? `${value} ${label} ago` : `in ${value} ${label}`
|
|
50
|
+
}
|
|
51
|
+
})()
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compute the relative time string for a given timestamp.
|
|
55
|
+
*/
|
|
56
|
+
function computeTimeAgo(
|
|
57
|
+
date: Date | number,
|
|
58
|
+
formatter: (value: number, unit: TimeUnit, isPast: boolean) => string,
|
|
59
|
+
): string {
|
|
60
|
+
const now = Date.now()
|
|
61
|
+
const target = typeof date === "number" ? date : date.getTime()
|
|
62
|
+
const diff = Math.abs(now - target)
|
|
63
|
+
const diffSeconds = Math.floor(diff / 1000)
|
|
64
|
+
const isPast = target < now
|
|
65
|
+
|
|
66
|
+
if (diffSeconds < 5) return "just now"
|
|
67
|
+
|
|
68
|
+
for (const { unit, seconds } of INTERVALS) {
|
|
69
|
+
const value = Math.floor(diffSeconds / seconds)
|
|
70
|
+
if (value >= 1) return formatter(value, unit, isPast)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return "just now"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reactive relative time that auto-updates.
|
|
78
|
+
* Returns a signal that displays "2 minutes ago", "just now", etc.
|
|
79
|
+
*
|
|
80
|
+
* @param date - Date object, timestamp, or reactive getter
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```tsx
|
|
84
|
+
* const timeAgo = useTimeAgo(post.createdAt)
|
|
85
|
+
* <span>{timeAgo}</span>
|
|
86
|
+
* // Renders: "5 minutes ago" → "6 minutes ago" (auto-updates)
|
|
87
|
+
*
|
|
88
|
+
* // With reactive date:
|
|
89
|
+
* const timeAgo = useTimeAgo(() => selectedPost().createdAt)
|
|
90
|
+
*
|
|
91
|
+
* // With custom formatter (e.g. for i18n):
|
|
92
|
+
* const timeAgo = useTimeAgo(date, {
|
|
93
|
+
* formatter: (value, unit, isPast) => t('time.' + unit, { count: value })
|
|
94
|
+
* })
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export function useTimeAgo(
|
|
98
|
+
date: Date | number | (() => Date | number),
|
|
99
|
+
options?: UseTimeAgoOptions,
|
|
100
|
+
): () => string {
|
|
101
|
+
const formatter = options?.formatter ?? defaultFormatter
|
|
102
|
+
const resolveDate = typeof date === "function" ? date : () => date
|
|
103
|
+
|
|
104
|
+
const result = signal(computeTimeAgo(resolveDate(), formatter))
|
|
105
|
+
|
|
106
|
+
// Disposed flag prevents timer chain from continuing after cleanup.
|
|
107
|
+
// Without this, the setTimeout callback could fire after the component
|
|
108
|
+
// unmounts, scheduling yet another timer indefinitely.
|
|
109
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
110
|
+
let disposed = false
|
|
111
|
+
|
|
112
|
+
function tick() {
|
|
113
|
+
if (disposed) return
|
|
114
|
+
|
|
115
|
+
const d = resolveDate()
|
|
116
|
+
result.set(computeTimeAgo(d, formatter))
|
|
117
|
+
|
|
118
|
+
// Schedule next update with adaptive interval
|
|
119
|
+
const target = typeof d === "number" ? d : d.getTime()
|
|
120
|
+
const diffSeconds = Math.floor(Math.abs(Date.now() - target) / 1000)
|
|
121
|
+
const interval = options?.interval ?? getRefreshInterval(diffSeconds)
|
|
122
|
+
timer = setTimeout(tick, interval)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Schedule first tick (don't call synchronously — let cleanup register first)
|
|
126
|
+
timer = setTimeout(tick, 0)
|
|
127
|
+
|
|
128
|
+
onCleanup(() => {
|
|
129
|
+
disposed = true
|
|
130
|
+
if (timer) clearTimeout(timer)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
return result
|
|
134
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { onUnmount } from "@pyreon/core"
|
|
2
|
+
|
|
3
|
+
export type UseTimeout = (
|
|
4
|
+
callback: () => void,
|
|
5
|
+
delay: number | null,
|
|
6
|
+
) => { reset: () => void; clear: () => void }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Declarative `setTimeout` with auto-cleanup.
|
|
10
|
+
* Pass `null` as `delay` to disable. Returns `reset` and `clear` controls.
|
|
11
|
+
* Always calls the latest callback (no stale closures).
|
|
12
|
+
*/
|
|
13
|
+
export const useTimeout: UseTimeout = (callback, delay) => {
|
|
14
|
+
const currentCallback = callback
|
|
15
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
16
|
+
|
|
17
|
+
const clear = () => {
|
|
18
|
+
if (timer != null) {
|
|
19
|
+
clearTimeout(timer)
|
|
20
|
+
timer = null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const reset = () => {
|
|
25
|
+
clear()
|
|
26
|
+
if (delay !== null) {
|
|
27
|
+
timer = setTimeout(() => {
|
|
28
|
+
timer = null
|
|
29
|
+
currentCallback()
|
|
30
|
+
}, delay)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Start the timer immediately
|
|
35
|
+
reset()
|
|
36
|
+
|
|
37
|
+
onUnmount(() => clear())
|
|
38
|
+
|
|
39
|
+
return { reset, clear }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default useTimeout
|
package/src/useToggle.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { signal } from "@pyreon/reactivity"
|
|
2
|
+
|
|
3
|
+
export interface UseToggleResult {
|
|
4
|
+
value: () => boolean
|
|
5
|
+
toggle: () => void
|
|
6
|
+
setTrue: () => void
|
|
7
|
+
setFalse: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Simple boolean toggle.
|
|
12
|
+
*/
|
|
13
|
+
export function useToggle(initial = false): UseToggleResult {
|
|
14
|
+
const value = signal(initial)
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
value,
|
|
18
|
+
toggle: () => value.update((v) => !v),
|
|
19
|
+
setTrue: () => value.set(true),
|
|
20
|
+
setFalse: () => value.set(false),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { onUnmount } from "@pyreon/core"
|
|
2
|
+
import { watch } from "@pyreon/reactivity"
|
|
3
|
+
|
|
4
|
+
export type UseUpdateEffect = <T>(
|
|
5
|
+
source: () => T,
|
|
6
|
+
callback: (newVal: T, oldVal: T | undefined) => undefined | (() => void),
|
|
7
|
+
) => void
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Like `effect` but skips the initial value — only fires on updates.
|
|
11
|
+
*
|
|
12
|
+
* In Pyreon, this is implemented using `watch()` which already skips
|
|
13
|
+
* the initial value by default (immediate defaults to false).
|
|
14
|
+
*
|
|
15
|
+
* @param source - A reactive getter to watch
|
|
16
|
+
* @param callback - Called when source changes, receives (newVal, oldVal)
|
|
17
|
+
*/
|
|
18
|
+
export const useUpdateEffect: UseUpdateEffect = (source, callback) => {
|
|
19
|
+
const stop = watch(source, callback)
|
|
20
|
+
|
|
21
|
+
onUnmount(() => stop())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default useUpdateEffect
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { onMount, onUnmount } from "@pyreon/core"
|
|
2
|
+
import { signal } from "@pyreon/reactivity"
|
|
3
|
+
|
|
4
|
+
export interface WindowSize {
|
|
5
|
+
width: number
|
|
6
|
+
height: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Track window dimensions reactively with throttling.
|
|
11
|
+
*/
|
|
12
|
+
export function useWindowResize(throttleMs = 200): () => WindowSize {
|
|
13
|
+
const size = signal<WindowSize>({
|
|
14
|
+
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
|
15
|
+
height: typeof window !== "undefined" ? window.innerHeight : 0,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
19
|
+
|
|
20
|
+
function onResize() {
|
|
21
|
+
if (timer !== undefined) return
|
|
22
|
+
timer = setTimeout(() => {
|
|
23
|
+
timer = undefined
|
|
24
|
+
size.set({ width: window.innerWidth, height: window.innerHeight })
|
|
25
|
+
}, throttleMs)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
onMount(() => {
|
|
29
|
+
window.addEventListener("resize", onResize)
|
|
30
|
+
return undefined
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
onUnmount(() => {
|
|
34
|
+
window.removeEventListener("resize", onResize)
|
|
35
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return size
|
|
39
|
+
}
|