@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,190 @@
|
|
|
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 { useElementSize } from "../useElementSize"
|
|
16
|
+
|
|
17
|
+
describe("useElementSize", () => {
|
|
18
|
+
let resizeCallback: ((entries: ResizeObserverEntry[]) => void) | undefined
|
|
19
|
+
let observeSpy: ReturnType<typeof vi.fn>
|
|
20
|
+
let disconnectSpy: ReturnType<typeof vi.fn>
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
mountCallbacks = []
|
|
24
|
+
unmountCallbacks = []
|
|
25
|
+
resizeCallback = undefined
|
|
26
|
+
observeSpy = vi.fn()
|
|
27
|
+
disconnectSpy = vi.fn()
|
|
28
|
+
|
|
29
|
+
globalThis.ResizeObserver = vi.fn(function (this: unknown, cb: ResizeObserverCallback) {
|
|
30
|
+
resizeCallback = cb as (entries: ResizeObserverEntry[]) => void
|
|
31
|
+
return {
|
|
32
|
+
observe: observeSpy,
|
|
33
|
+
unobserve: vi.fn(),
|
|
34
|
+
disconnect: disconnectSpy,
|
|
35
|
+
}
|
|
36
|
+
}) as unknown as typeof ResizeObserver
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("returns initial size of 0x0", () => {
|
|
40
|
+
const size = useElementSize(() => null)
|
|
41
|
+
expect(size().width).toBe(0)
|
|
42
|
+
expect(size().height).toBe(0)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("measures initial element size on mount", () => {
|
|
46
|
+
const el = document.createElement("div")
|
|
47
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
|
48
|
+
width: 200,
|
|
49
|
+
height: 100,
|
|
50
|
+
x: 0,
|
|
51
|
+
y: 0,
|
|
52
|
+
top: 0,
|
|
53
|
+
left: 0,
|
|
54
|
+
bottom: 100,
|
|
55
|
+
right: 200,
|
|
56
|
+
toJSON: () => {
|
|
57
|
+
/* no-op */
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const size = useElementSize(() => el)
|
|
62
|
+
mountCallbacks.forEach((cb) => {
|
|
63
|
+
cb()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(size().width).toBe(200)
|
|
67
|
+
expect(size().height).toBe(100)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("observes the element with ResizeObserver", () => {
|
|
71
|
+
const el = document.createElement("div")
|
|
72
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
|
73
|
+
width: 0,
|
|
74
|
+
height: 0,
|
|
75
|
+
x: 0,
|
|
76
|
+
y: 0,
|
|
77
|
+
top: 0,
|
|
78
|
+
left: 0,
|
|
79
|
+
bottom: 0,
|
|
80
|
+
right: 0,
|
|
81
|
+
toJSON: () => {
|
|
82
|
+
/* no-op */
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
useElementSize(() => el)
|
|
87
|
+
mountCallbacks.forEach((cb) => {
|
|
88
|
+
cb()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(observeSpy).toHaveBeenCalledWith(el)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("updates size when ResizeObserver fires", () => {
|
|
95
|
+
const el = document.createElement("div")
|
|
96
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
|
97
|
+
width: 100,
|
|
98
|
+
height: 50,
|
|
99
|
+
x: 0,
|
|
100
|
+
y: 0,
|
|
101
|
+
top: 0,
|
|
102
|
+
left: 0,
|
|
103
|
+
bottom: 50,
|
|
104
|
+
right: 100,
|
|
105
|
+
toJSON: () => {
|
|
106
|
+
/* no-op */
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const size = useElementSize(() => el)
|
|
111
|
+
mountCallbacks.forEach((cb) => {
|
|
112
|
+
cb()
|
|
113
|
+
})
|
|
114
|
+
expect(size().width).toBe(100)
|
|
115
|
+
|
|
116
|
+
// Simulate resize
|
|
117
|
+
resizeCallback?.([
|
|
118
|
+
{
|
|
119
|
+
contentRect: { width: 300, height: 150 },
|
|
120
|
+
} as unknown as ResizeObserverEntry,
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
expect(size().width).toBe(300)
|
|
124
|
+
expect(size().height).toBe(150)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("does nothing on mount when element is null", () => {
|
|
128
|
+
useElementSize(() => null)
|
|
129
|
+
mountCallbacks.forEach((cb) => {
|
|
130
|
+
cb()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
expect(observeSpy).not.toHaveBeenCalled()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("disconnects ResizeObserver on unmount", () => {
|
|
137
|
+
const el = document.createElement("div")
|
|
138
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
|
139
|
+
width: 0,
|
|
140
|
+
height: 0,
|
|
141
|
+
x: 0,
|
|
142
|
+
y: 0,
|
|
143
|
+
top: 0,
|
|
144
|
+
left: 0,
|
|
145
|
+
bottom: 0,
|
|
146
|
+
right: 0,
|
|
147
|
+
toJSON: () => {
|
|
148
|
+
/* no-op */
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
useElementSize(() => el)
|
|
153
|
+
mountCallbacks.forEach((cb) => {
|
|
154
|
+
cb()
|
|
155
|
+
})
|
|
156
|
+
unmountCallbacks.forEach((cb) => {
|
|
157
|
+
cb()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
expect(disconnectSpy).toHaveBeenCalled()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("handles ResizeObserver callback with no entry", () => {
|
|
164
|
+
const el = document.createElement("div")
|
|
165
|
+
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
|
166
|
+
width: 50,
|
|
167
|
+
height: 25,
|
|
168
|
+
x: 0,
|
|
169
|
+
y: 0,
|
|
170
|
+
top: 0,
|
|
171
|
+
left: 0,
|
|
172
|
+
bottom: 25,
|
|
173
|
+
right: 50,
|
|
174
|
+
toJSON: () => {
|
|
175
|
+
/* no-op */
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const size = useElementSize(() => el)
|
|
180
|
+
mountCallbacks.forEach((cb) => {
|
|
181
|
+
cb()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// Empty entries array
|
|
185
|
+
resizeCallback?.([])
|
|
186
|
+
// Should keep initial measurement
|
|
187
|
+
expect(size().width).toBe(50)
|
|
188
|
+
expect(size().height).toBe(25)
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { useFocus } from "../useFocus"
|
|
3
|
+
|
|
4
|
+
describe("useFocus", () => {
|
|
5
|
+
it("initializes with focused=false", () => {
|
|
6
|
+
const { focused } = useFocus()
|
|
7
|
+
expect(focused()).toBe(false)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("sets focused to true on focus", () => {
|
|
11
|
+
const { focused, props } = useFocus()
|
|
12
|
+
props.onFocus()
|
|
13
|
+
expect(focused()).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("sets focused to false on blur", () => {
|
|
17
|
+
const { focused, props } = useFocus()
|
|
18
|
+
props.onFocus()
|
|
19
|
+
expect(focused()).toBe(true)
|
|
20
|
+
props.onBlur()
|
|
21
|
+
expect(focused()).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("toggles focus state through multiple cycles", () => {
|
|
25
|
+
const { focused, props } = useFocus()
|
|
26
|
+
props.onFocus()
|
|
27
|
+
expect(focused()).toBe(true)
|
|
28
|
+
props.onBlur()
|
|
29
|
+
expect(focused()).toBe(false)
|
|
30
|
+
props.onFocus()
|
|
31
|
+
expect(focused()).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("calling blur when not focused is safe", () => {
|
|
35
|
+
const { focused, props } = useFocus()
|
|
36
|
+
props.onBlur()
|
|
37
|
+
expect(focused()).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("calling focus multiple times stays true", () => {
|
|
41
|
+
const { focused, props } = useFocus()
|
|
42
|
+
props.onFocus()
|
|
43
|
+
props.onFocus()
|
|
44
|
+
expect(focused()).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("returns props object with onFocus and onBlur", () => {
|
|
48
|
+
const { props } = useFocus()
|
|
49
|
+
expect(typeof props.onFocus).toBe("function")
|
|
50
|
+
expect(typeof props.onBlur).toBe("function")
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
|
|
3
|
+
let mountCallbacks: Array<() => void> = []
|
|
4
|
+
let unmountCallbacks: Array<() => void> = []
|
|
5
|
+
|
|
6
|
+
vi.mock("@pyreon/core", () => ({
|
|
7
|
+
onMount: (fn: () => unknown) => {
|
|
8
|
+
mountCallbacks.push(fn as () => void)
|
|
9
|
+
},
|
|
10
|
+
onUnmount: (fn: () => void) => {
|
|
11
|
+
unmountCallbacks.push(fn)
|
|
12
|
+
},
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
import { useFocusTrap } from "../useFocusTrap"
|
|
16
|
+
|
|
17
|
+
describe("useFocusTrap", () => {
|
|
18
|
+
let container: HTMLDivElement
|
|
19
|
+
let btn1: HTMLButtonElement
|
|
20
|
+
let btn2: HTMLButtonElement
|
|
21
|
+
let btn3: HTMLButtonElement
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
mountCallbacks = []
|
|
25
|
+
unmountCallbacks = []
|
|
26
|
+
container = document.createElement("div")
|
|
27
|
+
btn1 = document.createElement("button")
|
|
28
|
+
btn2 = document.createElement("button")
|
|
29
|
+
btn3 = document.createElement("button")
|
|
30
|
+
container.append(btn1, btn2, btn3)
|
|
31
|
+
document.body.appendChild(container)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
document.body.removeChild(container)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("wraps focus from last to first on Tab", () => {
|
|
39
|
+
useFocusTrap(() => container)
|
|
40
|
+
mountCallbacks.forEach((cb) => {
|
|
41
|
+
cb()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
btn3.focus()
|
|
45
|
+
const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true })
|
|
46
|
+
const prevented = vi.spyOn(event, "preventDefault")
|
|
47
|
+
document.dispatchEvent(event)
|
|
48
|
+
|
|
49
|
+
expect(prevented).toHaveBeenCalled()
|
|
50
|
+
expect(document.activeElement).toBe(btn1)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("wraps focus from first to last on Shift+Tab", () => {
|
|
54
|
+
useFocusTrap(() => container)
|
|
55
|
+
mountCallbacks.forEach((cb) => {
|
|
56
|
+
cb()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
btn1.focus()
|
|
60
|
+
const event = new KeyboardEvent("keydown", {
|
|
61
|
+
key: "Tab",
|
|
62
|
+
shiftKey: true,
|
|
63
|
+
bubbles: true,
|
|
64
|
+
})
|
|
65
|
+
const prevented = vi.spyOn(event, "preventDefault")
|
|
66
|
+
document.dispatchEvent(event)
|
|
67
|
+
|
|
68
|
+
expect(prevented).toHaveBeenCalled()
|
|
69
|
+
expect(document.activeElement).toBe(btn3)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("does not wrap when Tab on middle element", () => {
|
|
73
|
+
useFocusTrap(() => container)
|
|
74
|
+
mountCallbacks.forEach((cb) => {
|
|
75
|
+
cb()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
btn2.focus()
|
|
79
|
+
const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true })
|
|
80
|
+
const prevented = vi.spyOn(event, "preventDefault")
|
|
81
|
+
document.dispatchEvent(event)
|
|
82
|
+
|
|
83
|
+
expect(prevented).not.toHaveBeenCalled()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("ignores non-Tab keys", () => {
|
|
87
|
+
useFocusTrap(() => container)
|
|
88
|
+
mountCallbacks.forEach((cb) => {
|
|
89
|
+
cb()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
btn3.focus()
|
|
93
|
+
const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
|
|
94
|
+
const prevented = vi.spyOn(event, "preventDefault")
|
|
95
|
+
document.dispatchEvent(event)
|
|
96
|
+
|
|
97
|
+
expect(prevented).not.toHaveBeenCalled()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("does nothing when element is null", () => {
|
|
101
|
+
useFocusTrap(() => null)
|
|
102
|
+
mountCallbacks.forEach((cb) => {
|
|
103
|
+
cb()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true })
|
|
107
|
+
const prevented = vi.spyOn(event, "preventDefault")
|
|
108
|
+
document.dispatchEvent(event)
|
|
109
|
+
|
|
110
|
+
expect(prevented).not.toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("does nothing when container has no focusable children", () => {
|
|
114
|
+
const emptyContainer = document.createElement("div")
|
|
115
|
+
emptyContainer.appendChild(document.createElement("div"))
|
|
116
|
+
document.body.appendChild(emptyContainer)
|
|
117
|
+
|
|
118
|
+
useFocusTrap(() => emptyContainer)
|
|
119
|
+
mountCallbacks.forEach((cb) => {
|
|
120
|
+
cb()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true })
|
|
124
|
+
const prevented = vi.spyOn(event, "preventDefault")
|
|
125
|
+
document.dispatchEvent(event)
|
|
126
|
+
|
|
127
|
+
expect(prevented).not.toHaveBeenCalled()
|
|
128
|
+
document.body.removeChild(emptyContainer)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("cleans up event listener on unmount", () => {
|
|
132
|
+
const removeSpy = vi.spyOn(document, "removeEventListener")
|
|
133
|
+
useFocusTrap(() => container)
|
|
134
|
+
mountCallbacks.forEach((cb) => {
|
|
135
|
+
cb()
|
|
136
|
+
})
|
|
137
|
+
unmountCallbacks.forEach((cb) => {
|
|
138
|
+
cb()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
expect(removeSpy).toHaveBeenCalledWith("keydown", expect.any(Function))
|
|
142
|
+
removeSpy.mockRestore()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("does not prevent default when Shift+Tab on non-first element", () => {
|
|
146
|
+
useFocusTrap(() => container)
|
|
147
|
+
mountCallbacks.forEach((cb) => {
|
|
148
|
+
cb()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
btn2.focus()
|
|
152
|
+
const event = new KeyboardEvent("keydown", {
|
|
153
|
+
key: "Tab",
|
|
154
|
+
shiftKey: true,
|
|
155
|
+
bubbles: true,
|
|
156
|
+
})
|
|
157
|
+
const prevented = vi.spyOn(event, "preventDefault")
|
|
158
|
+
document.dispatchEvent(event)
|
|
159
|
+
|
|
160
|
+
expect(prevented).not.toHaveBeenCalled()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { useHover } from "../useHover"
|
|
3
|
+
|
|
4
|
+
describe("useHover", () => {
|
|
5
|
+
it("initializes with hovered=false", () => {
|
|
6
|
+
const { hovered } = useHover()
|
|
7
|
+
expect(hovered()).toBe(false)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("sets hovered to true on mouseEnter", () => {
|
|
11
|
+
const { hovered, props } = useHover()
|
|
12
|
+
props.onMouseEnter()
|
|
13
|
+
expect(hovered()).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("sets hovered to false on mouseLeave", () => {
|
|
17
|
+
const { hovered, props } = useHover()
|
|
18
|
+
props.onMouseEnter()
|
|
19
|
+
expect(hovered()).toBe(true)
|
|
20
|
+
props.onMouseLeave()
|
|
21
|
+
expect(hovered()).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("toggles hover state correctly through multiple cycles", () => {
|
|
25
|
+
const { hovered, props } = useHover()
|
|
26
|
+
props.onMouseEnter()
|
|
27
|
+
expect(hovered()).toBe(true)
|
|
28
|
+
props.onMouseLeave()
|
|
29
|
+
expect(hovered()).toBe(false)
|
|
30
|
+
props.onMouseEnter()
|
|
31
|
+
expect(hovered()).toBe(true)
|
|
32
|
+
props.onMouseLeave()
|
|
33
|
+
expect(hovered()).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("calling mouseLeave when already not hovered is safe", () => {
|
|
37
|
+
const { hovered, props } = useHover()
|
|
38
|
+
props.onMouseLeave()
|
|
39
|
+
expect(hovered()).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("calling mouseEnter multiple times stays true", () => {
|
|
43
|
+
const { hovered, props } = useHover()
|
|
44
|
+
props.onMouseEnter()
|
|
45
|
+
props.onMouseEnter()
|
|
46
|
+
expect(hovered()).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("returns props object with onMouseEnter and onMouseLeave", () => {
|
|
50
|
+
const { props } = useHover()
|
|
51
|
+
expect(typeof props.onMouseEnter).toBe("function")
|
|
52
|
+
expect(typeof props.onMouseLeave).toBe("function")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("returns stable handler references", () => {
|
|
56
|
+
const { props } = useHover()
|
|
57
|
+
const enter = props.onMouseEnter
|
|
58
|
+
const leave = props.onMouseLeave
|
|
59
|
+
expect(props.onMouseEnter).toBe(enter)
|
|
60
|
+
expect(props.onMouseLeave).toBe(leave)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
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 { useIntersection } from "../useIntersection"
|
|
16
|
+
|
|
17
|
+
describe("useIntersection", () => {
|
|
18
|
+
let intersectionCallback: ((entries: IntersectionObserverEntry[]) => void) | undefined
|
|
19
|
+
let observeSpy: ReturnType<typeof vi.fn>
|
|
20
|
+
let disconnectSpy: ReturnType<typeof vi.fn>
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
mountCallbacks = []
|
|
24
|
+
unmountCallbacks = []
|
|
25
|
+
intersectionCallback = undefined
|
|
26
|
+
observeSpy = vi.fn()
|
|
27
|
+
disconnectSpy = vi.fn()
|
|
28
|
+
|
|
29
|
+
globalThis.IntersectionObserver = vi.fn(function (
|
|
30
|
+
this: unknown,
|
|
31
|
+
cb: IntersectionObserverCallback,
|
|
32
|
+
options?: IntersectionObserverInit,
|
|
33
|
+
) {
|
|
34
|
+
intersectionCallback = cb as (entries: IntersectionObserverEntry[]) => void
|
|
35
|
+
return {
|
|
36
|
+
observe: observeSpy,
|
|
37
|
+
unobserve: vi.fn(),
|
|
38
|
+
disconnect: disconnectSpy,
|
|
39
|
+
root: options?.root ?? null,
|
|
40
|
+
rootMargin: options?.rootMargin ?? "0px",
|
|
41
|
+
thresholds: Array.isArray(options?.threshold)
|
|
42
|
+
? options.threshold
|
|
43
|
+
: [options?.threshold ?? 0],
|
|
44
|
+
takeRecords: vi.fn(() => []),
|
|
45
|
+
}
|
|
46
|
+
}) as unknown as typeof IntersectionObserver
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("returns null initially", () => {
|
|
50
|
+
const entry = useIntersection(() => null)
|
|
51
|
+
expect(entry()).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("observes the element on mount", () => {
|
|
55
|
+
const el = document.createElement("div")
|
|
56
|
+
useIntersection(() => el)
|
|
57
|
+
mountCallbacks.forEach((cb) => {
|
|
58
|
+
cb()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
expect(observeSpy).toHaveBeenCalledWith(el)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("does not observe when element is null", () => {
|
|
65
|
+
useIntersection(() => null)
|
|
66
|
+
mountCallbacks.forEach((cb) => {
|
|
67
|
+
cb()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(observeSpy).not.toHaveBeenCalled()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("updates entry when intersection changes", () => {
|
|
74
|
+
const el = document.createElement("div")
|
|
75
|
+
const entrySignal = useIntersection(() => el)
|
|
76
|
+
mountCallbacks.forEach((cb) => {
|
|
77
|
+
cb()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const mockEntry = {
|
|
81
|
+
isIntersecting: true,
|
|
82
|
+
intersectionRatio: 0.5,
|
|
83
|
+
target: el,
|
|
84
|
+
boundingClientRect: el.getBoundingClientRect(),
|
|
85
|
+
intersectionRect: el.getBoundingClientRect(),
|
|
86
|
+
rootBounds: null,
|
|
87
|
+
time: Date.now(),
|
|
88
|
+
} as unknown as IntersectionObserverEntry
|
|
89
|
+
|
|
90
|
+
intersectionCallback?.([mockEntry])
|
|
91
|
+
expect(entrySignal()).toBe(mockEntry)
|
|
92
|
+
expect(entrySignal()?.isIntersecting).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("passes options to IntersectionObserver", () => {
|
|
96
|
+
const el = document.createElement("div")
|
|
97
|
+
const options = { threshold: 0.5, rootMargin: "10px" }
|
|
98
|
+
useIntersection(() => el, options)
|
|
99
|
+
mountCallbacks.forEach((cb) => {
|
|
100
|
+
cb()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(IntersectionObserver).toHaveBeenCalledWith(expect.any(Function), options)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("disconnects observer on unmount", () => {
|
|
107
|
+
const el = document.createElement("div")
|
|
108
|
+
useIntersection(() => el)
|
|
109
|
+
mountCallbacks.forEach((cb) => {
|
|
110
|
+
cb()
|
|
111
|
+
})
|
|
112
|
+
unmountCallbacks.forEach((cb) => {
|
|
113
|
+
cb()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(disconnectSpy).toHaveBeenCalled()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("does not crash when callback has empty entries", () => {
|
|
120
|
+
const el = document.createElement("div")
|
|
121
|
+
const entrySignal = useIntersection(() => el)
|
|
122
|
+
mountCallbacks.forEach((cb) => {
|
|
123
|
+
cb()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Empty entries - e is undefined, so entry.set should not be called
|
|
127
|
+
intersectionCallback?.([])
|
|
128
|
+
expect(entrySignal()).toBeNull()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("updates to latest entry on subsequent intersections", () => {
|
|
132
|
+
const el = document.createElement("div")
|
|
133
|
+
const entrySignal = useIntersection(() => el)
|
|
134
|
+
mountCallbacks.forEach((cb) => {
|
|
135
|
+
cb()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const entry1 = {
|
|
139
|
+
isIntersecting: true,
|
|
140
|
+
intersectionRatio: 0.3,
|
|
141
|
+
} as unknown as IntersectionObserverEntry
|
|
142
|
+
|
|
143
|
+
const entry2 = {
|
|
144
|
+
isIntersecting: false,
|
|
145
|
+
intersectionRatio: 0,
|
|
146
|
+
} as unknown as IntersectionObserverEntry
|
|
147
|
+
|
|
148
|
+
intersectionCallback?.([entry1])
|
|
149
|
+
expect(entrySignal()).toBe(entry1)
|
|
150
|
+
|
|
151
|
+
intersectionCallback?.([entry2])
|
|
152
|
+
expect(entrySignal()).toBe(entry2)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { useInterval } from "../useInterval"
|
|
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("useInterval", () => {
|
|
13
|
+
beforeEach(() => vi.useFakeTimers())
|
|
14
|
+
afterEach(() => vi.useRealTimers())
|
|
15
|
+
|
|
16
|
+
it("calls callback at the specified interval", () => {
|
|
17
|
+
const fn = vi.fn()
|
|
18
|
+
useInterval(fn, 100)
|
|
19
|
+
|
|
20
|
+
vi.advanceTimersByTime(100)
|
|
21
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
22
|
+
|
|
23
|
+
vi.advanceTimersByTime(100)
|
|
24
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("does not call callback when delay is null", () => {
|
|
28
|
+
const fn = vi.fn()
|
|
29
|
+
useInterval(fn, null)
|
|
30
|
+
|
|
31
|
+
vi.advanceTimersByTime(1000)
|
|
32
|
+
expect(fn).not.toHaveBeenCalled()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("calls the latest callback", () => {
|
|
36
|
+
let value = 0
|
|
37
|
+
let currentCb = () => {
|
|
38
|
+
value = 1
|
|
39
|
+
}
|
|
40
|
+
useInterval(() => currentCb(), 100)
|
|
41
|
+
|
|
42
|
+
currentCb = () => {
|
|
43
|
+
value = 2
|
|
44
|
+
}
|
|
45
|
+
vi.advanceTimersByTime(100)
|
|
46
|
+
expect(value).toBe(2)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { onMount } from "@pyreon/core"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import useIsomorphicLayoutEffect from "../useIsomorphicLayoutEffect"
|
|
4
|
+
|
|
5
|
+
describe("useIsomorphicLayoutEffect", () => {
|
|
6
|
+
it("is onMount in a browser environment", () => {
|
|
7
|
+
expect(useIsomorphicLayoutEffect).toBe(onMount)
|
|
8
|
+
})
|
|
9
|
+
})
|