@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.
Files changed (64) hide show
  1. package/package.json +14 -12
  2. package/src/__tests__/useBreakpoint.test.ts +148 -0
  3. package/src/__tests__/useClickOutside.test.ts +144 -0
  4. package/src/__tests__/useColorScheme.test.ts +101 -0
  5. package/src/__tests__/useControllableState.test.ts +45 -0
  6. package/src/__tests__/useDebouncedCallback.test.ts +80 -0
  7. package/src/__tests__/useDebouncedValue.test.ts +112 -0
  8. package/src/__tests__/useElementSize.test.ts +190 -0
  9. package/src/__tests__/useFocus.test.ts +52 -0
  10. package/src/__tests__/useFocusTrap.test.ts +162 -0
  11. package/src/__tests__/useHover.test.ts +62 -0
  12. package/src/__tests__/useIntersection.test.ts +154 -0
  13. package/src/__tests__/useInterval.test.ts +48 -0
  14. package/src/__tests__/useIsomorphicLayoutEffect.test.ts +9 -0
  15. package/src/__tests__/useKeyboard.test.ts +144 -0
  16. package/src/__tests__/useLatest.test.ts +24 -0
  17. package/src/__tests__/useMediaQuery.test.ts +143 -0
  18. package/src/__tests__/useMergedRef.test.ts +48 -0
  19. package/src/__tests__/usePrevious.test.ts +70 -0
  20. package/src/__tests__/useReducedMotion.test.ts +96 -0
  21. package/src/__tests__/useRootSize.test.ts +27 -0
  22. package/src/__tests__/useScrollLock.test.ts +123 -0
  23. package/src/__tests__/useSpacing.test.ts +23 -0
  24. package/src/__tests__/useThemeValue.test.ts +12 -0
  25. package/src/__tests__/useThrottledCallback.test.ts +56 -0
  26. package/src/__tests__/useTimeout.test.ts +54 -0
  27. package/src/__tests__/useToggle.test.ts +82 -0
  28. package/src/__tests__/useUpdateEffect.test.ts +48 -0
  29. package/src/__tests__/useWindowResize.test.ts +139 -0
  30. package/src/index.ts +56 -0
  31. package/src/useBreakpoint.ts +52 -0
  32. package/src/useClickOutside.ts +23 -0
  33. package/src/useClipboard.ts +51 -0
  34. package/src/useColorScheme.ts +10 -0
  35. package/src/useControllableState.ts +39 -0
  36. package/src/useDebouncedCallback.ts +57 -0
  37. package/src/useDebouncedValue.ts +24 -0
  38. package/src/useDialog.ts +83 -0
  39. package/src/useElementSize.ts +38 -0
  40. package/src/useEventListener.ts +31 -0
  41. package/src/useFocus.ts +24 -0
  42. package/src/useFocusTrap.ts +42 -0
  43. package/src/useHover.ts +30 -0
  44. package/src/useInfiniteScroll.ts +115 -0
  45. package/src/useIntersection.ts +30 -0
  46. package/src/useInterval.ts +31 -0
  47. package/src/useIsomorphicLayoutEffect.ts +19 -0
  48. package/src/useKeyboard.ts +28 -0
  49. package/src/useLatest.ts +17 -0
  50. package/src/useMediaQuery.ts +27 -0
  51. package/src/useMergedRef.ts +24 -0
  52. package/src/useOnline.ts +31 -0
  53. package/src/usePrevious.ts +18 -0
  54. package/src/useReducedMotion.ts +8 -0
  55. package/src/useRootSize.ts +28 -0
  56. package/src/useScrollLock.ts +37 -0
  57. package/src/useSpacing.ts +26 -0
  58. package/src/useThemeValue.ts +21 -0
  59. package/src/useThrottledCallback.ts +30 -0
  60. package/src/useTimeAgo.ts +134 -0
  61. package/src/useTimeout.ts +42 -0
  62. package/src/useToggle.ts +22 -0
  63. package/src/useUpdateEffect.ts +24 -0
  64. 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
@@ -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
+ }