@react-spa-scaffold/mcp 2.1.1 → 2.3.0
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/README.md +2 -1
- package/dist/constants.d.ts +4 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +4 -0
- package/dist/constants.js.map +1 -1
- package/dist/features/definitions/auth.d.ts +3 -0
- package/dist/features/definitions/auth.d.ts.map +1 -0
- package/dist/features/definitions/auth.js +17 -0
- package/dist/features/definitions/auth.js.map +1 -0
- package/dist/features/definitions/core.d.ts.map +1 -1
- package/dist/features/definitions/core.js +16 -1
- package/dist/features/definitions/core.js.map +1 -1
- package/dist/features/definitions/database.d.ts +3 -0
- package/dist/features/definitions/database.d.ts.map +1 -0
- package/dist/features/definitions/database.js +45 -0
- package/dist/features/definitions/database.js.map +1 -0
- package/dist/features/definitions/deployment.d.ts +3 -0
- package/dist/features/definitions/deployment.d.ts.map +1 -0
- package/dist/features/definitions/deployment.js +14 -0
- package/dist/features/definitions/deployment.js.map +1 -0
- package/dist/features/definitions/forms.d.ts.map +1 -1
- package/dist/features/definitions/forms.js +4 -0
- package/dist/features/definitions/forms.js.map +1 -1
- package/dist/features/definitions/index.d.ts +3 -0
- package/dist/features/definitions/index.d.ts.map +1 -1
- package/dist/features/definitions/index.js +3 -0
- package/dist/features/definitions/index.js.map +1 -1
- package/dist/features/definitions/mobile.d.ts.map +1 -1
- package/dist/features/definitions/mobile.js +11 -2
- package/dist/features/definitions/mobile.js.map +1 -1
- package/dist/features/definitions/observability.js +1 -1
- package/dist/features/definitions/observability.js.map +1 -1
- package/dist/features/definitions/routing.d.ts.map +1 -1
- package/dist/features/definitions/routing.js +2 -1
- package/dist/features/definitions/routing.js.map +1 -1
- package/dist/features/definitions/state.d.ts.map +1 -1
- package/dist/features/definitions/state.js +9 -2
- package/dist/features/definitions/state.js.map +1 -1
- package/dist/features/definitions/testing.d.ts.map +1 -1
- package/dist/features/definitions/testing.js +4 -2
- package/dist/features/definitions/testing.js.map +1 -1
- package/dist/features/registry.d.ts.map +1 -1
- package/dist/features/registry.js +4 -1
- package/dist/features/registry.js.map +1 -1
- package/dist/features/types.test.js +6 -2
- package/dist/features/types.test.js.map +1 -1
- package/dist/resources/docs.d.ts.map +1 -1
- package/dist/resources/docs.js +5 -0
- package/dist/resources/docs.js.map +1 -1
- package/dist/tools/add-features.js +1 -1
- package/dist/tools/add-features.js.map +1 -1
- package/dist/utils/docs.d.ts.map +1 -1
- package/dist/utils/docs.js +2 -0
- package/dist/utils/docs.js.map +1 -1
- package/dist/utils/scaffold/claude-md/index.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/index.js +3 -1
- package/dist/utils/scaffold/claude-md/index.js.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.d.ts +2 -0
- package/dist/utils/scaffold/claude-md/sections.d.ts.map +1 -1
- package/dist/utils/scaffold/claude-md/sections.js +132 -2
- package/dist/utils/scaffold/claude-md/sections.js.map +1 -1
- package/dist/utils/scaffold/compute.js +1 -1
- package/dist/utils/scaffold/compute.js.map +1 -1
- package/dist/utils/scaffold/generators.d.ts +2 -2
- package/dist/utils/scaffold/generators.d.ts.map +1 -1
- package/dist/utils/scaffold/generators.js +64 -22
- package/dist/utils/scaffold/generators.js.map +1 -1
- package/package.json +1 -1
- package/templates/.env.example +44 -10
- package/templates/.github/workflows/ci.yml +12 -4
- package/templates/.github/workflows/deploy.yml +59 -0
- package/templates/CLAUDE.md +251 -2
- package/templates/docs/ARCHITECTURE.md +13 -12
- package/templates/docs/AUTHENTICATION.md +325 -0
- package/templates/docs/CODING_STANDARDS.md +65 -0
- package/templates/docs/DEPLOYMENT.md +268 -0
- package/templates/docs/E2E_TESTING.md +133 -11
- package/templates/docs/SUPABASE_INTEGRATION.md +310 -0
- package/templates/docs/TESTING.md +195 -77
- package/templates/e2e/auth/auth.setup.ts +60 -0
- package/templates/e2e/fixtures/index.ts +24 -2
- package/templates/e2e/tests/profile.auth.spec.ts +103 -0
- package/templates/e2e/tests/profile.spec.ts +64 -0
- package/templates/e2e/tests/register-form.spec.ts +38 -0
- package/templates/gitignore +5 -0
- package/templates/package.json +15 -3
- package/templates/playwright.config.ts +39 -4
- package/templates/src/App.tsx +32 -19
- package/templates/src/components/layout/Header.test.tsx +17 -1
- package/templates/src/components/layout/Header.tsx +13 -1
- package/templates/src/components/shared/AccountButton/AccountButton.test.tsx +30 -0
- package/templates/src/components/shared/AccountButton/AccountButton.tsx +38 -0
- package/templates/src/components/shared/AccountButton/index.ts +1 -0
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.test.tsx +4 -4
- package/templates/src/components/shared/ErrorBoundary/ErrorBoundary.tsx +55 -53
- package/templates/src/components/shared/ProfileSync/ProfileSync.test.tsx +44 -0
- package/templates/src/components/shared/ProfileSync/ProfileSync.tsx +104 -0
- package/templates/src/components/shared/ProfileSync/index.ts +1 -0
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.test.tsx +43 -0
- package/templates/src/components/shared/ProtectedRoute/ProtectedRoute.tsx +35 -0
- package/templates/src/components/shared/ProtectedRoute/index.ts +1 -0
- package/templates/src/components/shared/index.ts +5 -2
- package/templates/src/contexts/clerkContext.tsx +45 -0
- package/templates/src/contexts/performanceContext.tsx +3 -3
- package/templates/src/contexts/supabaseContext.test.tsx +59 -0
- package/templates/src/contexts/supabaseContext.tsx +87 -0
- package/templates/src/hooks/index.ts +40 -2
- package/templates/src/hooks/supabase/index.ts +12 -0
- package/templates/src/hooks/supabase/useProfiles.test.tsx +207 -0
- package/templates/src/hooks/supabase/useProfiles.ts +213 -0
- package/templates/src/hooks/supabase/useSupabaseQuery.test.tsx +150 -0
- package/templates/src/hooks/supabase/useSupabaseQuery.ts +91 -0
- package/templates/src/hooks/useCopyFeedback.test.ts +129 -0
- package/templates/src/hooks/useCopyFeedback.ts +41 -0
- package/templates/src/hooks/useDebouncedCallback.test.ts +164 -0
- package/templates/src/hooks/useDebouncedCallback.ts +47 -0
- package/templates/src/hooks/useDocumentTitle.test.ts +59 -0
- package/templates/src/hooks/useDocumentTitle.ts +31 -0
- package/templates/src/hooks/useIOSViewportReset.test.ts +58 -0
- package/templates/src/hooks/useIOSViewportReset.ts +18 -0
- package/templates/src/hooks/useKeyboardShortcut.test.ts +86 -0
- package/templates/src/hooks/useKeyboardShortcuts.ts +44 -0
- package/templates/src/hooks/useLocalStorage.test.ts +111 -0
- package/templates/src/hooks/useLocalStorage.ts +77 -0
- package/templates/src/hooks/useSyncedFormData.test.ts +75 -0
- package/templates/src/hooks/useSyncedFormData.ts +21 -0
- package/templates/src/hooks/useSyncedState.test.ts +119 -0
- package/templates/src/hooks/useSyncedState.ts +30 -0
- package/templates/src/index.css +1 -0
- package/templates/src/lib/api.test.ts +30 -38
- package/templates/src/lib/api.ts +1 -7
- package/templates/src/lib/config.ts +54 -4
- package/templates/src/lib/constants.ts +10 -0
- package/templates/src/lib/createSelectors.test.ts +136 -0
- package/templates/src/lib/createSelectors.ts +31 -0
- package/templates/src/lib/env.ts +36 -14
- package/templates/src/lib/index.ts +5 -2
- package/templates/src/lib/routes.ts +1 -0
- package/templates/src/lib/sentry.ts +58 -0
- package/templates/src/lib/storage.ts +6 -2
- package/templates/src/lib/supabase/client.ts +58 -0
- package/templates/src/lib/supabase/index.ts +5 -0
- package/templates/src/main.tsx +19 -31
- package/templates/src/mocks/constants.ts +31 -0
- package/templates/src/mocks/fixtures/index.ts +3 -1
- package/templates/src/mocks/fixtures/profiles.ts +55 -0
- package/templates/src/mocks/fixtures/users.ts +91 -0
- package/templates/src/mocks/handlers/index.ts +2 -1
- package/templates/src/mocks/handlers/supabase.ts +64 -0
- package/templates/src/mocks/handlers/todos.ts +1 -1
- package/templates/src/mocks/index.ts +6 -0
- package/templates/src/pages/Profile.test.tsx +263 -0
- package/templates/src/pages/Profile.tsx +171 -0
- package/templates/src/pages/index.ts +1 -0
- package/templates/src/stores/preferencesStore.ts +35 -9
- package/templates/src/test/clerkMock.tsx +137 -0
- package/templates/src/test/fetchMock.ts +58 -0
- package/templates/src/test/index.ts +51 -2
- package/templates/src/test/mocks.ts +128 -1
- package/templates/src/test/providers.tsx +10 -4
- package/templates/src/test/supabaseMock.ts +112 -0
- package/templates/src/test-setup.ts +42 -2
- package/templates/src/types/database.ts +46 -0
- package/templates/src/types/index.ts +1 -0
- package/templates/src/types/supabase.ts +167 -0
- package/templates/src/vite-env.d.ts +6 -0
- package/templates/supabase/migrations/20260104000000_create_profiles_table.sql +67 -0
- package/templates/vitest.config.ts +9 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { TIMING } from '@/lib/constants';
|
|
5
|
+
|
|
6
|
+
import { useCopyFeedback } from './useCopyFeedback';
|
|
7
|
+
|
|
8
|
+
describe('useCopyFeedback', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.useFakeTimers();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns isCopied: false initially', () => {
|
|
18
|
+
const { result } = renderHook(() => useCopyFeedback());
|
|
19
|
+
expect(result.current.isCopied).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('sets isCopied to true when triggerCopied is called', () => {
|
|
23
|
+
const { result } = renderHook(() => useCopyFeedback());
|
|
24
|
+
|
|
25
|
+
act(() => {
|
|
26
|
+
result.current.triggerCopied();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(result.current.isCopied).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('resets isCopied to false after default duration', () => {
|
|
33
|
+
const { result } = renderHook(() => useCopyFeedback());
|
|
34
|
+
|
|
35
|
+
act(() => {
|
|
36
|
+
result.current.triggerCopied();
|
|
37
|
+
});
|
|
38
|
+
expect(result.current.isCopied).toBe(true);
|
|
39
|
+
|
|
40
|
+
// Just before duration ends
|
|
41
|
+
act(() => {
|
|
42
|
+
vi.advanceTimersByTime(TIMING.COPY_FEEDBACK_DURATION - 1);
|
|
43
|
+
});
|
|
44
|
+
expect(result.current.isCopied).toBe(true);
|
|
45
|
+
|
|
46
|
+
// At duration
|
|
47
|
+
act(() => {
|
|
48
|
+
vi.advanceTimersByTime(1);
|
|
49
|
+
});
|
|
50
|
+
expect(result.current.isCopied).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('respects custom duration', () => {
|
|
54
|
+
const customDuration = 1000;
|
|
55
|
+
const { result } = renderHook(() => useCopyFeedback(customDuration));
|
|
56
|
+
|
|
57
|
+
act(() => {
|
|
58
|
+
result.current.triggerCopied();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
act(() => {
|
|
62
|
+
vi.advanceTimersByTime(customDuration - 1);
|
|
63
|
+
});
|
|
64
|
+
expect(result.current.isCopied).toBe(true);
|
|
65
|
+
|
|
66
|
+
act(() => {
|
|
67
|
+
vi.advanceTimersByTime(1);
|
|
68
|
+
});
|
|
69
|
+
expect(result.current.isCopied).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('resets timer if triggered again quickly', () => {
|
|
73
|
+
const duration = 1000;
|
|
74
|
+
const { result } = renderHook(() => useCopyFeedback(duration));
|
|
75
|
+
|
|
76
|
+
act(() => {
|
|
77
|
+
result.current.triggerCopied();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Wait 500ms
|
|
81
|
+
act(() => {
|
|
82
|
+
vi.advanceTimersByTime(500);
|
|
83
|
+
});
|
|
84
|
+
expect(result.current.isCopied).toBe(true);
|
|
85
|
+
|
|
86
|
+
// Trigger again - resets timer
|
|
87
|
+
act(() => {
|
|
88
|
+
result.current.triggerCopied();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Wait another 500ms (1000ms total from first trigger)
|
|
92
|
+
act(() => {
|
|
93
|
+
vi.advanceTimersByTime(500);
|
|
94
|
+
});
|
|
95
|
+
expect(result.current.isCopied).toBe(true);
|
|
96
|
+
|
|
97
|
+
// Wait remaining 500ms from second trigger
|
|
98
|
+
act(() => {
|
|
99
|
+
vi.advanceTimersByTime(500);
|
|
100
|
+
});
|
|
101
|
+
expect(result.current.isCopied).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('cleans up timeout on unmount', () => {
|
|
105
|
+
const { result, unmount } = renderHook(() => useCopyFeedback(1000));
|
|
106
|
+
|
|
107
|
+
act(() => {
|
|
108
|
+
result.current.triggerCopied();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Unmount before timeout completes - should not cause errors
|
|
112
|
+
unmount();
|
|
113
|
+
|
|
114
|
+
// This should not throw
|
|
115
|
+
act(() => {
|
|
116
|
+
vi.advanceTimersByTime(1000);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('maintains stable triggerCopied reference', () => {
|
|
121
|
+
const { result, rerender } = renderHook(() => useCopyFeedback());
|
|
122
|
+
|
|
123
|
+
const firstRef = result.current.triggerCopied;
|
|
124
|
+
rerender();
|
|
125
|
+
const secondRef = result.current.triggerCopied;
|
|
126
|
+
|
|
127
|
+
expect(firstRef).toBe(secondRef);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { TIMING } from '@/lib/constants';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manages temporary "Copied!" feedback state with automatic reset after a duration.
|
|
7
|
+
*
|
|
8
|
+
* @param duration - How long to show feedback (defaults to TIMING.COPY_FEEDBACK_DURATION)
|
|
9
|
+
*/
|
|
10
|
+
export function useCopyFeedback(duration: number = TIMING.COPY_FEEDBACK_DURATION): {
|
|
11
|
+
isCopied: boolean;
|
|
12
|
+
triggerCopied: () => void;
|
|
13
|
+
} {
|
|
14
|
+
const [isCopied, setIsCopied] = useState(false);
|
|
15
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
16
|
+
|
|
17
|
+
// Cleanup on unmount
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
return () => {
|
|
20
|
+
if (timeoutRef.current) {
|
|
21
|
+
clearTimeout(timeoutRef.current);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const triggerCopied = useCallback(() => {
|
|
27
|
+
// Clear any existing timeout
|
|
28
|
+
if (timeoutRef.current) {
|
|
29
|
+
clearTimeout(timeoutRef.current);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setIsCopied(true);
|
|
33
|
+
|
|
34
|
+
// Reset after duration
|
|
35
|
+
timeoutRef.current = setTimeout(() => {
|
|
36
|
+
setIsCopied(false);
|
|
37
|
+
}, duration);
|
|
38
|
+
}, [duration]);
|
|
39
|
+
|
|
40
|
+
return { isCopied, triggerCopied };
|
|
41
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { TIMING } from '@/lib/constants';
|
|
5
|
+
|
|
6
|
+
import { useDebouncedCallback } from './useDebouncedCallback';
|
|
7
|
+
|
|
8
|
+
describe('useDebouncedCallback', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.useFakeTimers();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('debounces callback execution', () => {
|
|
18
|
+
const callback = vi.fn();
|
|
19
|
+
const { result } = renderHook(() => useDebouncedCallback(callback));
|
|
20
|
+
|
|
21
|
+
act(() => {
|
|
22
|
+
result.current('arg1');
|
|
23
|
+
result.current('arg2');
|
|
24
|
+
result.current('arg3');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Not called yet
|
|
28
|
+
expect(callback).not.toHaveBeenCalled();
|
|
29
|
+
|
|
30
|
+
// Advance time past debounce delay
|
|
31
|
+
act(() => {
|
|
32
|
+
vi.advanceTimersByTime(TIMING.DEBOUNCE_DELAY);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Called once with last arguments
|
|
36
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(callback).toHaveBeenCalledWith('arg3');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('uses default delay from TIMING.DEBOUNCE_DELAY', () => {
|
|
41
|
+
const callback = vi.fn();
|
|
42
|
+
const { result } = renderHook(() => useDebouncedCallback(callback));
|
|
43
|
+
|
|
44
|
+
act(() => {
|
|
45
|
+
result.current();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Just before default delay
|
|
49
|
+
act(() => {
|
|
50
|
+
vi.advanceTimersByTime(TIMING.DEBOUNCE_DELAY - 1);
|
|
51
|
+
});
|
|
52
|
+
expect(callback).not.toHaveBeenCalled();
|
|
53
|
+
|
|
54
|
+
// At default delay
|
|
55
|
+
act(() => {
|
|
56
|
+
vi.advanceTimersByTime(1);
|
|
57
|
+
});
|
|
58
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('respects custom delay', () => {
|
|
62
|
+
const callback = vi.fn();
|
|
63
|
+
const customDelay = 500;
|
|
64
|
+
const { result } = renderHook(() => useDebouncedCallback(callback, customDelay));
|
|
65
|
+
|
|
66
|
+
act(() => {
|
|
67
|
+
result.current();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
act(() => {
|
|
71
|
+
vi.advanceTimersByTime(customDelay - 1);
|
|
72
|
+
});
|
|
73
|
+
expect(callback).not.toHaveBeenCalled();
|
|
74
|
+
|
|
75
|
+
act(() => {
|
|
76
|
+
vi.advanceTimersByTime(1);
|
|
77
|
+
});
|
|
78
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('resets timer on each call', () => {
|
|
82
|
+
const callback = vi.fn();
|
|
83
|
+
const delay = 100;
|
|
84
|
+
const { result } = renderHook(() => useDebouncedCallback(callback, delay));
|
|
85
|
+
|
|
86
|
+
act(() => {
|
|
87
|
+
result.current();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Advance halfway
|
|
91
|
+
act(() => {
|
|
92
|
+
vi.advanceTimersByTime(50);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Call again - resets timer
|
|
96
|
+
act(() => {
|
|
97
|
+
result.current();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Advance another 50ms (100ms total from first call)
|
|
101
|
+
act(() => {
|
|
102
|
+
vi.advanceTimersByTime(50);
|
|
103
|
+
});
|
|
104
|
+
expect(callback).not.toHaveBeenCalled();
|
|
105
|
+
|
|
106
|
+
// Advance remaining 50ms
|
|
107
|
+
act(() => {
|
|
108
|
+
vi.advanceTimersByTime(50);
|
|
109
|
+
});
|
|
110
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('maintains stable callback reference', () => {
|
|
114
|
+
const callback = vi.fn();
|
|
115
|
+
const { result, rerender } = renderHook(() => useDebouncedCallback(callback, 100));
|
|
116
|
+
|
|
117
|
+
const firstRef = result.current;
|
|
118
|
+
rerender();
|
|
119
|
+
const secondRef = result.current;
|
|
120
|
+
|
|
121
|
+
expect(firstRef).toBe(secondRef);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('uses latest callback version', () => {
|
|
125
|
+
let counter = 0;
|
|
126
|
+
const { result, rerender } = renderHook(({ cb }) => useDebouncedCallback(cb, 100), {
|
|
127
|
+
initialProps: { cb: () => counter++ },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
act(() => {
|
|
131
|
+
result.current();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Update callback before debounce fires
|
|
135
|
+
const newCallback = vi.fn(() => (counter = 100));
|
|
136
|
+
rerender({ cb: newCallback });
|
|
137
|
+
|
|
138
|
+
act(() => {
|
|
139
|
+
vi.advanceTimersByTime(100);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Should have used the new callback
|
|
143
|
+
expect(counter).toBe(100);
|
|
144
|
+
expect(newCallback).toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('cleans up timeout on unmount', () => {
|
|
148
|
+
const callback = vi.fn();
|
|
149
|
+
const { result, unmount } = renderHook(() => useDebouncedCallback(callback, 100));
|
|
150
|
+
|
|
151
|
+
act(() => {
|
|
152
|
+
result.current();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
unmount();
|
|
156
|
+
|
|
157
|
+
act(() => {
|
|
158
|
+
vi.advanceTimersByTime(100);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Callback should not be called after unmount
|
|
162
|
+
expect(callback).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { TIMING } from '@/lib/constants';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns a debounced version of a callback that only fires after a delay
|
|
7
|
+
* since the last call.
|
|
8
|
+
*
|
|
9
|
+
* @param callback - Function to debounce
|
|
10
|
+
* @param delay - Delay in ms (defaults to TIMING.DEBOUNCE_DELAY)
|
|
11
|
+
*/
|
|
12
|
+
export function useDebouncedCallback<T extends (...args: never[]) => void>(
|
|
13
|
+
callback: T,
|
|
14
|
+
delay: number = TIMING.DEBOUNCE_DELAY,
|
|
15
|
+
): T {
|
|
16
|
+
const callbackRef = useRef(callback);
|
|
17
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
18
|
+
|
|
19
|
+
// Keep callback ref up to date without restarting debounce
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
callbackRef.current = callback;
|
|
22
|
+
}, [callback]);
|
|
23
|
+
|
|
24
|
+
// Cleanup on unmount
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
return () => {
|
|
27
|
+
if (timeoutRef.current) {
|
|
28
|
+
clearTimeout(timeoutRef.current);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
// Stable reference via useCallback (delay is the only dependency)
|
|
34
|
+
const debouncedCallback = useCallback(
|
|
35
|
+
(...args: Parameters<T>) => {
|
|
36
|
+
if (timeoutRef.current) {
|
|
37
|
+
clearTimeout(timeoutRef.current);
|
|
38
|
+
}
|
|
39
|
+
timeoutRef.current = setTimeout(() => {
|
|
40
|
+
callbackRef.current(...args);
|
|
41
|
+
}, delay);
|
|
42
|
+
},
|
|
43
|
+
[delay],
|
|
44
|
+
) as T;
|
|
45
|
+
|
|
46
|
+
return debouncedCallback;
|
|
47
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
vi.mock('@/lib/config', () => ({
|
|
5
|
+
APP_CONFIG: { name: 'TestApp' },
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
import { useDocumentTitle } from './useDocumentTitle';
|
|
9
|
+
|
|
10
|
+
describe('useDocumentTitle', () => {
|
|
11
|
+
const originalTitle = 'Original Title';
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
document.title = originalTitle;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
document.title = originalTitle;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('sets document title with app name suffix', () => {
|
|
22
|
+
renderHook(() => useDocumentTitle('My Page'));
|
|
23
|
+
expect(document.title).toBe('My Page | TestApp');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('sets just app name when title is undefined', () => {
|
|
27
|
+
renderHook(() => useDocumentTitle(undefined));
|
|
28
|
+
expect(document.title).toBe('TestApp');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('sets just app name when title is empty string', () => {
|
|
32
|
+
renderHook(() => useDocumentTitle(''));
|
|
33
|
+
expect(document.title).toBe('TestApp');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('restores previous title on unmount', () => {
|
|
37
|
+
const { unmount } = renderHook(() => useDocumentTitle('My Page'));
|
|
38
|
+
expect(document.title).toBe('My Page | TestApp');
|
|
39
|
+
|
|
40
|
+
unmount();
|
|
41
|
+
expect(document.title).toBe(originalTitle);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('updates title when title prop changes', () => {
|
|
45
|
+
const { rerender } = renderHook(({ title }) => useDocumentTitle(title), {
|
|
46
|
+
initialProps: { title: 'Page 1' },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(document.title).toBe('Page 1 | TestApp');
|
|
50
|
+
|
|
51
|
+
rerender({ title: 'Page 2' });
|
|
52
|
+
expect(document.title).toBe('Page 2 | TestApp');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('handles title with special characters', () => {
|
|
56
|
+
renderHook(() => useDocumentTitle('Page & "Title" | More'));
|
|
57
|
+
expect(document.title).toBe('Page & "Title" | More | TestApp');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
import { APP_CONFIG } from '@/lib/config';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sets the document title dynamically, with cleanup on unmount.
|
|
7
|
+
*
|
|
8
|
+
* @param title - The page title (will be formatted as "{title} | {APP_NAME}")
|
|
9
|
+
*/
|
|
10
|
+
export function useDocumentTitle(title?: string): void {
|
|
11
|
+
const previousTitleRef = useRef<string | undefined>(undefined);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (typeof document === 'undefined') return;
|
|
15
|
+
|
|
16
|
+
// Store previous title on first run
|
|
17
|
+
if (previousTitleRef.current === undefined) {
|
|
18
|
+
previousTitleRef.current = document.title;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Format title as "{title} | {APP_NAME}" or just "{APP_NAME}" if empty
|
|
22
|
+
document.title = title ? `${title} | ${APP_CONFIG.name}` : APP_CONFIG.name;
|
|
23
|
+
|
|
24
|
+
return () => {
|
|
25
|
+
// Restore previous title on unmount
|
|
26
|
+
if (previousTitleRef.current !== undefined) {
|
|
27
|
+
document.title = previousTitleRef.current;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}, [title]);
|
|
31
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useIOSViewportReset } from './useIOSViewportReset';
|
|
5
|
+
|
|
6
|
+
describe('useIOSViewportReset', () => {
|
|
7
|
+
it('returns a stable blur handler function', () => {
|
|
8
|
+
const { result, rerender } = renderHook(() => useIOSViewportReset());
|
|
9
|
+
|
|
10
|
+
const firstRef = result.current;
|
|
11
|
+
rerender();
|
|
12
|
+
const secondRef = result.current;
|
|
13
|
+
|
|
14
|
+
expect(typeof firstRef).toBe('function');
|
|
15
|
+
expect(firstRef).toBe(secondRef);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('scrolls to top when blur handler is called', () => {
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
|
21
|
+
|
|
22
|
+
const { result } = renderHook(() => useIOSViewportReset());
|
|
23
|
+
|
|
24
|
+
// Call the blur handler
|
|
25
|
+
result.current();
|
|
26
|
+
|
|
27
|
+
// Should not scroll immediately
|
|
28
|
+
expect(scrollToSpy).not.toHaveBeenCalled();
|
|
29
|
+
|
|
30
|
+
// Advance past the 50ms delay
|
|
31
|
+
vi.advanceTimersByTime(50);
|
|
32
|
+
|
|
33
|
+
expect(scrollToSpy).toHaveBeenCalledWith(0, 0);
|
|
34
|
+
|
|
35
|
+
scrollToSpy.mockRestore();
|
|
36
|
+
vi.useRealTimers();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('is safe to call handler multiple times', () => {
|
|
40
|
+
vi.useFakeTimers();
|
|
41
|
+
const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
|
42
|
+
|
|
43
|
+
const { result } = renderHook(() => useIOSViewportReset());
|
|
44
|
+
|
|
45
|
+
// Call multiple times
|
|
46
|
+
result.current();
|
|
47
|
+
result.current();
|
|
48
|
+
result.current();
|
|
49
|
+
|
|
50
|
+
vi.advanceTimersByTime(50);
|
|
51
|
+
|
|
52
|
+
// Each call triggers a scroll
|
|
53
|
+
expect(scrollToSpy).toHaveBeenCalledTimes(3);
|
|
54
|
+
|
|
55
|
+
scrollToSpy.mockRestore();
|
|
56
|
+
vi.useRealTimers();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns a blur handler that fixes iOS Safari's viewport scroll bug
|
|
5
|
+
* when the keyboard is dismissed.
|
|
6
|
+
*
|
|
7
|
+
* Uses a 50ms delay to allow the viewport to settle before resetting.
|
|
8
|
+
*/
|
|
9
|
+
export function useIOSViewportReset(): () => void {
|
|
10
|
+
const handleBlur = useCallback(() => {
|
|
11
|
+
// Use setTimeout to allow viewport to settle after keyboard dismissal
|
|
12
|
+
setTimeout(() => {
|
|
13
|
+
window.scrollTo(0, 0);
|
|
14
|
+
}, 50);
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
return handleBlur;
|
|
18
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { useKeyboardShortcut } from './useKeyboardShortcuts';
|
|
5
|
+
|
|
6
|
+
// Mock react-hotkeys-hook
|
|
7
|
+
vi.mock('react-hotkeys-hook', () => ({
|
|
8
|
+
useHotkeys: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { useHotkeys } from 'react-hotkeys-hook';
|
|
12
|
+
|
|
13
|
+
const mockedUseHotkeys = vi.mocked(useHotkeys);
|
|
14
|
+
|
|
15
|
+
describe('useKeyboardShortcut', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mockedUseHotkeys.mockClear();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('calls useHotkeys with the correct shortcut and handler', () => {
|
|
21
|
+
const handler = vi.fn();
|
|
22
|
+
|
|
23
|
+
renderHook(() => useKeyboardShortcut('mod+s', handler));
|
|
24
|
+
|
|
25
|
+
expect(mockedUseHotkeys).toHaveBeenCalledWith(
|
|
26
|
+
'mod+s',
|
|
27
|
+
handler,
|
|
28
|
+
expect.objectContaining({
|
|
29
|
+
enableOnFormTags: false,
|
|
30
|
+
preventDefault: true,
|
|
31
|
+
}),
|
|
32
|
+
[handler],
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('respects custom options', () => {
|
|
37
|
+
const handler = vi.fn();
|
|
38
|
+
|
|
39
|
+
renderHook(() =>
|
|
40
|
+
useKeyboardShortcut('mod+enter', handler, {
|
|
41
|
+
enableOnFormTags: true,
|
|
42
|
+
preventDefault: false,
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(mockedUseHotkeys).toHaveBeenCalledWith(
|
|
47
|
+
'mod+enter',
|
|
48
|
+
handler,
|
|
49
|
+
expect.objectContaining({
|
|
50
|
+
enableOnFormTags: true,
|
|
51
|
+
preventDefault: false,
|
|
52
|
+
}),
|
|
53
|
+
[handler],
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('uses default options when not provided', () => {
|
|
58
|
+
const handler = vi.fn();
|
|
59
|
+
|
|
60
|
+
renderHook(() => useKeyboardShortcut('escape', handler));
|
|
61
|
+
|
|
62
|
+
expect(mockedUseHotkeys).toHaveBeenCalledWith(
|
|
63
|
+
'escape',
|
|
64
|
+
handler,
|
|
65
|
+
expect.objectContaining({
|
|
66
|
+
enableOnFormTags: false,
|
|
67
|
+
preventDefault: true,
|
|
68
|
+
}),
|
|
69
|
+
[handler],
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('can be used for multiple shortcuts by calling hook multiple times', () => {
|
|
74
|
+
const saveHandler = vi.fn();
|
|
75
|
+
const undoHandler = vi.fn();
|
|
76
|
+
|
|
77
|
+
renderHook(() => {
|
|
78
|
+
useKeyboardShortcut('mod+s', saveHandler);
|
|
79
|
+
useKeyboardShortcut('mod+z', undoHandler);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(mockedUseHotkeys).toHaveBeenCalledTimes(2);
|
|
83
|
+
expect(mockedUseHotkeys).toHaveBeenCalledWith('mod+s', saveHandler, expect.any(Object), [saveHandler]);
|
|
84
|
+
expect(mockedUseHotkeys).toHaveBeenCalledWith('mod+z', undoHandler, expect.any(Object), [undoHandler]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useHotkeys, type Options } from 'react-hotkeys-hook';
|
|
2
|
+
|
|
3
|
+
interface ShortcutOptions {
|
|
4
|
+
/** Enable shortcuts in form tags (input, textarea, select). Default: false */
|
|
5
|
+
enableOnFormTags?: boolean;
|
|
6
|
+
/** Prevent default browser behavior. Default: true */
|
|
7
|
+
preventDefault?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_OPTIONS: Required<ShortcutOptions> = {
|
|
11
|
+
enableOnFormTags: false,
|
|
12
|
+
preventDefault: true,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Registers a single keyboard shortcut with platform-aware modifiers.
|
|
17
|
+
* Uses react-hotkeys-hook under the hood.
|
|
18
|
+
*
|
|
19
|
+
* For multiple shortcuts, call this hook once for each shortcut.
|
|
20
|
+
*
|
|
21
|
+
* @param shortcut - The keyboard shortcut (e.g., 'mod+s', 'mod+enter')
|
|
22
|
+
* @param handler - Function to call when shortcut is triggered
|
|
23
|
+
* @param options - Configuration options for shortcut behavior
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* // Single shortcut
|
|
28
|
+
* useKeyboardShortcut('mod+s', () => handleSave());
|
|
29
|
+
*
|
|
30
|
+
* // Multiple shortcuts (call the hook multiple times)
|
|
31
|
+
* useKeyboardShortcut('mod+s', () => handleSave());
|
|
32
|
+
* useKeyboardShortcut('mod+z', () => handleUndo());
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function useKeyboardShortcut(shortcut: string, handler: () => void, options: ShortcutOptions = {}): void {
|
|
36
|
+
const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
37
|
+
|
|
38
|
+
const hotkeyOptions: Options = {
|
|
39
|
+
enableOnFormTags: mergedOptions.enableOnFormTags,
|
|
40
|
+
preventDefault: mergedOptions.preventDefault,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
useHotkeys(shortcut, handler, hotkeyOptions, [handler]);
|
|
44
|
+
}
|