@pyreon/hooks 0.11.1 → 0.11.3
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/hooks",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/pyreon/pyreon",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"!lib/**/*.map",
|
|
25
25
|
"!lib/analysis",
|
|
26
26
|
"README.md",
|
|
27
|
-
"LICENSE"
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"src"
|
|
28
29
|
],
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">= 22"
|
|
@@ -43,13 +44,13 @@
|
|
|
43
44
|
"typecheck": "tsc --noEmit"
|
|
44
45
|
},
|
|
45
46
|
"peerDependencies": {
|
|
46
|
-
"@pyreon/core": "^0.11.
|
|
47
|
-
"@pyreon/reactivity": "^0.11.
|
|
48
|
-
"@pyreon/styler": "^0.11.
|
|
49
|
-
"@pyreon/ui-core": "^0.11.
|
|
47
|
+
"@pyreon/core": "^0.11.3",
|
|
48
|
+
"@pyreon/reactivity": "^0.11.3",
|
|
49
|
+
"@pyreon/styler": "^0.11.3",
|
|
50
|
+
"@pyreon/ui-core": "^0.11.3"
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
53
|
"@vitus-labs/tools-rolldown": "^1.15.3",
|
|
53
|
-
"@pyreon/typescript": "^0.11.
|
|
54
|
+
"@pyreon/typescript": "^0.11.3"
|
|
54
55
|
}
|
|
55
56
|
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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 { useBreakpoint } from "../useBreakpoint"
|
|
16
|
+
|
|
17
|
+
describe("useBreakpoint", () => {
|
|
18
|
+
const originalInnerWidth = window.innerWidth
|
|
19
|
+
let rafCallback: FrameRequestCallback | undefined
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mountCallbacks = []
|
|
23
|
+
unmountCallbacks = []
|
|
24
|
+
rafCallback = undefined
|
|
25
|
+
|
|
26
|
+
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
|
27
|
+
rafCallback = cb
|
|
28
|
+
return 1
|
|
29
|
+
})
|
|
30
|
+
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {
|
|
31
|
+
/* no-op */
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: originalInnerWidth })
|
|
37
|
+
vi.restoreAllMocks()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("returns the active breakpoint based on window width", () => {
|
|
41
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 800 })
|
|
42
|
+
const bp = useBreakpoint()
|
|
43
|
+
expect(bp()).toBe("md") // 768 <= 800 < 992
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("uses default breakpoints", () => {
|
|
47
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 400 })
|
|
48
|
+
const bp = useBreakpoint()
|
|
49
|
+
expect(bp()).toBe("xs") // 0 <= 400 < 576
|
|
50
|
+
|
|
51
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 600 })
|
|
52
|
+
const bp2 = useBreakpoint()
|
|
53
|
+
expect(bp2()).toBe("sm") // 576 <= 600 < 768
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("supports custom breakpoints", () => {
|
|
57
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 500 })
|
|
58
|
+
const bp = useBreakpoint({ mobile: 0, tablet: 600, desktop: 1024 })
|
|
59
|
+
expect(bp()).toBe("mobile")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("returns largest matching breakpoint", () => {
|
|
63
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 1200 })
|
|
64
|
+
const bp = useBreakpoint()
|
|
65
|
+
expect(bp()).toBe("xl")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("returns first breakpoint for very small widths", () => {
|
|
69
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 0 })
|
|
70
|
+
const bp = useBreakpoint()
|
|
71
|
+
expect(bp()).toBe("xs")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("updates breakpoint on resize", () => {
|
|
75
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 800 })
|
|
76
|
+
const bp = useBreakpoint()
|
|
77
|
+
mountCallbacks.forEach((cb) => {
|
|
78
|
+
cb()
|
|
79
|
+
})
|
|
80
|
+
expect(bp()).toBe("md")
|
|
81
|
+
|
|
82
|
+
// Simulate resize
|
|
83
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 1200 })
|
|
84
|
+
window.dispatchEvent(new Event("resize"))
|
|
85
|
+
|
|
86
|
+
// Execute rAF callback
|
|
87
|
+
rafCallback?.(0)
|
|
88
|
+
expect(bp()).toBe("xl")
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("debounces resize with requestAnimationFrame", () => {
|
|
92
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 800 })
|
|
93
|
+
const bp = useBreakpoint()
|
|
94
|
+
mountCallbacks.forEach((cb) => {
|
|
95
|
+
cb()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 1200 })
|
|
99
|
+
window.dispatchEvent(new Event("resize"))
|
|
100
|
+
|
|
101
|
+
// Before rAF fires, value should still be old
|
|
102
|
+
expect(bp()).toBe("md")
|
|
103
|
+
|
|
104
|
+
rafCallback?.(0)
|
|
105
|
+
expect(bp()).toBe("xl")
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("cancels pending rAF on new resize", () => {
|
|
109
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 800 })
|
|
110
|
+
useBreakpoint()
|
|
111
|
+
mountCallbacks.forEach((cb) => {
|
|
112
|
+
cb()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
window.dispatchEvent(new Event("resize"))
|
|
116
|
+
window.dispatchEvent(new Event("resize"))
|
|
117
|
+
|
|
118
|
+
expect(window.cancelAnimationFrame).toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("cleans up on unmount", () => {
|
|
122
|
+
const removeSpy = vi.spyOn(window, "removeEventListener")
|
|
123
|
+
useBreakpoint()
|
|
124
|
+
mountCallbacks.forEach((cb) => {
|
|
125
|
+
cb()
|
|
126
|
+
})
|
|
127
|
+
unmountCallbacks.forEach((cb) => {
|
|
128
|
+
cb()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("does not update when breakpoint has not changed", () => {
|
|
135
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 800 })
|
|
136
|
+
const bp = useBreakpoint()
|
|
137
|
+
mountCallbacks.forEach((cb) => {
|
|
138
|
+
cb()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Resize but stay within same breakpoint
|
|
142
|
+
Object.defineProperty(window, "innerWidth", { writable: true, value: 850 })
|
|
143
|
+
window.dispatchEvent(new Event("resize"))
|
|
144
|
+
rafCallback?.(0)
|
|
145
|
+
|
|
146
|
+
expect(bp()).toBe("md") // Still md (768-991)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
|
|
3
|
+
// Capture registered callbacks so we can invoke lifecycle manually
|
|
4
|
+
let mountCallbacks: Array<() => void> = []
|
|
5
|
+
let unmountCallbacks: Array<() => void> = []
|
|
6
|
+
|
|
7
|
+
vi.mock("@pyreon/core", () => ({
|
|
8
|
+
onMount: (fn: () => unknown) => {
|
|
9
|
+
mountCallbacks.push(fn as () => void)
|
|
10
|
+
},
|
|
11
|
+
onUnmount: (fn: () => void) => {
|
|
12
|
+
unmountCallbacks.push(fn)
|
|
13
|
+
},
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
import { useClickOutside } from "../useClickOutside"
|
|
17
|
+
|
|
18
|
+
describe("useClickOutside", () => {
|
|
19
|
+
let container: HTMLDivElement
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
mountCallbacks = []
|
|
23
|
+
unmountCallbacks = []
|
|
24
|
+
container = document.createElement("div")
|
|
25
|
+
document.body.appendChild(container)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
document.body.removeChild(container)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("calls handler when clicking outside the element", () => {
|
|
33
|
+
const handler = vi.fn()
|
|
34
|
+
useClickOutside(() => container, handler)
|
|
35
|
+
|
|
36
|
+
// Simulate mount
|
|
37
|
+
mountCallbacks.forEach((cb) => {
|
|
38
|
+
cb()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const outside = document.createElement("div")
|
|
42
|
+
document.body.appendChild(outside)
|
|
43
|
+
|
|
44
|
+
const event = new MouseEvent("mousedown", { bubbles: true })
|
|
45
|
+
Object.defineProperty(event, "target", { value: outside })
|
|
46
|
+
document.dispatchEvent(event)
|
|
47
|
+
|
|
48
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
49
|
+
document.body.removeChild(outside)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("does not call handler when clicking inside the element", () => {
|
|
53
|
+
const handler = vi.fn()
|
|
54
|
+
const child = document.createElement("span")
|
|
55
|
+
container.appendChild(child)
|
|
56
|
+
|
|
57
|
+
useClickOutside(() => container, handler)
|
|
58
|
+
mountCallbacks.forEach((cb) => {
|
|
59
|
+
cb()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const event = new MouseEvent("mousedown", { bubbles: true })
|
|
63
|
+
Object.defineProperty(event, "target", { value: child })
|
|
64
|
+
document.dispatchEvent(event)
|
|
65
|
+
|
|
66
|
+
expect(handler).not.toHaveBeenCalled()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("does not call handler when clicking the element itself", () => {
|
|
70
|
+
const handler = vi.fn()
|
|
71
|
+
useClickOutside(() => container, handler)
|
|
72
|
+
mountCallbacks.forEach((cb) => {
|
|
73
|
+
cb()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const event = new MouseEvent("mousedown", { bubbles: true })
|
|
77
|
+
Object.defineProperty(event, "target", { value: container })
|
|
78
|
+
document.dispatchEvent(event)
|
|
79
|
+
|
|
80
|
+
expect(handler).not.toHaveBeenCalled()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("does not call handler when element is null", () => {
|
|
84
|
+
const handler = vi.fn()
|
|
85
|
+
useClickOutside(() => null, handler)
|
|
86
|
+
mountCallbacks.forEach((cb) => {
|
|
87
|
+
cb()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const event = new MouseEvent("mousedown", { bubbles: true })
|
|
91
|
+
Object.defineProperty(event, "target", { value: document.body })
|
|
92
|
+
document.dispatchEvent(event)
|
|
93
|
+
|
|
94
|
+
expect(handler).not.toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("handles touchstart events", () => {
|
|
98
|
+
const handler = vi.fn()
|
|
99
|
+
useClickOutside(() => container, handler)
|
|
100
|
+
mountCallbacks.forEach((cb) => {
|
|
101
|
+
cb()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const outside = document.createElement("div")
|
|
105
|
+
document.body.appendChild(outside)
|
|
106
|
+
|
|
107
|
+
const event = new Event("touchstart", { bubbles: true })
|
|
108
|
+
Object.defineProperty(event, "target", { value: outside })
|
|
109
|
+
document.dispatchEvent(event)
|
|
110
|
+
|
|
111
|
+
expect(handler).toHaveBeenCalledTimes(1)
|
|
112
|
+
document.body.removeChild(outside)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("removes listeners on unmount", () => {
|
|
116
|
+
const handler = vi.fn()
|
|
117
|
+
useClickOutside(() => container, handler)
|
|
118
|
+
mountCallbacks.forEach((cb) => {
|
|
119
|
+
cb()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const removeSpy = vi.spyOn(document, "removeEventListener")
|
|
123
|
+
unmountCallbacks.forEach((cb) => {
|
|
124
|
+
cb()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
expect(removeSpy).toHaveBeenCalledWith("mousedown", expect.any(Function), true)
|
|
128
|
+
expect(removeSpy).toHaveBeenCalledWith("touchstart", expect.any(Function), true)
|
|
129
|
+
removeSpy.mockRestore()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("adds listeners with capture phase", () => {
|
|
133
|
+
const handler = vi.fn()
|
|
134
|
+
const addSpy = vi.spyOn(document, "addEventListener")
|
|
135
|
+
useClickOutside(() => container, handler)
|
|
136
|
+
mountCallbacks.forEach((cb) => {
|
|
137
|
+
cb()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
expect(addSpy).toHaveBeenCalledWith("mousedown", expect.any(Function), true)
|
|
141
|
+
expect(addSpy).toHaveBeenCalledWith("touchstart", expect.any(Function), true)
|
|
142
|
+
addSpy.mockRestore()
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { 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 { useColorScheme } from "../useColorScheme"
|
|
16
|
+
|
|
17
|
+
describe("useColorScheme", () => {
|
|
18
|
+
let changeListeners: Map<string, (e: MediaQueryListEvent) => void>
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
mountCallbacks = []
|
|
22
|
+
unmountCallbacks = []
|
|
23
|
+
changeListeners = new Map()
|
|
24
|
+
|
|
25
|
+
Object.defineProperty(window, "matchMedia", {
|
|
26
|
+
writable: true,
|
|
27
|
+
value: vi.fn((query: string) => ({
|
|
28
|
+
matches: false,
|
|
29
|
+
media: query,
|
|
30
|
+
addEventListener: vi.fn((event: string, cb: (e: MediaQueryListEvent) => void) => {
|
|
31
|
+
if (event === "change") changeListeners.set(query, cb)
|
|
32
|
+
}),
|
|
33
|
+
removeEventListener: vi.fn(),
|
|
34
|
+
})),
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("returns light by default", () => {
|
|
39
|
+
const scheme = useColorScheme()
|
|
40
|
+
mountCallbacks.forEach((cb) => {
|
|
41
|
+
cb()
|
|
42
|
+
})
|
|
43
|
+
expect(scheme()).toBe("light")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("returns dark when prefers-color-scheme is dark", () => {
|
|
47
|
+
Object.defineProperty(window, "matchMedia", {
|
|
48
|
+
writable: true,
|
|
49
|
+
value: vi.fn((query: string) => ({
|
|
50
|
+
matches: true,
|
|
51
|
+
media: query,
|
|
52
|
+
addEventListener: vi.fn((event: string, cb: (e: MediaQueryListEvent) => void) => {
|
|
53
|
+
if (event === "change") changeListeners.set(query, cb)
|
|
54
|
+
}),
|
|
55
|
+
removeEventListener: vi.fn(),
|
|
56
|
+
})),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const scheme = useColorScheme()
|
|
60
|
+
mountCallbacks.forEach((cb) => {
|
|
61
|
+
cb()
|
|
62
|
+
})
|
|
63
|
+
expect(scheme()).toBe("dark")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("updates when color scheme changes from light to dark", () => {
|
|
67
|
+
const scheme = useColorScheme()
|
|
68
|
+
mountCallbacks.forEach((cb) => {
|
|
69
|
+
cb()
|
|
70
|
+
})
|
|
71
|
+
expect(scheme()).toBe("light")
|
|
72
|
+
|
|
73
|
+
const listener = changeListeners.get("(prefers-color-scheme: dark)")
|
|
74
|
+
listener?.({ matches: true } as MediaQueryListEvent)
|
|
75
|
+
expect(scheme()).toBe("dark")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("updates when color scheme changes from dark to light", () => {
|
|
79
|
+
Object.defineProperty(window, "matchMedia", {
|
|
80
|
+
writable: true,
|
|
81
|
+
value: vi.fn((query: string) => ({
|
|
82
|
+
matches: true,
|
|
83
|
+
media: query,
|
|
84
|
+
addEventListener: vi.fn((event: string, cb: (e: MediaQueryListEvent) => void) => {
|
|
85
|
+
if (event === "change") changeListeners.set(query, cb)
|
|
86
|
+
}),
|
|
87
|
+
removeEventListener: vi.fn(),
|
|
88
|
+
})),
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const scheme = useColorScheme()
|
|
92
|
+
mountCallbacks.forEach((cb) => {
|
|
93
|
+
cb()
|
|
94
|
+
})
|
|
95
|
+
expect(scheme()).toBe("dark")
|
|
96
|
+
|
|
97
|
+
const listener = changeListeners.get("(prefers-color-scheme: dark)")
|
|
98
|
+
listener?.({ matches: false } as MediaQueryListEvent)
|
|
99
|
+
expect(scheme()).toBe("light")
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { useControllableState } from "../useControllableState"
|
|
3
|
+
|
|
4
|
+
describe("useControllableState", () => {
|
|
5
|
+
it("uses defaultValue when uncontrolled", () => {
|
|
6
|
+
const [value] = useControllableState({ defaultValue: "hello" })
|
|
7
|
+
expect(value()).toBe("hello")
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("updates internal state when uncontrolled", () => {
|
|
11
|
+
const [value, setValue] = useControllableState({ defaultValue: 0 })
|
|
12
|
+
setValue(5)
|
|
13
|
+
expect(value()).toBe(5)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("uses value when controlled", () => {
|
|
17
|
+
const [value] = useControllableState({ value: "controlled", defaultValue: "default" })
|
|
18
|
+
expect(value()).toBe("controlled")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("does not update internal state when controlled", () => {
|
|
22
|
+
const onChange = vi.fn()
|
|
23
|
+
const [value, setValue] = useControllableState({
|
|
24
|
+
value: "controlled",
|
|
25
|
+
defaultValue: "default",
|
|
26
|
+
onChange,
|
|
27
|
+
})
|
|
28
|
+
setValue("new")
|
|
29
|
+
expect(value()).toBe("controlled")
|
|
30
|
+
expect(onChange).toHaveBeenCalledWith("new")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("calls onChange in uncontrolled mode", () => {
|
|
34
|
+
const onChange = vi.fn()
|
|
35
|
+
const [, setValue] = useControllableState({ defaultValue: 0, onChange })
|
|
36
|
+
setValue(10)
|
|
37
|
+
expect(onChange).toHaveBeenCalledWith(10)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("supports updater function", () => {
|
|
41
|
+
const [value, setValue] = useControllableState({ defaultValue: 1 })
|
|
42
|
+
setValue((prev: number) => prev + 1)
|
|
43
|
+
expect(value()).toBe(2)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { useDebouncedCallback } from "../useDebouncedCallback"
|
|
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("useDebouncedCallback", () => {
|
|
13
|
+
beforeEach(() => vi.useFakeTimers())
|
|
14
|
+
afterEach(() => vi.useRealTimers())
|
|
15
|
+
|
|
16
|
+
it("debounces the callback", () => {
|
|
17
|
+
const fn = vi.fn()
|
|
18
|
+
const debounced = useDebouncedCallback(fn, 100)
|
|
19
|
+
|
|
20
|
+
debounced("a")
|
|
21
|
+
debounced("b")
|
|
22
|
+
debounced("c")
|
|
23
|
+
|
|
24
|
+
expect(fn).not.toHaveBeenCalled()
|
|
25
|
+
vi.advanceTimersByTime(100)
|
|
26
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
27
|
+
expect(fn).toHaveBeenCalledWith("c")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it("cancel prevents the callback", () => {
|
|
31
|
+
const fn = vi.fn()
|
|
32
|
+
const debounced = useDebouncedCallback(fn, 100)
|
|
33
|
+
|
|
34
|
+
debounced("a")
|
|
35
|
+
debounced.cancel()
|
|
36
|
+
vi.advanceTimersByTime(200)
|
|
37
|
+
|
|
38
|
+
expect(fn).not.toHaveBeenCalled()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("flush invokes immediately", () => {
|
|
42
|
+
const fn = vi.fn()
|
|
43
|
+
const debounced = useDebouncedCallback(fn, 100)
|
|
44
|
+
|
|
45
|
+
debounced("x")
|
|
46
|
+
debounced.flush()
|
|
47
|
+
|
|
48
|
+
expect(fn).toHaveBeenCalledWith("x")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("flush is a no-op when no pending timer", () => {
|
|
52
|
+
const fn = vi.fn()
|
|
53
|
+
const debounced = useDebouncedCallback(fn, 100)
|
|
54
|
+
|
|
55
|
+
debounced.flush()
|
|
56
|
+
expect(fn).not.toHaveBeenCalled()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("flush is a no-op after timer already fired", () => {
|
|
60
|
+
const fn = vi.fn()
|
|
61
|
+
const debounced = useDebouncedCallback(fn, 100)
|
|
62
|
+
|
|
63
|
+
debounced("a")
|
|
64
|
+
vi.advanceTimersByTime(100)
|
|
65
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
66
|
+
|
|
67
|
+
debounced.flush()
|
|
68
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("flush is a no-op after cancel", () => {
|
|
72
|
+
const fn = vi.fn()
|
|
73
|
+
const debounced = useDebouncedCallback(fn, 100)
|
|
74
|
+
|
|
75
|
+
debounced("a")
|
|
76
|
+
debounced.cancel()
|
|
77
|
+
debounced.flush()
|
|
78
|
+
expect(fn).not.toHaveBeenCalled()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { signal } from "@pyreon/reactivity"
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { useDebouncedValue } from "../useDebouncedValue"
|
|
4
|
+
|
|
5
|
+
// Mock onUnmount since it requires component lifecycle context
|
|
6
|
+
vi.mock("@pyreon/core", () => ({
|
|
7
|
+
onMount: (fn: () => void) => fn(),
|
|
8
|
+
onUnmount: (_fn: () => void) => {
|
|
9
|
+
/* no-op */
|
|
10
|
+
},
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
describe("useDebouncedValue", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.useFakeTimers()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.useRealTimers()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("returns the initial value immediately", () => {
|
|
23
|
+
const source = signal(42)
|
|
24
|
+
const debounced = useDebouncedValue(source, 300)
|
|
25
|
+
expect(debounced()).toBe(42)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("does not update immediately when source changes", () => {
|
|
29
|
+
const source = signal("hello")
|
|
30
|
+
const debounced = useDebouncedValue(source, 300)
|
|
31
|
+
expect(debounced()).toBe("hello")
|
|
32
|
+
|
|
33
|
+
source.set("world")
|
|
34
|
+
expect(debounced()).toBe("hello")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it("updates after the delay elapses", () => {
|
|
38
|
+
const source = signal("hello")
|
|
39
|
+
const debounced = useDebouncedValue(source, 300)
|
|
40
|
+
|
|
41
|
+
source.set("world")
|
|
42
|
+
expect(debounced()).toBe("hello")
|
|
43
|
+
|
|
44
|
+
vi.advanceTimersByTime(300)
|
|
45
|
+
expect(debounced()).toBe("world")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("resets the timer on rapid changes", () => {
|
|
49
|
+
const source = signal(1)
|
|
50
|
+
const debounced = useDebouncedValue(source, 300)
|
|
51
|
+
|
|
52
|
+
source.set(2)
|
|
53
|
+
vi.advanceTimersByTime(100)
|
|
54
|
+
expect(debounced()).toBe(1)
|
|
55
|
+
|
|
56
|
+
source.set(3)
|
|
57
|
+
vi.advanceTimersByTime(100)
|
|
58
|
+
expect(debounced()).toBe(1)
|
|
59
|
+
|
|
60
|
+
source.set(4)
|
|
61
|
+
vi.advanceTimersByTime(300)
|
|
62
|
+
expect(debounced()).toBe(4)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("only applies the last value after debounce", () => {
|
|
66
|
+
const source = signal(0)
|
|
67
|
+
const debounced = useDebouncedValue(source, 200)
|
|
68
|
+
|
|
69
|
+
source.set(1)
|
|
70
|
+
source.set(2)
|
|
71
|
+
source.set(3)
|
|
72
|
+
source.set(4)
|
|
73
|
+
source.set(5)
|
|
74
|
+
|
|
75
|
+
vi.advanceTimersByTime(200)
|
|
76
|
+
expect(debounced()).toBe(5)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("handles multiple debounce cycles", () => {
|
|
80
|
+
const source = signal("a")
|
|
81
|
+
const debounced = useDebouncedValue(source, 100)
|
|
82
|
+
|
|
83
|
+
source.set("b")
|
|
84
|
+
vi.advanceTimersByTime(100)
|
|
85
|
+
expect(debounced()).toBe("b")
|
|
86
|
+
|
|
87
|
+
source.set("c")
|
|
88
|
+
vi.advanceTimersByTime(100)
|
|
89
|
+
expect(debounced()).toBe("c")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("works with zero delay", () => {
|
|
93
|
+
const source = signal(1)
|
|
94
|
+
const debounced = useDebouncedValue(source, 0)
|
|
95
|
+
|
|
96
|
+
source.set(2)
|
|
97
|
+
vi.advanceTimersByTime(0)
|
|
98
|
+
expect(debounced()).toBe(2)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("works with object values", () => {
|
|
102
|
+
const obj1 = { name: "Alice" }
|
|
103
|
+
const obj2 = { name: "Bob" }
|
|
104
|
+
const source = signal(obj1)
|
|
105
|
+
const debounced = useDebouncedValue(source, 100)
|
|
106
|
+
expect(debounced()).toBe(obj1)
|
|
107
|
+
|
|
108
|
+
source.set(obj2)
|
|
109
|
+
vi.advanceTimersByTime(100)
|
|
110
|
+
expect(debounced()).toBe(obj2)
|
|
111
|
+
})
|
|
112
|
+
})
|