feat(website): 三轮视觉改造与页面过渡动画
改造概要(30项): - 第一轮:Hero重构/Section差异化/SocialProof强化/CTA对比度/About架构 - 第二轮:字体优化/背景交替/Solutions差异化/Footer五列/MegaDropdown修复 - 第三轮:卡片交互/表单层级/CTA统一/时间线标记/连接线/三列布局/移动导航/Button微交互/SEO Schema - P3-2:template.tsx+Framer Motion页面过渡/loading.tsx加载状态 - 清理:删除未用组件/hooks,修复重复移动导航,清理冗余CSS
This commit is contained in:
@@ -1,68 +0,0 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useFontLoading } from './use-font-loading';
|
||||
|
||||
describe('useFontLoading', () => {
|
||||
const originalFonts = document.fonts;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(document, 'fonts', {
|
||||
value: originalFonts,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true when fonts API is not supported', async () => {
|
||||
// 完全删除 fonts 属性来模拟不支持的情况
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const originalFonts = (document as unknown as Record<string, unknown>).fonts;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (document as unknown as Record<string, unknown>).fonts;
|
||||
|
||||
const { result } = renderHook(() => useFontLoading());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
// 恢复 fonts 属性
|
||||
Object.defineProperty(document, 'fonts', {
|
||||
value: originalFonts,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true when font is loaded', async () => {
|
||||
const mockLoad = jest.fn().mockResolvedValue([]);
|
||||
Object.defineProperty(document, 'fonts', {
|
||||
value: { load: mockLoad },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFontLoading('Ma Shan Zheng'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockLoad).toHaveBeenCalledWith('1em "Ma Shan Zheng"');
|
||||
});
|
||||
|
||||
it('should return true when font loading fails', async () => {
|
||||
const mockLoad = jest.fn().mockRejectedValue(new Error('Font load failed'));
|
||||
Object.defineProperty(document, 'fonts', {
|
||||
value: { load: mockLoad },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFontLoading());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useFontLoading(fontFamily: string = 'Ma Shan Zheng') {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
// 检查字体是否已加载
|
||||
if ('fonts' in document) {
|
||||
document.fonts
|
||||
.load(`1em "${fontFamily}"`)
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 如果字体加载失败,仍然标记为已加载,使用回退字体
|
||||
if (!cancelled) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 不支持 Font Loading API 的浏览器,使用 setTimeout 避免同步 setState
|
||||
const timer = setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fontFamily]);
|
||||
|
||||
return isLoaded;
|
||||
}
|
||||
|
||||
export function useFontPreloader(fontUrls: string[]) {
|
||||
useEffect(() => {
|
||||
fontUrls.forEach((url) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'preload';
|
||||
link.as = 'font';
|
||||
link.href = url;
|
||||
link.crossOrigin = 'anonymous';
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}, [fontUrls]);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useFormAutosave } from './use-form-autosave';
|
||||
|
||||
describe('useFormAutosave', () => {
|
||||
const mockKey = 'test_form';
|
||||
const mockInitialData = { name: '', email: '' };
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should initialize with initial data', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFormAutosave({
|
||||
key: mockKey,
|
||||
initialData: mockInitialData,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.data).toEqual(mockInitialData);
|
||||
expect(result.current.lastSaved).toBeNull();
|
||||
expect(result.current.isRestored).toBe(false);
|
||||
});
|
||||
|
||||
it('should restore data from localStorage', () => {
|
||||
const savedData = {
|
||||
data: { name: 'John', email: 'john@example.com' },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(`form_autosave_${mockKey}`, JSON.stringify(savedData));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFormAutosave({
|
||||
key: mockKey,
|
||||
initialData: mockInitialData,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.data).toEqual(savedData.data);
|
||||
expect(result.current.isRestored).toBe(true);
|
||||
});
|
||||
|
||||
it('should not restore expired data', () => {
|
||||
const expiredData = {
|
||||
data: { name: 'John', email: 'john@example.com' },
|
||||
timestamp: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), // 25 hours ago
|
||||
};
|
||||
localStorage.setItem(`form_autosave_${mockKey}`, JSON.stringify(expiredData));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFormAutosave({
|
||||
key: mockKey,
|
||||
initialData: mockInitialData,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.data).toEqual(mockInitialData);
|
||||
expect(result.current.isRestored).toBe(false);
|
||||
});
|
||||
|
||||
it('should update data and auto-save after debounce', async () => {
|
||||
const onSave = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useFormAutosave({
|
||||
key: mockKey,
|
||||
initialData: mockInitialData,
|
||||
onSave,
|
||||
debounceMs: 1000,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.updateData({ name: 'John' });
|
||||
});
|
||||
|
||||
expect(result.current.data.name).toBe('John');
|
||||
expect(result.current.lastSaved).toBeNull();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.lastSaved).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({ name: 'John', email: '' });
|
||||
});
|
||||
|
||||
it('should clear saved data', () => {
|
||||
const savedData = {
|
||||
data: { name: 'John', email: 'john@example.com' },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(`form_autosave_${mockKey}`, JSON.stringify(savedData));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFormAutosave({
|
||||
key: mockKey,
|
||||
initialData: mockInitialData,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.clearSavedData();
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockInitialData);
|
||||
expect(result.current.lastSaved).toBeNull();
|
||||
expect(result.current.isRestored).toBe(false);
|
||||
expect(localStorage.getItem(`form_autosave_${mockKey}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('should save immediately when called', async () => {
|
||||
const onSave = jest.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useFormAutosave({
|
||||
key: mockKey,
|
||||
initialData: mockInitialData,
|
||||
onSave,
|
||||
debounceMs: 5000,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.updateData({ name: 'John' });
|
||||
});
|
||||
|
||||
// 等待状态更新
|
||||
await waitFor(() => {
|
||||
expect(result.current.data.name).toBe('John');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.saveImmediately();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith({ name: 'John', email: '' });
|
||||
});
|
||||
expect(result.current.lastSaved).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
interface UseFormAutosaveOptions<T> {
|
||||
key: string;
|
||||
initialData: T;
|
||||
onSave?: (data: T) => void;
|
||||
debounceMs?: number;
|
||||
maxAge?: number; // 数据最大保存时间(毫秒),默认 24 小时
|
||||
}
|
||||
|
||||
interface AutosaveState<T> {
|
||||
data: T;
|
||||
lastSaved: Date | null;
|
||||
isRestored: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function useFormAutosave<T extends Record<string, any>>({
|
||||
key,
|
||||
initialData,
|
||||
onSave,
|
||||
debounceMs = 1000,
|
||||
maxAge = 24 * 60 * 60 * 1000, // 24 小时
|
||||
}: UseFormAutosaveOptions<T>) {
|
||||
const [state, setState] = useState<AutosaveState<T>>({
|
||||
data: initialData,
|
||||
lastSaved: null,
|
||||
isRestored: false,
|
||||
});
|
||||
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const storageKey = `form_autosave_${key}`;
|
||||
|
||||
// 从 localStorage 恢复数据
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
const savedTime = new Date(parsed.timestamp).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
// 检查数据是否过期
|
||||
if (now - savedTime < maxAge) {
|
||||
setState({
|
||||
data: { ...initialData, ...parsed.data },
|
||||
lastSaved: new Date(parsed.timestamp),
|
||||
isRestored: true,
|
||||
});
|
||||
} else {
|
||||
// 数据过期,清除
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restore form data:', error);
|
||||
}
|
||||
}, [storageKey, initialData, maxAge]);
|
||||
|
||||
// 自动保存到 localStorage
|
||||
const saveToStorage = useCallback((data: T) => {
|
||||
try {
|
||||
const payload = {
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
lastSaved: new Date(),
|
||||
}));
|
||||
onSave?.(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to save form data:', error);
|
||||
}
|
||||
}, [storageKey, onSave]);
|
||||
|
||||
// 防抖保存
|
||||
const debouncedSave = useCallback((data: T) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
saveToStorage(data);
|
||||
}, debounceMs);
|
||||
}, [debounceMs, saveToStorage]);
|
||||
|
||||
// 更新数据
|
||||
const updateData = useCallback((updates: Partial<T> | ((prev: T) => Partial<T>)) => {
|
||||
setState(prev => {
|
||||
const newData = typeof updates === 'function'
|
||||
? { ...prev.data, ...updates(prev.data) }
|
||||
: { ...prev.data, ...updates };
|
||||
|
||||
debouncedSave(newData);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
data: newData,
|
||||
};
|
||||
});
|
||||
}, [debouncedSave]);
|
||||
|
||||
// 清除保存的数据
|
||||
const clearSavedData = useCallback(() => {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
setState({
|
||||
data: initialData,
|
||||
lastSaved: null,
|
||||
isRestored: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to clear form data:', error);
|
||||
}
|
||||
}, [storageKey, initialData]);
|
||||
|
||||
// 立即保存(用于表单提交前)
|
||||
const saveImmediately = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
saveToStorage(state.data);
|
||||
}, [state.data, saveToStorage]);
|
||||
|
||||
// 清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data: state.data,
|
||||
lastSaved: state.lastSaved,
|
||||
isRestored: state.isRestored,
|
||||
updateData,
|
||||
clearSavedData,
|
||||
saveImmediately,
|
||||
};
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useIntersectionObserver } from './use-intersection-observer';
|
||||
|
||||
describe('useIntersectionObserver', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should return ref and isIntersecting state', () => {
|
||||
const { result } = renderHook(() => useIntersectionObserver<HTMLDivElement>());
|
||||
expect(result.current[0]).toBeDefined();
|
||||
expect(typeof result.current[1]).toBe('boolean');
|
||||
expect(result.current[1]).toBe(false);
|
||||
});
|
||||
|
||||
it('should return ref with null current value', () => {
|
||||
const { result } = renderHook(() => useIntersectionObserver<HTMLDivElement>());
|
||||
expect(result.current[0].current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Options', () => {
|
||||
it('should accept threshold option', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useIntersectionObserver<HTMLDivElement>({ threshold: 0.5 })
|
||||
);
|
||||
expect(result.current[1]).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept root option', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useIntersectionObserver<HTMLDivElement>({ root: null })
|
||||
);
|
||||
expect(result.current[1]).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept rootMargin option', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useIntersectionObserver<HTMLDivElement>({ rootMargin: '10px' })
|
||||
);
|
||||
expect(result.current[1]).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept freezeOnceVisible option', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useIntersectionObserver<HTMLDivElement>({ freezeOnceVisible: true })
|
||||
);
|
||||
expect(result.current[1]).toBe(false);
|
||||
});
|
||||
|
||||
it('should use default options', () => {
|
||||
const { result } = renderHook(() => useIntersectionObserver<HTMLDivElement>());
|
||||
expect(result.current[1]).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept multiple thresholds', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useIntersectionObserver<HTMLDivElement>({ threshold: [0, 0.25, 0.5, 0.75, 1] })
|
||||
);
|
||||
expect(result.current[1]).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Behavior', () => {
|
||||
it('should not observe when freezeOnceVisible is true and already intersecting', () => {
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useIntersectionObserver<HTMLDivElement>({ freezeOnceVisible: true })
|
||||
);
|
||||
|
||||
expect(result.current[1]).toBe(false);
|
||||
|
||||
rerender();
|
||||
expect(result.current[1]).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should cleanup observer on unmount', () => {
|
||||
const { unmount } = renderHook(() => useIntersectionObserver<HTMLDivElement>());
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Safety', () => {
|
||||
it('should work with different element types', () => {
|
||||
const { result: result1 } = renderHook(() => useIntersectionObserver<HTMLDivElement>());
|
||||
const { result: result2 } = renderHook(() => useIntersectionObserver<HTMLSpanElement>());
|
||||
const { result: result3 } = renderHook(() => useIntersectionObserver<HTMLElement>());
|
||||
|
||||
expect(result1.current[1]).toBe(false);
|
||||
expect(result2.current[1]).toBe(false);
|
||||
expect(result3.current[1]).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, type RefObject } from 'react';
|
||||
|
||||
interface UseIntersectionObserverOptions {
|
||||
threshold?: number | number[];
|
||||
root?: Element | null;
|
||||
rootMargin?: string;
|
||||
freezeOnceVisible?: boolean;
|
||||
}
|
||||
|
||||
export function useIntersectionObserver<T extends Element>(
|
||||
options: UseIntersectionObserverOptions = {}
|
||||
): [RefObject<T | null>, boolean] {
|
||||
const { threshold = 0, root = null, rootMargin = '0px', freezeOnceVisible = false } = options;
|
||||
|
||||
const elementRef = useRef<T | null>(null);
|
||||
const [isIntersecting, setIsIntersecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) {return;}
|
||||
|
||||
if (freezeOnceVisible && isIntersecting) {return;}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry) {
|
||||
setIsIntersecting(entry.isIntersecting);
|
||||
}
|
||||
},
|
||||
{ threshold, root, rootMargin }
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [threshold, root, rootMargin, freezeOnceVisible, isIntersecting]);
|
||||
|
||||
return [elementRef, isIntersecting];
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useMediaQuery, useIsMobile, useIsTablet, useIsDesktop } from './use-media-query';
|
||||
|
||||
describe('useMediaQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useMediaQuery', () => {
|
||||
it('should return false on server side', () => {
|
||||
const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return boolean value', () => {
|
||||
const { result } = renderHook(() => useMediaQuery('(min-width: 768px)'));
|
||||
expect(typeof result.current).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useIsMobile', () => {
|
||||
it('should return boolean value', () => {
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(typeof result.current).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should use correct media query', () => {
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useIsTablet', () => {
|
||||
it('should return boolean value', () => {
|
||||
const { result } = renderHook(() => useIsTablet());
|
||||
expect(typeof result.current).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should use correct media query', () => {
|
||||
const { result } = renderHook(() => useIsTablet());
|
||||
expect(result.current).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useIsDesktop', () => {
|
||||
it('should return boolean value', () => {
|
||||
const { result } = renderHook(() => useIsDesktop());
|
||||
expect(typeof result.current).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should use correct media query', () => {
|
||||
const { result } = renderHook(() => useIsDesktop());
|
||||
expect(result.current).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Media Query Behavior', () => {
|
||||
it('should handle different queries', () => {
|
||||
const { result: result1 } = renderHook(() => useMediaQuery('(min-width: 1024px)'));
|
||||
const { result: result2 } = renderHook(() => useMediaQuery('(max-width: 767px)'));
|
||||
|
||||
expect(typeof result1.current).toBe('boolean');
|
||||
expect(typeof result2.current).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should handle complex queries', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMediaQuery('(min-width: 768px) and (max-width: 1023px)')
|
||||
);
|
||||
expect(typeof result.current).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
function getMediaQuerySnapshot(query: string): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
|
||||
function getMediaQueryServerSnapshot(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function subscribeToMediaQuery(query: string, callback: () => void): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
const media = window.matchMedia(query);
|
||||
media.addEventListener('change', callback);
|
||||
return () => {
|
||||
media.removeEventListener('change', callback);
|
||||
};
|
||||
}
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const matches = useSyncExternalStore(
|
||||
(callback) => subscribeToMediaQuery(query, callback),
|
||||
() => getMediaQuerySnapshot(query),
|
||||
getMediaQueryServerSnapshot
|
||||
);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery('(max-width: 767px)');
|
||||
}
|
||||
|
||||
export function useIsTablet(): boolean {
|
||||
return useMediaQuery('(min-width: 768px) and (max-width: 1023px)');
|
||||
}
|
||||
|
||||
export function useIsDesktop(): boolean {
|
||||
return useMediaQuery('(min-width: 1024px)');
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useScrollReveal, useScrollProgress, useParallax } from './use-scroll-reveal';
|
||||
|
||||
describe('useScrollReveal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useScrollReveal', () => {
|
||||
it('should return ref and isVisible state', () => {
|
||||
const { result } = renderHook(() => useScrollReveal());
|
||||
expect(result.current).toHaveProperty('ref');
|
||||
expect(result.current).toHaveProperty('isVisible');
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept custom options', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useScrollReveal({ threshold: 0.5, rootMargin: '10px', triggerOnce: false })
|
||||
);
|
||||
expect(result.current).toHaveProperty('ref');
|
||||
expect(result.current).toHaveProperty('isVisible');
|
||||
});
|
||||
|
||||
it('should use default options', () => {
|
||||
const { result } = renderHook(() => useScrollReveal());
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useScrollProgress', () => {
|
||||
it('should return progress value', () => {
|
||||
const { result } = renderHook(() => useScrollProgress());
|
||||
expect(typeof result.current).toBe('number');
|
||||
expect(result.current).toBeGreaterThanOrEqual(0);
|
||||
expect(result.current).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should accept threshold option', () => {
|
||||
const { result } = renderHook(() => useScrollProgress({ threshold: 0.5 }));
|
||||
expect(typeof result.current).toBe('number');
|
||||
});
|
||||
|
||||
it('should use default threshold', () => {
|
||||
const { result } = renderHook(() => useScrollProgress());
|
||||
expect(result.current).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useParallax', () => {
|
||||
it('should return ref and offset', () => {
|
||||
const { result } = renderHook(() => useParallax());
|
||||
expect(result.current).toHaveProperty('ref');
|
||||
expect(result.current).toHaveProperty('offset');
|
||||
expect(typeof result.current.offset).toBe('number');
|
||||
});
|
||||
|
||||
it('should accept speed option', () => {
|
||||
const { result } = renderHook(() => useParallax({ speed: 0.8 }));
|
||||
expect(result.current).toHaveProperty('ref');
|
||||
expect(result.current).toHaveProperty('offset');
|
||||
});
|
||||
|
||||
it('should use default speed', () => {
|
||||
const { result } = renderHook(() => useParallax());
|
||||
expect(result.current.offset).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle different speed values', () => {
|
||||
const { result: result1 } = renderHook(() => useParallax({ speed: 0 }));
|
||||
const { result: result2 } = renderHook(() => useParallax({ speed: 1 }));
|
||||
|
||||
expect(result1.current.offset).toBeDefined();
|
||||
expect(result2.current.offset).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should cleanup intersection observer on unmount', () => {
|
||||
const { unmount } = renderHook(() => useScrollReveal());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should cleanup scroll listener on unmount', () => {
|
||||
const { unmount } = renderHook(() => useScrollProgress());
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should cleanup parallax scroll listener on unmount', () => {
|
||||
const { unmount } = renderHook(() => useParallax());
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface UseScrollRevealOptions {
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
triggerOnce?: boolean;
|
||||
}
|
||||
|
||||
export function useScrollReveal({
|
||||
threshold = 0.1,
|
||||
rootMargin = '0px',
|
||||
triggerOnce = true,
|
||||
}: UseScrollRevealOptions = {}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) {return;}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
if (triggerOnce) {
|
||||
observer.unobserve(element);
|
||||
}
|
||||
} else if (!triggerOnce) {
|
||||
setIsVisible(false);
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin }
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [threshold, rootMargin, triggerOnce]);
|
||||
|
||||
return { ref, isVisible };
|
||||
}
|
||||
|
||||
interface UseScrollProgressOptions {
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export function useScrollProgress({ threshold: _threshold = 0 }: UseScrollProgressOptions = {}) {
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.pageYOffset;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const scrollProgress = scrollTop / docHeight;
|
||||
setProgress(Math.min(1, Math.max(0, scrollProgress)));
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
interface UseParallaxOptions {
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export function useParallax({ speed = 0.5 }: UseParallaxOptions = {}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (!ref.current) {return;}
|
||||
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const scrolled = window.pageYOffset;
|
||||
const elementTop = rect.top + scrolled;
|
||||
const parallaxOffset = (scrolled - elementTop) * speed;
|
||||
|
||||
setOffset(parallaxOffset);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
handleScroll();
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [speed]);
|
||||
|
||||
return { ref, offset };
|
||||
}
|
||||
Reference in New Issue
Block a user