@startsimpli/hooks 0.4.8 → 0.4.11
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/hooks",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.11",
|
|
4
4
|
"description": "Shared React hooks for StartSimpli apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"clean": "rm -rf dist"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
|
-
"
|
|
26
|
+
"@startsimpli/api": "workspace:*",
|
|
27
27
|
"@tanstack/react-query": ">=5.0.0",
|
|
28
|
-
"
|
|
28
|
+
"react": ">=18.0.0"
|
|
29
29
|
},
|
|
30
30
|
"peerDependenciesMeta": {
|
|
31
31
|
"@tanstack/react-query": {
|
|
@@ -37,14 +37,14 @@
|
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@startsimpli/api": "workspace:*",
|
|
40
|
-
"@tanstack/react-query": "^5.
|
|
41
|
-
"@testing-library/react": "^
|
|
42
|
-
"@types/node": "^20.
|
|
43
|
-
"@types/react": "^
|
|
44
|
-
"react": "^
|
|
45
|
-
"react-dom": "^
|
|
40
|
+
"@tanstack/react-query": "^5.99.2",
|
|
41
|
+
"@testing-library/react": "^16.3.2",
|
|
42
|
+
"@types/node": "^20.19.39",
|
|
43
|
+
"@types/react": "^19.2.14",
|
|
44
|
+
"react": "^19.2.5",
|
|
45
|
+
"react-dom": "^19.2.5",
|
|
46
46
|
"tsup": "^8.5.1",
|
|
47
|
-
"typescript": "^
|
|
48
|
-
"vitest": "^1.
|
|
47
|
+
"typescript": "^6.0.3",
|
|
48
|
+
"vitest": "^4.1.5"
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for useReducedMotion (bead swb1).
|
|
3
|
+
*
|
|
4
|
+
* Written to fail until the hook is moved into @startsimpli/hooks.
|
|
5
|
+
* Currently duplicated in raise-simpli/web-app/src/hooks/useReducedMotion.ts
|
|
6
|
+
* — after the move, the app imports this one and deletes the local copy.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
10
|
+
import { renderHook, act } from '@testing-library/react'
|
|
11
|
+
import { useReducedMotion } from '../useReducedMotion'
|
|
12
|
+
|
|
13
|
+
// Minimal MediaQueryList mock that matchMedia returns.
|
|
14
|
+
function makeMediaQueryList(matches: boolean) {
|
|
15
|
+
const listeners = new Set<(e: MediaQueryListEvent) => void>()
|
|
16
|
+
return {
|
|
17
|
+
matches,
|
|
18
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
19
|
+
addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => {
|
|
20
|
+
listeners.add(cb)
|
|
21
|
+
},
|
|
22
|
+
removeEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => {
|
|
23
|
+
listeners.delete(cb)
|
|
24
|
+
},
|
|
25
|
+
dispatch(nextMatches: boolean) {
|
|
26
|
+
for (const cb of listeners)
|
|
27
|
+
cb({ matches: nextMatches } as unknown as MediaQueryListEvent)
|
|
28
|
+
},
|
|
29
|
+
listenerCount() {
|
|
30
|
+
return listeners.size
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('useReducedMotion', () => {
|
|
36
|
+
let mql: ReturnType<typeof makeMediaQueryList>
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
mql = makeMediaQueryList(false)
|
|
40
|
+
window.matchMedia = vi.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
vi.restoreAllMocks()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns the current prefers-reduced-motion value on mount', () => {
|
|
48
|
+
mql = makeMediaQueryList(true)
|
|
49
|
+
window.matchMedia = vi.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia
|
|
50
|
+
|
|
51
|
+
const { result } = renderHook(() => useReducedMotion())
|
|
52
|
+
expect(result.current).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('updates when the media query changes', () => {
|
|
56
|
+
const { result } = renderHook(() => useReducedMotion())
|
|
57
|
+
expect(result.current).toBe(false)
|
|
58
|
+
|
|
59
|
+
act(() => {
|
|
60
|
+
mql.dispatch(true)
|
|
61
|
+
})
|
|
62
|
+
expect(result.current).toBe(true)
|
|
63
|
+
|
|
64
|
+
act(() => {
|
|
65
|
+
mql.dispatch(false)
|
|
66
|
+
})
|
|
67
|
+
expect(result.current).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('cleans up the media query listener on unmount', () => {
|
|
71
|
+
const { unmount } = renderHook(() => useReducedMotion())
|
|
72
|
+
expect(mql.listenerCount()).toBe(1)
|
|
73
|
+
|
|
74
|
+
unmount()
|
|
75
|
+
expect(mql.listenerCount()).toBe(0)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for useRefetchOnFocus (bead swb1).
|
|
3
|
+
*
|
|
4
|
+
* Hook is framework-agnostic — the caller passes in `pathname` so the hook
|
|
5
|
+
* doesn't take a Next dependency. In the raise-simpli app, callers pass
|
|
6
|
+
* usePathname() from 'next/navigation'.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
10
|
+
import { renderHook, act } from '@testing-library/react'
|
|
11
|
+
import { useRefetchOnFocus } from '../useRefetchOnFocus'
|
|
12
|
+
|
|
13
|
+
describe('useRefetchOnFocus', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
Object.defineProperty(document, 'visibilityState', {
|
|
16
|
+
configurable: true,
|
|
17
|
+
get: () => 'visible',
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.useRealTimers()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('does not call refetch on initial mount', () => {
|
|
26
|
+
const refetch = vi.fn()
|
|
27
|
+
renderHook(() => useRefetchOnFocus(refetch, '/dashboard'))
|
|
28
|
+
expect(refetch).not.toHaveBeenCalled()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('calls refetch when the pathname changes', () => {
|
|
32
|
+
const refetch = vi.fn()
|
|
33
|
+
const { rerender } = renderHook(
|
|
34
|
+
({ path }) => useRefetchOnFocus(refetch, path),
|
|
35
|
+
{ initialProps: { path: '/dashboard' } }
|
|
36
|
+
)
|
|
37
|
+
expect(refetch).not.toHaveBeenCalled()
|
|
38
|
+
|
|
39
|
+
rerender({ path: '/investors' })
|
|
40
|
+
expect(refetch).toHaveBeenCalledTimes(1)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('calls refetch when the tab becomes visible after being hidden', () => {
|
|
44
|
+
vi.useFakeTimers()
|
|
45
|
+
const refetch = vi.fn()
|
|
46
|
+
const { rerender } = renderHook(
|
|
47
|
+
({ path }) => useRefetchOnFocus(refetch, path),
|
|
48
|
+
{ initialProps: { path: '/dashboard' } }
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// Clear the initial-mount skip by triggering one pathname change, then
|
|
52
|
+
// advance past the 2s debounce window so the visibility event isn't
|
|
53
|
+
// suppressed.
|
|
54
|
+
rerender({ path: '/next' })
|
|
55
|
+
refetch.mockClear()
|
|
56
|
+
vi.advanceTimersByTime(2100)
|
|
57
|
+
|
|
58
|
+
Object.defineProperty(document, 'visibilityState', {
|
|
59
|
+
configurable: true,
|
|
60
|
+
get: () => 'visible',
|
|
61
|
+
})
|
|
62
|
+
act(() => {
|
|
63
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
64
|
+
})
|
|
65
|
+
expect(refetch).toHaveBeenCalledTimes(1)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('debounces rapid refetches to ≤1 per 2-second window', () => {
|
|
69
|
+
vi.useFakeTimers()
|
|
70
|
+
const refetch = vi.fn()
|
|
71
|
+
const { rerender } = renderHook(
|
|
72
|
+
({ path }) => useRefetchOnFocus(refetch, path),
|
|
73
|
+
{ initialProps: { path: '/dashboard' } }
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
rerender({ path: '/a' })
|
|
77
|
+
expect(refetch).toHaveBeenCalledTimes(1)
|
|
78
|
+
|
|
79
|
+
rerender({ path: '/b' })
|
|
80
|
+
expect(refetch).toHaveBeenCalledTimes(1) // within debounce
|
|
81
|
+
|
|
82
|
+
vi.advanceTimersByTime(2100)
|
|
83
|
+
rerender({ path: '/c' })
|
|
84
|
+
expect(refetch).toHaveBeenCalledTimes(2)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('no-ops when enabled=false', () => {
|
|
88
|
+
const refetch = vi.fn()
|
|
89
|
+
const { rerender } = renderHook(
|
|
90
|
+
({ path, enabled }) => useRefetchOnFocus(refetch, path, enabled),
|
|
91
|
+
{ initialProps: { path: '/dashboard', enabled: false } }
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
rerender({ path: '/other', enabled: false })
|
|
95
|
+
|
|
96
|
+
act(() => {
|
|
97
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
expect(refetch).not.toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -49,6 +49,8 @@ export type { UseEntityTableOptions, UseEntityTableReturn } from './useEntityTab
|
|
|
49
49
|
export { useTargetListDetail, useTargetListMutations, TARGET_LIST_KEYS } from './useTargetLists'
|
|
50
50
|
export type { TargetListApiFns, UseTargetListMutationsOptions } from './useTargetLists'
|
|
51
51
|
export { useEnrichment, useBatchEnrichment, useQueueStatus } from './useEnrichment'
|
|
52
|
+
export { useReducedMotion } from './useReducedMotion'
|
|
53
|
+
export { useRefetchOnFocus } from './useRefetchOnFocus'
|
|
52
54
|
export type {
|
|
53
55
|
UseEnrichmentState,
|
|
54
56
|
UseEnrichmentOptions,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useReducedMotion Hook
|
|
3
|
+
* Detects if user prefers reduced motion for accessibility
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client'
|
|
7
|
+
|
|
8
|
+
import { useEffect, useState } from 'react'
|
|
9
|
+
|
|
10
|
+
export function useReducedMotion(): boolean {
|
|
11
|
+
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
15
|
+
|
|
16
|
+
setPrefersReducedMotion(mediaQuery.matches)
|
|
17
|
+
|
|
18
|
+
const handleChange = (event: MediaQueryListEvent) => {
|
|
19
|
+
setPrefersReducedMotion(event.matches)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
mediaQuery.addEventListener('change', handleChange)
|
|
23
|
+
return () => mediaQuery.removeEventListener('change', handleChange)
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
return prefersReducedMotion
|
|
27
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calls `refetch` when the page regains visibility (tab switch back)
|
|
5
|
+
* or when the provided `pathname` changes (route navigation).
|
|
6
|
+
*
|
|
7
|
+
* Skips the initial mount — only fires on subsequent focus/navigation events.
|
|
8
|
+
* Debounces to prevent rapid re-fetches (minimum 2s between calls).
|
|
9
|
+
*
|
|
10
|
+
* Framework-agnostic: in Next.js apps pass `usePathname()` from
|
|
11
|
+
* 'next/navigation'. In non-Next environments pass any string that
|
|
12
|
+
* represents the current route (or '' if not applicable).
|
|
13
|
+
*/
|
|
14
|
+
export function useRefetchOnFocus(
|
|
15
|
+
refetch: () => void,
|
|
16
|
+
pathname: string,
|
|
17
|
+
enabled = true
|
|
18
|
+
): void {
|
|
19
|
+
const mounted = useRef(false)
|
|
20
|
+
const lastRefetch = useRef(0)
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!enabled) return
|
|
24
|
+
|
|
25
|
+
if (!mounted.current) {
|
|
26
|
+
mounted.current = true
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const now = Date.now()
|
|
31
|
+
if (now - lastRefetch.current < 2000) return
|
|
32
|
+
|
|
33
|
+
lastRefetch.current = now
|
|
34
|
+
refetch()
|
|
35
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
36
|
+
}, [pathname, enabled])
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!enabled) return
|
|
40
|
+
|
|
41
|
+
function handleVisibilityChange() {
|
|
42
|
+
if (document.visibilityState === 'visible' && mounted.current) {
|
|
43
|
+
const now = Date.now()
|
|
44
|
+
if (now - lastRefetch.current < 2000) return
|
|
45
|
+
lastRefetch.current = now
|
|
46
|
+
refetch()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
51
|
+
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
52
|
+
}, [refetch, enabled])
|
|
53
|
+
}
|