@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,12 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { useThemeValue } from "../useThemeValue"
3
+
4
+ // Without a ThemeProvider, useTheme returns the default (empty object {}).
5
+ // So get(theme, path) returns undefined for any path.
6
+
7
+ describe("useThemeValue", () => {
8
+ it("returns undefined when no theme values exist", () => {
9
+ const result = useThemeValue("colors.primary")
10
+ expect(result).toBeUndefined()
11
+ })
12
+ })
@@ -0,0 +1,56 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ // Mock @pyreon/core partially - keep real exports but stub lifecycle hooks
4
+ vi.mock("@pyreon/core", async (importOriginal) => {
5
+ const actual = await importOriginal<typeof import("@pyreon/core")>()
6
+ return {
7
+ ...actual,
8
+ onMount: (fn: () => void) => fn(),
9
+ onUnmount: (_fn: () => void) => {
10
+ return undefined
11
+ },
12
+ }
13
+ })
14
+
15
+ import { useThrottledCallback } from "../useThrottledCallback"
16
+
17
+ describe("useThrottledCallback", () => {
18
+ beforeEach(() => vi.useFakeTimers())
19
+ afterEach(() => vi.useRealTimers())
20
+
21
+ it("calls immediately on first invocation (leading)", () => {
22
+ const fn = vi.fn()
23
+ const throttled = useThrottledCallback(fn, 100)
24
+
25
+ throttled("a")
26
+ expect(fn).toHaveBeenCalledTimes(1)
27
+ expect(fn).toHaveBeenCalledWith("a")
28
+ })
29
+
30
+ it("throttles subsequent calls", () => {
31
+ const fn = vi.fn()
32
+ const throttled = useThrottledCallback(fn, 100)
33
+
34
+ throttled("a")
35
+ throttled("b")
36
+ throttled("c")
37
+
38
+ expect(fn).toHaveBeenCalledTimes(1)
39
+
40
+ vi.advanceTimersByTime(100)
41
+ expect(fn).toHaveBeenCalledTimes(2)
42
+ expect(fn).toHaveBeenLastCalledWith("c")
43
+ })
44
+
45
+ it("cancel stops pending trailing call", () => {
46
+ const fn = vi.fn()
47
+ const throttled = useThrottledCallback(fn, 100)
48
+
49
+ throttled("a")
50
+ throttled("b")
51
+ throttled.cancel()
52
+
53
+ vi.advanceTimersByTime(200)
54
+ expect(fn).toHaveBeenCalledTimes(1)
55
+ })
56
+ })
@@ -0,0 +1,54 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+ import { useTimeout } from "../useTimeout"
3
+
4
+ // Mock onUnmount since it requires component lifecycle context
5
+ vi.mock("@pyreon/core", () => ({
6
+ onMount: (fn: () => void) => fn(),
7
+ onUnmount: (_fn: () => void) => {
8
+ /* no-op */
9
+ },
10
+ }))
11
+
12
+ describe("useTimeout", () => {
13
+ beforeEach(() => vi.useFakeTimers())
14
+ afterEach(() => vi.useRealTimers())
15
+
16
+ it("calls callback after delay", () => {
17
+ const fn = vi.fn()
18
+ useTimeout(fn, 200)
19
+
20
+ vi.advanceTimersByTime(200)
21
+ expect(fn).toHaveBeenCalledTimes(1)
22
+ })
23
+
24
+ it("does not call callback when delay is null", () => {
25
+ const fn = vi.fn()
26
+ useTimeout(fn, null)
27
+
28
+ vi.advanceTimersByTime(1000)
29
+ expect(fn).not.toHaveBeenCalled()
30
+ })
31
+
32
+ it("clear() prevents the callback", () => {
33
+ const fn = vi.fn()
34
+ const { clear } = useTimeout(fn, 200)
35
+
36
+ clear()
37
+ vi.advanceTimersByTime(500)
38
+ expect(fn).not.toHaveBeenCalled()
39
+ })
40
+
41
+ it("reset() restarts the timer", () => {
42
+ const fn = vi.fn()
43
+ const { reset } = useTimeout(fn, 200)
44
+
45
+ vi.advanceTimersByTime(150)
46
+ reset()
47
+
48
+ vi.advanceTimersByTime(150)
49
+ expect(fn).not.toHaveBeenCalled()
50
+
51
+ vi.advanceTimersByTime(50)
52
+ expect(fn).toHaveBeenCalledTimes(1)
53
+ })
54
+ })
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { useToggle } from "../useToggle"
3
+
4
+ describe("useToggle", () => {
5
+ it("defaults to false", () => {
6
+ const { value } = useToggle()
7
+ expect(value()).toBe(false)
8
+ })
9
+
10
+ it("respects initial value of true", () => {
11
+ const { value } = useToggle(true)
12
+ expect(value()).toBe(true)
13
+ })
14
+
15
+ it("respects initial value of false", () => {
16
+ const { value } = useToggle(false)
17
+ expect(value()).toBe(false)
18
+ })
19
+
20
+ it("toggle flips value from false to true", () => {
21
+ const { value, toggle } = useToggle()
22
+ toggle()
23
+ expect(value()).toBe(true)
24
+ })
25
+
26
+ it("toggle flips value from true to false", () => {
27
+ const { value, toggle } = useToggle(true)
28
+ toggle()
29
+ expect(value()).toBe(false)
30
+ })
31
+
32
+ it("toggle flips multiple times", () => {
33
+ const { value, toggle } = useToggle()
34
+ toggle()
35
+ expect(value()).toBe(true)
36
+ toggle()
37
+ expect(value()).toBe(false)
38
+ toggle()
39
+ expect(value()).toBe(true)
40
+ })
41
+
42
+ it("setTrue sets value to true", () => {
43
+ const { value, setTrue } = useToggle()
44
+ setTrue()
45
+ expect(value()).toBe(true)
46
+ })
47
+
48
+ it("setTrue is idempotent", () => {
49
+ const { value, setTrue } = useToggle(true)
50
+ setTrue()
51
+ expect(value()).toBe(true)
52
+ })
53
+
54
+ it("setFalse sets value to false", () => {
55
+ const { value, setFalse } = useToggle(true)
56
+ setFalse()
57
+ expect(value()).toBe(false)
58
+ })
59
+
60
+ it("setFalse is idempotent", () => {
61
+ const { value, setFalse } = useToggle(false)
62
+ setFalse()
63
+ expect(value()).toBe(false)
64
+ })
65
+
66
+ it("combines toggle, setTrue, and setFalse correctly", () => {
67
+ const { value, toggle, setTrue, setFalse } = useToggle()
68
+ expect(value()).toBe(false)
69
+
70
+ setTrue()
71
+ expect(value()).toBe(true)
72
+
73
+ setFalse()
74
+ expect(value()).toBe(false)
75
+
76
+ toggle()
77
+ expect(value()).toBe(true)
78
+
79
+ setFalse()
80
+ expect(value()).toBe(false)
81
+ })
82
+ })
@@ -0,0 +1,48 @@
1
+ import { signal } from "@pyreon/reactivity"
2
+ import { describe, expect, it, vi } from "vitest"
3
+
4
+ // Mock onUnmount since it requires component lifecycle context
5
+ vi.mock("@pyreon/core", () => ({
6
+ onMount: (fn: () => void) => fn(),
7
+ onUnmount: (_fn: () => void) => {
8
+ /* no-op */
9
+ },
10
+ }))
11
+
12
+ import { useUpdateEffect } from "../useUpdateEffect"
13
+
14
+ describe("useUpdateEffect", () => {
15
+ it("does not fire on initial setup", () => {
16
+ const callback = vi.fn()
17
+ const source = signal(1)
18
+
19
+ useUpdateEffect(source, callback)
20
+ expect(callback).not.toHaveBeenCalled()
21
+ })
22
+
23
+ it("fires when source changes", () => {
24
+ const callback = vi.fn()
25
+ const source = signal(1)
26
+
27
+ useUpdateEffect(source, callback)
28
+ expect(callback).not.toHaveBeenCalled()
29
+
30
+ source.set(2)
31
+ expect(callback).toHaveBeenCalledTimes(1)
32
+ expect(callback).toHaveBeenCalledWith(2, 1)
33
+ })
34
+
35
+ it("fires on each subsequent change", () => {
36
+ const callback = vi.fn()
37
+ const source = signal(1)
38
+
39
+ useUpdateEffect(source, callback)
40
+
41
+ source.set(2)
42
+ expect(callback).toHaveBeenCalledTimes(1)
43
+
44
+ source.set(3)
45
+ expect(callback).toHaveBeenCalledTimes(2)
46
+ expect(callback).toHaveBeenCalledWith(3, 2)
47
+ })
48
+ })
@@ -0,0 +1,139 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ let mountCallbacks: Array<() => unknown> = []
4
+ let unmountCallbacks: Array<() => void> = []
5
+
6
+ vi.mock("@pyreon/core", () => ({
7
+ onMount: (fn: () => unknown) => {
8
+ mountCallbacks.push(fn)
9
+ },
10
+ onUnmount: (fn: () => void) => {
11
+ unmountCallbacks.push(fn)
12
+ },
13
+ }))
14
+
15
+ import { useWindowResize } from "../useWindowResize"
16
+
17
+ describe("useWindowResize", () => {
18
+ const originalInnerWidth = window.innerWidth
19
+ const originalInnerHeight = window.innerHeight
20
+
21
+ beforeEach(() => {
22
+ mountCallbacks = []
23
+ unmountCallbacks = []
24
+ vi.useFakeTimers()
25
+ })
26
+
27
+ afterEach(() => {
28
+ vi.useRealTimers()
29
+ Object.defineProperty(window, "innerWidth", { writable: true, value: originalInnerWidth })
30
+ Object.defineProperty(window, "innerHeight", { writable: true, value: originalInnerHeight })
31
+ })
32
+
33
+ it("returns initial window dimensions", () => {
34
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 1024 })
35
+ Object.defineProperty(window, "innerHeight", { writable: true, value: 768 })
36
+
37
+ const size = useWindowResize()
38
+ expect(size().width).toBe(1024)
39
+ expect(size().height).toBe(768)
40
+ })
41
+
42
+ it("updates dimensions after resize with throttle", () => {
43
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 1024 })
44
+ Object.defineProperty(window, "innerHeight", { writable: true, value: 768 })
45
+
46
+ const size = useWindowResize(200)
47
+ mountCallbacks.forEach((cb) => {
48
+ cb()
49
+ })
50
+
51
+ // Simulate resize
52
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 800 })
53
+ Object.defineProperty(window, "innerHeight", { writable: true, value: 600 })
54
+ window.dispatchEvent(new Event("resize"))
55
+
56
+ // Should not update immediately
57
+ expect(size().width).toBe(1024)
58
+
59
+ // After throttle period
60
+ vi.advanceTimersByTime(200)
61
+ expect(size().width).toBe(800)
62
+ expect(size().height).toBe(600)
63
+ })
64
+
65
+ it("throttles rapid resize events", () => {
66
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 1024 })
67
+ Object.defineProperty(window, "innerHeight", { writable: true, value: 768 })
68
+
69
+ const size = useWindowResize(100)
70
+ mountCallbacks.forEach((cb) => {
71
+ cb()
72
+ })
73
+
74
+ // First resize triggers timer
75
+ window.dispatchEvent(new Event("resize"))
76
+
77
+ // Second resize within throttle window should be ignored
78
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 500 })
79
+ Object.defineProperty(window, "innerHeight", { writable: true, value: 400 })
80
+ window.dispatchEvent(new Event("resize"))
81
+
82
+ // After throttle, should use current window dimensions
83
+ vi.advanceTimersByTime(100)
84
+ expect(size().width).toBe(500)
85
+ expect(size().height).toBe(400)
86
+ })
87
+
88
+ it("uses default throttle of 200ms", () => {
89
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 1024 })
90
+ Object.defineProperty(window, "innerHeight", { writable: true, value: 768 })
91
+
92
+ const size = useWindowResize()
93
+ mountCallbacks.forEach((cb) => {
94
+ cb()
95
+ })
96
+
97
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 800 })
98
+ window.dispatchEvent(new Event("resize"))
99
+
100
+ vi.advanceTimersByTime(100)
101
+ expect(size().width).toBe(1024) // Still throttled
102
+
103
+ vi.advanceTimersByTime(100)
104
+ expect(size().width).toBe(800) // Now updated
105
+ })
106
+
107
+ it("cleans up on unmount", () => {
108
+ const removeSpy = vi.spyOn(window, "removeEventListener")
109
+ useWindowResize()
110
+ mountCallbacks.forEach((cb) => {
111
+ cb()
112
+ })
113
+ unmountCallbacks.forEach((cb) => {
114
+ cb()
115
+ })
116
+
117
+ expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function))
118
+ removeSpy.mockRestore()
119
+ })
120
+
121
+ it("clears pending timer on unmount", () => {
122
+ const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout")
123
+ Object.defineProperty(window, "innerWidth", { writable: true, value: 1024 })
124
+
125
+ useWindowResize(200)
126
+ mountCallbacks.forEach((cb) => {
127
+ cb()
128
+ })
129
+
130
+ // Trigger a resize to start the timer
131
+ window.dispatchEvent(new Event("resize"))
132
+
133
+ unmountCallbacks.forEach((cb) => {
134
+ cb()
135
+ })
136
+ expect(clearTimeoutSpy).toHaveBeenCalled()
137
+ clearTimeoutSpy.mockRestore()
138
+ })
139
+ })
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ export type { BreakpointMap } from "./useBreakpoint"
2
+ export { useBreakpoint } from "./useBreakpoint"
3
+ export { useClickOutside } from "./useClickOutside"
4
+ export type { UseClipboardResult } from "./useClipboard"
5
+ export { useClipboard } from "./useClipboard"
6
+ export { useColorScheme } from "./useColorScheme"
7
+ export type { UseControllableState } from "./useControllableState"
8
+ export { useControllableState } from "./useControllableState"
9
+ export type { UseDebouncedCallback } from "./useDebouncedCallback"
10
+ export { useDebouncedCallback } from "./useDebouncedCallback"
11
+ export { useDebouncedValue } from "./useDebouncedValue"
12
+ export type { UseDialogResult } from "./useDialog"
13
+ export { useDialog } from "./useDialog"
14
+ export type { Size } from "./useElementSize"
15
+ export { useElementSize } from "./useElementSize"
16
+ export { useEventListener } from "./useEventListener"
17
+ export type { UseFocusResult } from "./useFocus"
18
+ export { useFocus } from "./useFocus"
19
+ export { useFocusTrap } from "./useFocusTrap"
20
+ export type { UseHoverResult } from "./useHover"
21
+ export { useHover } from "./useHover"
22
+ export type { UseInfiniteScrollOptions, UseInfiniteScrollResult } from "./useInfiniteScroll"
23
+ export { useInfiniteScroll } from "./useInfiniteScroll"
24
+ export { useIntersection } from "./useIntersection"
25
+ export type { UseInterval } from "./useInterval"
26
+ export { useInterval } from "./useInterval"
27
+ export type { UseIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"
28
+ export { default as useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"
29
+ export { useKeyboard } from "./useKeyboard"
30
+ export type { UseLatest } from "./useLatest"
31
+ export { useLatest } from "./useLatest"
32
+ export { useMediaQuery } from "./useMediaQuery"
33
+ export type { UseMergedRef } from "./useMergedRef"
34
+ export { useMergedRef } from "./useMergedRef"
35
+ export { useOnline } from "./useOnline"
36
+ export { usePrevious } from "./usePrevious"
37
+ export { useReducedMotion } from "./useReducedMotion"
38
+ export type { UseRootSize } from "./useRootSize"
39
+ export { useRootSize } from "./useRootSize"
40
+ export { useScrollLock } from "./useScrollLock"
41
+ export type { UseSpacing } from "./useSpacing"
42
+ export { useSpacing } from "./useSpacing"
43
+ export type { UseThemeValue } from "./useThemeValue"
44
+ export { useThemeValue } from "./useThemeValue"
45
+ export type { UseThrottledCallback } from "./useThrottledCallback"
46
+ export { useThrottledCallback } from "./useThrottledCallback"
47
+ export type { UseTimeAgoOptions } from "./useTimeAgo"
48
+ export { useTimeAgo } from "./useTimeAgo"
49
+ export type { UseTimeout } from "./useTimeout"
50
+ export { useTimeout } from "./useTimeout"
51
+ export type { UseToggleResult } from "./useToggle"
52
+ export { useToggle } from "./useToggle"
53
+ export type { UseUpdateEffect } from "./useUpdateEffect"
54
+ export { useUpdateEffect } from "./useUpdateEffect"
55
+ export type { WindowSize } from "./useWindowResize"
56
+ export { useWindowResize } from "./useWindowResize"
@@ -0,0 +1,52 @@
1
+ import { onMount, onUnmount } from "@pyreon/core"
2
+ import { signal } from "@pyreon/reactivity"
3
+
4
+ export type BreakpointMap = Record<string, number>
5
+
6
+ const defaultBreakpoints: BreakpointMap = {
7
+ xs: 0,
8
+ sm: 576,
9
+ md: 768,
10
+ lg: 992,
11
+ xl: 1200,
12
+ }
13
+
14
+ /**
15
+ * Return the currently active breakpoint name as a reactive signal.
16
+ */
17
+ export function useBreakpoint(breakpoints: BreakpointMap = defaultBreakpoints): () => string {
18
+ const sorted = Object.entries(breakpoints).sort(([, a], [, b]) => a - b)
19
+ const active = signal(getActive(sorted))
20
+ let rafId: number | undefined
21
+
22
+ function getActive(bps: [string, number][]): string {
23
+ if (typeof window === "undefined") return bps[0]?.[0] ?? ""
24
+ const w = window.innerWidth
25
+ let result = bps[0]?.[0] ?? ""
26
+ for (const [name, min] of bps) {
27
+ if (w >= min) result = name
28
+ else break
29
+ }
30
+ return result
31
+ }
32
+
33
+ function onResize() {
34
+ if (rafId !== undefined) cancelAnimationFrame(rafId)
35
+ rafId = requestAnimationFrame(() => {
36
+ const next = getActive(sorted)
37
+ if (next !== active.peek()) active.set(next)
38
+ })
39
+ }
40
+
41
+ onMount(() => {
42
+ window.addEventListener("resize", onResize)
43
+ return undefined
44
+ })
45
+
46
+ onUnmount(() => {
47
+ window.removeEventListener("resize", onResize)
48
+ if (rafId !== undefined) cancelAnimationFrame(rafId)
49
+ })
50
+
51
+ return active
52
+ }
@@ -0,0 +1,23 @@
1
+ import { onMount, onUnmount } from "@pyreon/core"
2
+
3
+ /**
4
+ * Call handler when a click occurs outside the target element.
5
+ */
6
+ export function useClickOutside(getEl: () => HTMLElement | null, handler: () => void): void {
7
+ const listener = (e: Event) => {
8
+ const el = getEl()
9
+ if (!el || el.contains(e.target as Node)) return
10
+ handler()
11
+ }
12
+
13
+ onMount(() => {
14
+ document.addEventListener("mousedown", listener, true)
15
+ document.addEventListener("touchstart", listener, true)
16
+ return undefined
17
+ })
18
+
19
+ onUnmount(() => {
20
+ document.removeEventListener("mousedown", listener, true)
21
+ document.removeEventListener("touchstart", listener, true)
22
+ })
23
+ }
@@ -0,0 +1,51 @@
1
+ import { onCleanup, signal } from "@pyreon/reactivity"
2
+
3
+ export interface UseClipboardResult {
4
+ /** Copy text to clipboard. Returns true on success. */
5
+ copy: (text: string) => Promise<boolean>
6
+ /** Whether the last copy succeeded (resets after timeout). */
7
+ copied: () => boolean
8
+ /** The last successfully copied text. */
9
+ text: () => string
10
+ }
11
+
12
+ /**
13
+ * Reactive clipboard access — copy text and track copied state.
14
+ *
15
+ * @param options.timeout - ms before `copied` resets to false (default: 2000)
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * const { copy, copied } = useClipboard()
20
+ *
21
+ * <button onClick={() => copy("hello")}>
22
+ * {() => copied() ? "Copied!" : "Copy"}
23
+ * </button>
24
+ * ```
25
+ */
26
+ export function useClipboard(options?: { timeout?: number }): UseClipboardResult {
27
+ const timeout = options?.timeout ?? 2000
28
+ const copied = signal(false)
29
+ const text = signal("")
30
+ let timer: ReturnType<typeof setTimeout> | undefined
31
+
32
+ const copy = async (value: string): Promise<boolean> => {
33
+ try {
34
+ await navigator.clipboard.writeText(value)
35
+ text.set(value)
36
+ copied.set(true)
37
+ if (timer) clearTimeout(timer)
38
+ timer = setTimeout(() => copied.set(false), timeout)
39
+ return true
40
+ } catch {
41
+ copied.set(false)
42
+ return false
43
+ }
44
+ }
45
+
46
+ onCleanup(() => {
47
+ if (timer) clearTimeout(timer)
48
+ })
49
+
50
+ return { copy, copied, text }
51
+ }
@@ -0,0 +1,10 @@
1
+ import { computed } from "@pyreon/reactivity"
2
+ import { useMediaQuery } from "./useMediaQuery"
3
+
4
+ /**
5
+ * Returns the OS color scheme preference as 'light' or 'dark'.
6
+ */
7
+ export function useColorScheme(): () => "light" | "dark" {
8
+ const prefersDark = useMediaQuery("(prefers-color-scheme: dark)")
9
+ return computed(() => (prefersDark() ? "dark" : "light"))
10
+ }
@@ -0,0 +1,39 @@
1
+ import { signal } from "@pyreon/reactivity"
2
+
3
+ type UseControllableStateOptions<T> = {
4
+ value?: T | undefined
5
+ defaultValue: T
6
+ onChange?: ((value: T) => void) | undefined
7
+ }
8
+
9
+ export type UseControllableState = <T>(
10
+ options: UseControllableStateOptions<T>,
11
+ ) => [() => T, (next: T | ((prev: T) => T)) => void]
12
+
13
+ /**
14
+ * Unified controlled/uncontrolled state pattern.
15
+ * When `value` is provided the component is controlled; otherwise
16
+ * internal state is used with `defaultValue` as the initial value.
17
+ * The `onChange` callback fires in both modes.
18
+ *
19
+ * Returns [getter, setter] where getter is a reactive function.
20
+ */
21
+ export const useControllableState: UseControllableState = ({ value, defaultValue, onChange }) => {
22
+ const internal = signal(defaultValue)
23
+ const onChangeFn = onChange
24
+
25
+ const isControlled = value !== undefined
26
+
27
+ const getter = (): any => (isControlled ? value : internal())
28
+
29
+ const setValue = (next: any) => {
30
+ const current = isControlled ? value : internal()
31
+ const nextValue = typeof next === "function" ? next(current) : next
32
+ if (!isControlled) internal.set(nextValue)
33
+ onChangeFn?.(nextValue)
34
+ }
35
+
36
+ return [getter, setValue]
37
+ }
38
+
39
+ export default useControllableState
@@ -0,0 +1,57 @@
1
+ import { onUnmount } from "@pyreon/core"
2
+
3
+ type DebouncedFn<T extends (...args: any[]) => any> = {
4
+ (...args: Parameters<T>): void
5
+ cancel: () => void
6
+ flush: () => void
7
+ }
8
+
9
+ export type UseDebouncedCallback = <T extends (...args: any[]) => any>(
10
+ callback: T,
11
+ delay: number,
12
+ ) => DebouncedFn<T>
13
+
14
+ /**
15
+ * Returns a debounced version of the callback.
16
+ * The returned function has `.cancel()` and `.flush()` methods.
17
+ * Always calls the latest callback (no stale closures).
18
+ * Cleans up on unmount.
19
+ */
20
+ export const useDebouncedCallback: UseDebouncedCallback = (callback, delay) => {
21
+ const currentCallback = callback
22
+ let timer: ReturnType<typeof setTimeout> | null = null
23
+ let lastArgs: any[] | null = null
24
+
25
+ const cancel = () => {
26
+ if (timer != null) {
27
+ clearTimeout(timer)
28
+ timer = null
29
+ }
30
+ lastArgs = null
31
+ }
32
+
33
+ const flush = () => {
34
+ if (timer != null && lastArgs != null) {
35
+ clearTimeout(timer)
36
+ timer = null
37
+ currentCallback(...lastArgs)
38
+ lastArgs = null
39
+ }
40
+ }
41
+
42
+ const debounced = (...args: any[]) => {
43
+ lastArgs = args
44
+ if (timer != null) clearTimeout(timer)
45
+ timer = setTimeout(() => {
46
+ timer = null
47
+ currentCallback(...args)
48
+ lastArgs = null
49
+ }, delay)
50
+ }
51
+
52
+ onUnmount(() => cancel())
53
+
54
+ return Object.assign(debounced, { cancel, flush }) as DebouncedFn<typeof callback>
55
+ }
56
+
57
+ export default useDebouncedCallback