@pyreon/hooks 0.11.1 → 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 +8 -7
- 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,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
|