@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.8",
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
- "react": ">=18.0.0",
26
+ "@startsimpli/api": "workspace:*",
27
27
  "@tanstack/react-query": ">=5.0.0",
28
- "@startsimpli/api": "workspace:*"
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.0.0",
41
- "@testing-library/react": "^14.0.0",
42
- "@types/node": "^20.11.0",
43
- "@types/react": "^18.2.0",
44
- "react": "^18.2.0",
45
- "react-dom": "^18.2.0",
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": "^5.3.3",
48
- "vitest": "^1.2.0"
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
+ }