Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions app/(marketing)/_sections/kpi-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -23,10 +19,7 @@ const AnimatedMetric: React.FC<{ metric: KpiMetric }> = ({ metric }) => {
const displayValue = formatNumber(animatedValue);

return (
<div
ref={ref}
className="flex flex-col items-center justify-center rounded-xl p-4 md:p-6 text-center"
>
<div className="flex flex-col items-center justify-center rounded-xl p-4 md:p-6 text-center">
<div
className="w-[166px] h-[40px] flex items-center justify-center bg-gradient-to-br from-[#5B21B6] via-[#6B21A8] to-[#7C3AED] text-[30px] font-extrabold text-black mb-1"
aria-live="off"
Expand Down
72 changes: 54 additions & 18 deletions components/disputes/shared/TallyBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import * as React from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { Check, X } from 'lucide-react';
import { TallySide } from '@/types/disputes';
import { cn } from '@/lib/utils';
import { useCountUp } from '@/hooks/use-count-up';

interface TallyBarProps {
tally: [TallySide, TallySide];
Expand All @@ -16,12 +19,37 @@ function normalise(a: number, b: number): [number, number] {
export function TallyBar({ tally, showAmounts = false }: TallyBarProps) {
const [left, right] = tally;
const [leftPct, rightPct] = normalise(left.percentage, right.percentage);
const reducedMotion = useReducedMotion();
const animatedLeftPct = useCountUp(leftPct, 0, 400);
const animatedRightPct = useCountUp(rightPct, 0, 400);
const animatedLeftAmount = useCountUp(left.amount, 0, 400);
const animatedRightAmount = useCountUp(right.amount, 0, 400);
const [liveMessage, setLiveMessage] = React.useState('');
const lastAnnouncedMessage = React.useRef('');
const hasMountedRef = React.useRef(false);

const leftPctLabel = `${animatedLeftPct.toFixed(1)}%`;
const rightPctLabel = `${animatedRightPct.toFixed(1)}%`;
const leftAmountLabel = `${Math.round(animatedLeftAmount).toLocaleString()} tokens`;
const rightAmountLabel = `${Math.round(animatedRightAmount).toLocaleString()} tokens`;

// Accessible summary of the whole bar.
const ariaSummary = showAmounts
? `${left.label}: ${leftPct.toFixed(1)} percent, ${left.amount.toLocaleString()} tokens. ` +
`${right.label}: ${rightPct.toFixed(1)} percent, ${right.amount.toLocaleString()} tokens.`
: `${left.label}: ${leftPct.toFixed(1)} percent. ${right.label}: ${rightPct.toFixed(1)} percent.`;
? `${left.label}: ${animatedLeftPct.toFixed(1)} percent, ${leftAmountLabel}. ` +
`${right.label}: ${animatedRightPct.toFixed(1)} percent, ${rightAmountLabel}.`
: `${left.label}: ${animatedLeftPct.toFixed(1)} percent. ${right.label}: ${animatedRightPct.toFixed(1)} percent.`;

React.useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
lastAnnouncedMessage.current = ariaSummary;
return;
}

if (ariaSummary && ariaSummary !== lastAnnouncedMessage.current) {
setLiveMessage(ariaSummary);
lastAnnouncedMessage.current = ariaSummary;
}
}, [ariaSummary]);

return (
<div className="w-full space-y-1">
Expand All @@ -32,8 +60,8 @@ export function TallyBar({ tally, showAmounts = false }: TallyBarProps) {

{showAmounts && (
<div className="flex justify-between text-xs text-muted-foreground">
<span>{left.amount.toLocaleString()} tokens</span>
<span>{right.amount.toLocaleString()} tokens</span>
<span>{leftAmountLabel}</span>
<span>{rightAmountLabel}</span>
</div>
)}

Expand All @@ -42,31 +70,39 @@ export function TallyBar({ tally, showAmounts = false }: TallyBarProps) {
role="img"
aria-label={ariaSummary}
>
<div
<motion.div
className={cn(
'bg-chart-1 transition-all duration-300 flex items-center justify-center',
'bg-chart-1 flex items-center justify-center',
'text-[10px] font-medium text-white'
)}
style={{ width: `${leftPct}%` }}
aria-label={`${left.label}: ${leftPct.toFixed(1)}%`}
style={{ width: `${animatedLeftPct}%` }}
animate={{ width: `${animatedLeftPct}%` }}
transition={{ type: 'spring', damping: 26, stiffness: 240, duration: reducedMotion ? 0 : undefined }}
aria-label={`${left.label}: ${animatedLeftPct.toFixed(1)}%`}
>
<Check className="h-2.5 w-2.5" aria-hidden="true" />
</div>
<div
</motion.div>
<motion.div
className={cn(
'bg-chart-2 transition-all duration-300 flex items-center justify-center',
'bg-chart-2 flex items-center justify-center',
'text-[10px] font-medium text-white'
)}
style={{ width: `${rightPct}%` }}
aria-label={`${right.label}: ${rightPct.toFixed(1)}%`}
style={{ width: `${animatedRightPct}%` }}
animate={{ width: `${animatedRightPct}%` }}
transition={{ type: 'spring', damping: 26, stiffness: 240, duration: reducedMotion ? 0 : undefined }}
aria-label={`${right.label}: ${animatedRightPct.toFixed(1)}%`}
>
<X className="h-2.5 w-2.5" aria-hidden="true" />
</div>
</motion.div>
</div>

<div className="flex justify-between text-xs text-muted-foreground">
<span>{leftPct.toFixed(1)}%</span>
<span>{rightPct.toFixed(1)}%</span>
<span>{leftPctLabel}</span>
<span>{rightPctLabel}</span>
</div>

<div className="sr-only" aria-live="polite" aria-atomic="true">
{liveMessage}
</div>
</div>
);
Expand Down
72 changes: 71 additions & 1 deletion components/disputes/shared/__tests__/TallyBar.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(<TallyBar tally={tally} showAmounts />);

rerender(<TallyBar tally={[makeSide('Yes', 700, 70), makeSide('No', 300, 30)]} showAmounts />);

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(<TallyBar tally={tally} showAmounts />);

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(<TallyBar tally={tally} showAmounts />);

rerender(<TallyBar tally={[makeSide('Yes', 300, 30), makeSide('No', 700, 70)]} showAmounts />);

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(<TallyBar tally={tally} showAmounts />);

rerender(<TallyBar tally={[makeSide('Yes', 0, 0), makeSide('No', 0, 0)]} showAmounts />);

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),
Expand Down
1 change: 1 addition & 0 deletions hooks/use-count-up.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useCountUp } from '@/lib/use-count-up';
94 changes: 44 additions & 50 deletions lib/use-count-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>, number] => {
const [currentValue, setCurrentValue] = React.useState(startValue);
const ref = React.useRef<HTMLDivElement>(null);
const animationRef = React.useRef<number>(0);
const hasAnimated = React.useRef<boolean>(false);
duration: number = 400
): number => {
const [currentValue, setCurrentValue] = React.useState(endValue);
const animationRef = React.useRef<number | null>(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;
};