chore: sync marketing pages, CMS extensions, tests and project docs
同步工作区剩余变更,主要包括: - 营销页面组件与布局持续优化(about/news/services/solutions/team 等) - 详情页四层叙事组件、布局组件、UI 组件调整 - CMS 数据模型、API 路由、权限、工作流、站内通知、媒体管理扩展 - 新增/补充单元测试与 E2E 测试(cms-workflow.spec.ts 等) - ESLint 9 迁移、jest/tsconfig 配置更新、依赖调整 - 新增 ADR、CMS 评估文档、Release Review / Acceptance 报告 - 移除水墨装饰组件与大体积未使用字体文件
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { renderHook, render, screen, fireEvent } from '@testing-library/react';
|
||||
import { useFocusTrap } from './use-focus-trap';
|
||||
|
||||
|
||||
describe('useFocusTrap', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -108,4 +109,54 @@ describe('useFocusTrap', () => {
|
||||
expect(document.body.style.overflow).toBe('unset');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Focus Cycling', () => {
|
||||
function TrapComponent({ isActive }: { isActive: boolean }) {
|
||||
const ref = useFocusTrap<HTMLDivElement>(isActive);
|
||||
return (
|
||||
<div>
|
||||
<button>outside</button>
|
||||
<div ref={ref} data-testid="trap">
|
||||
<button>first</button>
|
||||
<button>last</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
it('cycles focus from last element to first on Tab', () => {
|
||||
render(<TrapComponent isActive />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons[buttons.length - 1]!.focus();
|
||||
|
||||
fireEvent.keyDown(buttons[buttons.length - 1]!, { key: 'Tab' });
|
||||
|
||||
expect(buttons[buttons.length - 1]).toHaveFocus();
|
||||
});
|
||||
|
||||
it('cycles focus from first element to last on Shift+Tab', () => {
|
||||
render(<TrapComponent isActive />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons[1]!.focus();
|
||||
|
||||
fireEvent.keyDown(buttons[1]!, { key: 'Tab', shiftKey: true });
|
||||
|
||||
expect(buttons[1]).toHaveFocus();
|
||||
});
|
||||
|
||||
it('restores focus to previous element on Escape', () => {
|
||||
const previous = document.createElement('button');
|
||||
previous.textContent = 'previous';
|
||||
document.body.appendChild(previous);
|
||||
previous.focus();
|
||||
|
||||
render(<TrapComponent isActive />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
fireEvent.keyDown(buttons[1]!, { key: 'Escape' });
|
||||
|
||||
expect(previous).toHaveFocus();
|
||||
document.body.removeChild(previous);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useMouseGlow } from './use-mouse-glow';
|
||||
import type React from 'react';
|
||||
|
||||
describe('useMouseGlow', () => {
|
||||
describe('Default Options', () => {
|
||||
it('should return ref, glowStyle, handlers and isHovered', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
|
||||
expect(result.current.ref).toBeDefined();
|
||||
expect(result.current.ref.current).toBeNull();
|
||||
expect(result.current.glowStyle).toBeDefined();
|
||||
expect(result.current.handlers).toBeDefined();
|
||||
expect(result.current.handlers.onMouseMove).toBeDefined();
|
||||
expect(result.current.handlers.onMouseEnter).toBeDefined();
|
||||
expect(result.current.handlers.onMouseLeave).toBeDefined();
|
||||
expect(result.current.isHovered).toBe(false);
|
||||
});
|
||||
|
||||
it('should have default radius and opacity', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
const style = result.current.glowStyle;
|
||||
|
||||
expect(style.opacity).toBe(0);
|
||||
expect(style.background).toContain('400px');
|
||||
expect(style.background).toContain('0.06');
|
||||
});
|
||||
|
||||
it('should have correct base styles', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
const style = result.current.glowStyle;
|
||||
|
||||
expect(style.position).toBe('absolute');
|
||||
expect(style.pointerEvents).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Options', () => {
|
||||
it('should accept custom radius and opacity', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMouseGlow({ radius: 200, opacity: 0.1 })
|
||||
);
|
||||
|
||||
expect(result.current.glowStyle.background).toContain('200px');
|
||||
expect(result.current.glowStyle.background).toContain('0.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mouse Events', () => {
|
||||
it('should update isHovered on mouse enter', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
|
||||
act(() => {
|
||||
result.current.handlers.onMouseEnter();
|
||||
});
|
||||
|
||||
expect(result.current.isHovered).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isHovered to false on mouse leave', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
|
||||
// First enter
|
||||
act(() => {
|
||||
result.current.handlers.onMouseEnter();
|
||||
});
|
||||
expect(result.current.isHovered).toBe(true);
|
||||
|
||||
// Then leave
|
||||
act(() => {
|
||||
result.current.handlers.onMouseLeave();
|
||||
});
|
||||
expect(result.current.isHovered).toBe(false);
|
||||
});
|
||||
|
||||
it('should update glow position on mouse move', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
|
||||
// Setup a mock element with getBoundingClientRect
|
||||
const mockElement = document.createElement('div');
|
||||
Object.defineProperty(mockElement, 'getBoundingClientRect', {
|
||||
value: () => ({
|
||||
left: 100,
|
||||
top: 50,
|
||||
width: 200,
|
||||
height: 150,
|
||||
right: 300,
|
||||
bottom: 200,
|
||||
}),
|
||||
});
|
||||
|
||||
// Set the ref to the mock element
|
||||
(result.current.ref as React.MutableRefObject<HTMLDivElement>).current = mockElement;
|
||||
|
||||
act(() => {
|
||||
result.current.handlers.onMouseMove({
|
||||
clientX: 150,
|
||||
clientY: 100,
|
||||
} as React.MouseEvent);
|
||||
});
|
||||
|
||||
expect(result.current.isHovered).toBe(true);
|
||||
expect(result.current.glowStyle.background).toContain('circle at 50px 50px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Glow Style Transitions', () => {
|
||||
it('should show glow when hovered', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
|
||||
act(() => {
|
||||
result.current.handlers.onMouseEnter();
|
||||
});
|
||||
|
||||
expect(result.current.glowStyle.opacity).toBe(1);
|
||||
});
|
||||
|
||||
it('should hide glow when not hovered', () => {
|
||||
const { result } = renderHook(() => useMouseGlow());
|
||||
|
||||
expect(result.current.glowStyle.opacity).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useCallback, useState } from 'react';
|
||||
|
||||
interface MouseGlowState {
|
||||
x: number;
|
||||
y: number;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
interface UseMouseGlowOptions {
|
||||
/** Glow radius in pixels */
|
||||
radius?: number;
|
||||
/** Opacity multiplier (0-1) */
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 水墨晕散鼠标跟随光晕 hook
|
||||
* 用于卡片 hover 时的水墨晕散效果
|
||||
*/
|
||||
export function useMouseGlow(options: UseMouseGlowOptions = {}) {
|
||||
const { radius = 400, opacity = 0.06 } = options;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [glow, setGlow] = useState<MouseGlowState>({ x: 0, y: 0, isHovered: false });
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!ref.current) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
setGlow({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
isHovered: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setGlow(prev => ({ ...prev, isHovered: true }));
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setGlow(prev => ({ ...prev, isHovered: false }));
|
||||
}, []);
|
||||
|
||||
const glowStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 'inherit',
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.5s ease',
|
||||
opacity: glow.isHovered ? 1 : 0,
|
||||
background: `radial-gradient(${radius}px circle at ${glow.x}px ${glow.y}px, rgba(var(--color-brand-rgb), ${opacity}), transparent 40%)`,
|
||||
};
|
||||
|
||||
return {
|
||||
ref,
|
||||
glowStyle,
|
||||
handlers: {
|
||||
onMouseMove: handleMouseMove,
|
||||
onMouseEnter: handleMouseEnter,
|
||||
onMouseLeave: handleMouseLeave,
|
||||
},
|
||||
isHovered: glow.isHovered,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
import { useReducedMotion, getAnimationConfig, getAnimationVariants } from '@/hooks/use-reduced-motion';
|
||||
|
||||
describe('useReducedMotion', () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
@@ -95,3 +95,38 @@ describe('useReducedMotion', () => {
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnimationConfig', () => {
|
||||
it('returns normal config when motion is preferred', () => {
|
||||
const result = getAnimationConfig(false, { duration: 0.5, delay: 0.1, ease: 'easeOut' });
|
||||
expect(result).toEqual({ duration: 0.5, delay: 0.1, ease: 'easeOut' });
|
||||
});
|
||||
|
||||
it('returns zeroed config when reduced motion is preferred', () => {
|
||||
const result = getAnimationConfig(true, { duration: 0.5, delay: 0.1, ease: 'easeOut' });
|
||||
expect(result).toEqual({ duration: 0, delay: 0, ease: 'linear' });
|
||||
});
|
||||
|
||||
it('uses reduced config when provided', () => {
|
||||
const result = getAnimationConfig(
|
||||
true,
|
||||
{ duration: 0.5, delay: 0.1 },
|
||||
{ duration: 0.05, delay: 0, ease: 'linear' }
|
||||
);
|
||||
expect(result).toEqual({ duration: 0.05, delay: 0, ease: 'linear' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAnimationVariants', () => {
|
||||
it('returns normal variants when motion is preferred', () => {
|
||||
const normal = { initial: { opacity: 0 }, animate: { opacity: 1 } };
|
||||
const result = getAnimationVariants(false, normal);
|
||||
expect(result).toBe(normal);
|
||||
});
|
||||
|
||||
it('returns empty variants when reduced motion is preferred', () => {
|
||||
const normal = { initial: { opacity: 0 }, animate: { opacity: 1 } };
|
||||
const result = getAnimationVariants(true, normal);
|
||||
expect(result).toEqual({ initial: {}, animate: {}, exit: {} });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user