From 46ee84409a4212e1aef738de203bedfcf4acf042 Mon Sep 17 00:00:00 2001 From: goodness-cpu Date: Sat, 27 Jun 2026 16:46:13 +0000 Subject: [PATCH] feat: animated tally bar in dispute panel --- app/(marketing)/_sections/kpi-strip.tsx | 11 +-- components/disputes/shared/TallyBar.tsx | 72 ++++++++++---- .../shared/__tests__/TallyBar.test.tsx | 72 +++++++++++++- hooks/use-count-up.ts | 1 + lib/use-count-up.ts | 94 +++++++++---------- 5 files changed, 172 insertions(+), 78 deletions(-) create mode 100644 hooks/use-count-up.ts diff --git a/app/(marketing)/_sections/kpi-strip.tsx b/app/(marketing)/_sections/kpi-strip.tsx index c4a0e10..6239cae 100644 --- a/app/(marketing)/_sections/kpi-strip.tsx +++ b/app/(marketing)/_sections/kpi-strip.tsx @@ -5,11 +5,7 @@ import { kpis, KpiMetric } from "../../content/kpis-sample"; import { useCountUp } from "@/lib/use-count-up"; const AnimatedMetric: React.FC<{ metric: KpiMetric }> = ({ metric }) => { - const [ref, animatedValue] = useCountUp( - metric.value, - 0, - 2000 - ); + const animatedValue = useCountUp(metric.value, 0, 2000); const formatNumber = (num: number) => { const fixedNum = num.toFixed(metric.decimals ?? 0); @@ -23,10 +19,7 @@ const AnimatedMetric: React.FC<{ metric: KpiMetric }> = ({ metric }) => { const displayValue = formatNumber(animatedValue); return ( -
+
{ + if (!hasMountedRef.current) { + hasMountedRef.current = true; + lastAnnouncedMessage.current = ariaSummary; + return; + } + + if (ariaSummary && ariaSummary !== lastAnnouncedMessage.current) { + setLiveMessage(ariaSummary); + lastAnnouncedMessage.current = ariaSummary; + } + }, [ariaSummary]); return (
@@ -32,8 +60,8 @@ export function TallyBar({ tally, showAmounts = false }: TallyBarProps) { {showAmounts && (
- {left.amount.toLocaleString()} tokens - {right.amount.toLocaleString()} tokens + {leftAmountLabel} + {rightAmountLabel}
)} @@ -42,31 +70,39 @@ export function TallyBar({ tally, showAmounts = false }: TallyBarProps) { role="img" aria-label={ariaSummary} > -
-
+
+
- {leftPct.toFixed(1)}% - {rightPct.toFixed(1)}% + {leftPctLabel} + {rightPctLabel} +
+ +
+ {liveMessage}
); diff --git a/components/disputes/shared/__tests__/TallyBar.test.tsx b/components/disputes/shared/__tests__/TallyBar.test.tsx index 80e6929..86849eb 100644 --- a/components/disputes/shared/__tests__/TallyBar.test.tsx +++ b/components/disputes/shared/__tests__/TallyBar.test.tsx @@ -1,7 +1,23 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { TallyBar } from '../TallyBar'; import { TallySide } from '@/types/disputes'; +const matchMediaMock = jest.fn(); + +beforeEach(() => { + matchMediaMock.mockImplementation((query: string) => ({ + matches: query === '(prefers-reduced-motion: reduce)' ? false : false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + window.matchMedia = matchMediaMock as typeof window.matchMedia; +}); + const makeSide = (label: string, amount: number, percentage: number): TallySide => ({ label, amount, @@ -91,6 +107,60 @@ describe('TallyBar', () => { expect(label).toMatch(/40\.0/); }); + it('renders a polite live region that announces updated totals', async () => { + const tally: [TallySide, TallySide] = [makeSide('Yes', 500, 60), makeSide('No', 333, 40)]; + const { rerender } = render(); + + rerender(); + + await waitFor(() => { + const liveRegion = screen.getByText(/Yes: 70\.0 percent, 700 tokens/); + expect(liveRegion).toHaveAttribute('aria-live', 'polite'); + }); + }); + + it('uses the finalized values immediately when reduced motion is preferred', () => { + matchMediaMock.mockImplementation((query: string) => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + + const tally: [TallySide, TallySide] = [makeSide('Yes', 500, 60), makeSide('No', 333, 40)]; + render(); + + expect(screen.getByText('60.0%')).toBeInTheDocument(); + expect(screen.getByText('500 tokens')).toBeInTheDocument(); + }); + + it('supports negative deltas without breaking the bar', async () => { + const tally: [TallySide, TallySide] = [makeSide('Yes', 500, 60), makeSide('No', 333, 40)]; + const { rerender } = render(); + + rerender(); + + await waitFor(() => { + expect(screen.getByText('30.0%')).toBeInTheDocument(); + expect(screen.getByText('70.0%')).toBeInTheDocument(); + }); + }); + + it('handles a zero baseline by rendering a stable 50/50 split', async () => { + const tally: [TallySide, TallySide] = [makeSide('Yes', 0, 0), makeSide('No', 0, 0)]; + const { rerender } = render(); + + rerender(); + + await waitFor(() => { + expect(screen.getAllByText('50.0%')).toHaveLength(2); + }); + }); + it('includes token amounts in the aria-label when showAmounts is true', () => { const tally: [TallySide, TallySide] = [ makeSide('Yes', 1234, 60), diff --git a/hooks/use-count-up.ts b/hooks/use-count-up.ts new file mode 100644 index 0000000..c899c1e --- /dev/null +++ b/hooks/use-count-up.ts @@ -0,0 +1 @@ +export { useCountUp } from '@/lib/use-count-up'; diff --git a/lib/use-count-up.ts b/lib/use-count-up.ts index 5409f95..bd93b61 100644 --- a/lib/use-count-up.ts +++ b/lib/use-count-up.ts @@ -2,76 +2,70 @@ import * as React from 'react'; /** - * Custom hook to animate a number count-up. - * Uses requestAnimationFrame and IntersectionObserver. - * @param endValue - The final value to count up to. - * @param startValue - The starting value (default 0). - * @param duration - The duration of the animation in milliseconds (default 2000). - * @returns An array containing the ref and the current animated value. + * Animates a numeric value with requestAnimationFrame while respecting reduced-motion settings. + * The animation uses the current rendered value as the start point for subsequent updates. */ export const useCountUp = ( endValue: number, startValue: number = 0, - duration: number = 2000 -): [React.RefObject, number] => { - const [currentValue, setCurrentValue] = React.useState(startValue); - const ref = React.useRef(null); - const animationRef = React.useRef(0); - const hasAnimated = React.useRef(false); + duration: number = 400 +): number => { + const [currentValue, setCurrentValue] = React.useState(endValue); + const animationRef = React.useRef(null); + const currentValueRef = React.useRef(endValue); React.useEffect(() => { - // Check for reduced motion preference - const prefersReducedMotion = window.matchMedia( - '(prefers-reduced-motion: reduce)' - ).matches; + if (typeof window === 'undefined') { + setCurrentValue(endValue); + currentValueRef.current = endValue; + return; + } - // If reduced motion is preferred, just set the final value instantly - if (prefersReducedMotion) { + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (prefersReducedMotion || duration <= 0) { setCurrentValue(endValue); + currentValueRef.current = endValue; return; } - const element = ref.current; - if (!element) return; + const start = currentValueRef.current; + const delta = endValue - start; - // IntersectionObserver to start animation when element is visible - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting && !hasAnimated.current) { - hasAnimated.current = true; - let startTime: number | null = null; + if (delta === 0) { + setCurrentValue(endValue); + currentValueRef.current = endValue; + return; + } - const easeOutQuint = (t: number) => 1 - Math.pow(1 - t, 5); - const step = (timestamp: number) => { - if (!startTime) startTime = timestamp; - const progress = timestamp - startTime; - const linear = Math.min(progress / duration, 1); // 0 to 1 - const eased = easeOutQuint(linear); + let startTime: number | null = null; + const step = (timestamp: number) => { + if (startTime === null) { + startTime = timestamp; + } - const nextValue = startValue + (endValue - startValue) * eased; - setCurrentValue(nextValue); + const progress = Math.min((timestamp - startTime) / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + const nextValue = start + delta * eased; - if (linear < 1) { - animationRef.current = requestAnimationFrame(step); - } - }; + currentValueRef.current = nextValue; + setCurrentValue(nextValue); - animationRef.current = requestAnimationFrame(step); - } - }, - { threshold: 0.5 } // Trigger when 50% of the element is visible - ); + if (progress < 1) { + animationRef.current = window.requestAnimationFrame(step); + } else { + currentValueRef.current = endValue; + setCurrentValue(endValue); + } + }; - observer.observe(element); + animationRef.current = window.requestAnimationFrame(step); - // Cleanup function return () => { - observer.unobserve(element); - if (animationRef.current) { - cancelAnimationFrame(animationRef.current); + if (animationRef.current !== null) { + window.cancelAnimationFrame(animationRef.current); } }; - }, [endValue, startValue, duration]); + }, [duration, endValue]); - return [ref, currentValue]; + return currentValue; }; \ No newline at end of file