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:
2026-09-01 08:02:56 +08:00
co-authored by 阿睿
parent 549c151c6a
commit ac4c853b71
2 changed files with 89 additions and 2 deletions
@@ -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;