Dev #25
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, jest } from '@jest/globals';
|
||||
import { render } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import HeroProductVisual from './hero-product-visual';
|
||||
|
||||
jest.mock('@/hooks/use-reduced-motion', () => ({
|
||||
useReducedMotion: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
describe('HeroProductVisual - Absorb 层(渐进描边)', () => {
|
||||
it('renders the brand-red trend line path', () => {
|
||||
render(<HeroProductVisual />);
|
||||
const path = document.querySelector('.hero-trend-line');
|
||||
expect(path).toBeInTheDocument();
|
||||
expect(path).toHaveAttribute('stroke', 'var(--color-brand)');
|
||||
});
|
||||
|
||||
it('renders the trend line statically (no draw animation) under reduced motion', () => {
|
||||
const { useReducedMotion } = require('@/hooks/use-reduced-motion');
|
||||
(useReducedMotion as jest.Mock).mockReturnValue(true);
|
||||
render(<HeroProductVisual />);
|
||||
const path = document.querySelector('.hero-trend-line') as SVGPathElement;
|
||||
expect(path).toBeInTheDocument();
|
||||
// reduced-motion:入场即为完整描边(strokeDashoffset = 0),不播放描边动画
|
||||
expect(path.style.strokeDashoffset).toBe('0');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { TrendingUp, ArrowUpRight, ArrowDownRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
|
||||
// 抽象数据可视化示意(示例数据)——作为品牌「数据驱动」能力的视觉表达,不含具体产品 UI 外壳
|
||||
const KPIS = [
|
||||
@@ -19,9 +23,42 @@ function TrendBadge({ trend, label }: { trend: 'up' | 'down'; label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const EASE_INK = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
||||
// 描边路径长度上限(宽松覆盖实际路径,确保 offset 动画完整绘制整条线)
|
||||
const TREND_PATH_LENGTH = 600;
|
||||
|
||||
export function HeroProductVisual({ className }: { className?: string }) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const reduceMotion = useReducedMotion();
|
||||
// reduced-motion:首帧即为静态完整线,杜绝任何一闪
|
||||
const [drawn, setDrawn] = useState(reduceMotion);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduceMotion) {
|
||||
setDrawn(true);
|
||||
return;
|
||||
}
|
||||
const el = rootRef.current;
|
||||
if (!el || typeof IntersectionObserver === 'undefined') {
|
||||
setDrawn(true); // 无 IO 支持的降级:直接静态呈现完整线
|
||||
return;
|
||||
}
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) {
|
||||
setDrawn(true);
|
||||
io.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.25 },
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [reduceMotion]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-testid="hero-product-visual"
|
||||
role="img"
|
||||
aria-label="数据驱动可视化示意(示例数据)"
|
||||
@@ -49,7 +86,7 @@ export function HeroProductVisual({ className }: { className?: string }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 趋势图:品牌色曲线 + 渐变填充 */}
|
||||
{/* 趋势图:品牌色曲线 + 渐变填充(Absorb 层:滚动入视时曲线渐进描边,数据「长出来」) */}
|
||||
<div className="mt-3 border border-border-primary bg-bg-primary p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-ink">数据增长趋势</span>
|
||||
@@ -73,17 +110,40 @@ export function HeroProductVisual({ className }: { className?: string }) {
|
||||
<path
|
||||
d="M0,104 C36,96 56,80 88,82 C120,84 144,60 176,58 C208,56 232,38 264,40 C296,42 320,24 360,20 L360,120 L0,120 Z"
|
||||
fill="url(#hero-visual-fill)"
|
||||
style={{
|
||||
opacity: drawn ? 1 : 0,
|
||||
transition: reduceMotion ? undefined : 'opacity 600ms ease 120ms',
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
className="hero-trend-line"
|
||||
d="M0,104 C36,96 56,80 88,82 C120,84 144,60 176,58 C208,56 232,38 264,40 C296,42 320,24 360,20"
|
||||
fill="none"
|
||||
stroke="var(--color-brand)"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
style={{
|
||||
strokeDasharray: TREND_PATH_LENGTH,
|
||||
strokeDashoffset: drawn ? 0 : TREND_PATH_LENGTH,
|
||||
transition: reduceMotion
|
||||
? undefined
|
||||
: `stroke-dashoffset 600ms ${EASE_INK}`,
|
||||
}}
|
||||
/>
|
||||
<circle
|
||||
cx="360"
|
||||
cy="20"
|
||||
r="4"
|
||||
fill="var(--color-brand)"
|
||||
style={{
|
||||
opacity: drawn ? 1 : 0,
|
||||
transition: reduceMotion ? undefined : 'opacity 400ms ease 520ms',
|
||||
}}
|
||||
/>
|
||||
<circle cx="360" cy="20" r="4" fill="var(--color-brand)" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeroProductVisual;
|
||||
|
||||
Reference in New Issue
Block a user