feat(home): Hero 右侧看板 Absorb 层渐进描边
hero-product-visual 升级为客户端组件,趋势线改用 stroke-dashoffset 渐进描边: 滚动入视触发、600ms ease-ink 绘出,填充/端点同步淡入,补齐参考埃森哲的 Glance/Skim/Absorb 三层交互模型。reduced-motion 首帧即静态完整线、零动画; IntersectionObserver 不可用时降级静态。刻意未做 KPI count-up(格式化串解析 hacky 且偏装饰,与动效克制偏好冲突)。 Co-Authored-By: 阿睿 <workbuddy@tencent.com>
This commit is contained in:
@@ -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 { TrendingUp, ArrowUpRight, ArrowDownRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
|
|
||||||
// 抽象数据可视化示意(示例数据)——作为品牌「数据驱动」能力的视觉表达,不含具体产品 UI 外壳
|
// 抽象数据可视化示意(示例数据)——作为品牌「数据驱动」能力的视觉表达,不含具体产品 UI 外壳
|
||||||
const KPIS = [
|
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 }) {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={rootRef}
|
||||||
data-testid="hero-product-visual"
|
data-testid="hero-product-visual"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="数据驱动可视化示意(示例数据)"
|
aria-label="数据驱动可视化示意(示例数据)"
|
||||||
@@ -49,7 +86,7 @@ export function HeroProductVisual({ className }: { className?: string }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 趋势图:品牌色曲线 + 渐变填充 */}
|
{/* 趋势图:品牌色曲线 + 渐变填充(Absorb 层:滚动入视时曲线渐进描边,数据「长出来」) */}
|
||||||
<div className="mt-3 border border-border-primary bg-bg-primary p-4">
|
<div className="mt-3 border border-border-primary bg-bg-primary p-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<span className="text-xs font-semibold text-ink">数据增长趋势</span>
|
<span className="text-xs font-semibold text-ink">数据增长趋势</span>
|
||||||
@@ -73,17 +110,40 @@ export function HeroProductVisual({ className }: { className?: string }) {
|
|||||||
<path
|
<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"
|
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)"
|
fill="url(#hero-visual-fill)"
|
||||||
|
style={{
|
||||||
|
opacity: drawn ? 1 : 0,
|
||||||
|
transition: reduceMotion ? undefined : 'opacity 600ms ease 120ms',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<path
|
<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"
|
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"
|
fill="none"
|
||||||
stroke="var(--color-brand)"
|
stroke="var(--color-brand)"
|
||||||
strokeWidth="2.5"
|
strokeWidth="2.5"
|
||||||
strokeLinecap="round"
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default HeroProductVisual;
|
||||||
|
|||||||
Reference in New Issue
Block a user