diff --git a/src/components/Chart.css b/src/components/Chart.css index b9901e6..78320e2 100644 --- a/src/components/Chart.css +++ b/src/components/Chart.css @@ -9,12 +9,62 @@ margin-top: 0; } +.chart-wrapper { + position: relative; +} + .chart-svg { display: block; width: 100%; height: auto; } +.chart-bar { + cursor: pointer; + transition: fill 0.15s ease; +} + +.chart-tooltip { + position: absolute; + bottom: 100%; + left: 0; + transform: translateX(-50%); + z-index: 10; + padding: 0.35rem 0.6rem; + border-radius: 6px; + background: #0b1120; + border: 1px solid #334155; + color: var(--color-text); + font-size: 0.75rem; + white-space: nowrap; + pointer-events: none; + margin-bottom: 0.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; +} + +.chart-tooltip-value { + font-weight: 600; + font-size: 0.8rem; +} + +.chart-tooltip-label { + font-size: 0.65rem; + color: var(--color-muted, #94a3b8); + max-width: 10rem; + overflow: hidden; + text-overflow: ellipsis; +} + +@media (prefers-contrast: more) { + .chart-tooltip { + border-width: 2px; + background: #000; + } +} + .download-button { margin-top: 1rem; } diff --git a/src/components/Chart.jsx b/src/components/Chart.jsx index 1a31c44..9f4195a 100644 --- a/src/components/Chart.jsx +++ b/src/components/Chart.jsx @@ -1,9 +1,10 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import './Chart.css'; import Button from './Button.jsx'; -export default function Chart({ data, title }) { +export default function Chart({ data, title, formatValue }) { const chartRef = useRef(null); + const [hoveredIndex, setHoveredIndex] = useState(null); const downloadChart = () => { const svg = chartRef.current; @@ -21,22 +22,38 @@ export default function Chart({ data, title }) { }; const maxValue = Math.max(...data.map(d => d.value), 1); + const barCount = data.length; return (

{title}

- - {data.map((d, i) => ( - - ))} - +
+ + {data.map((d, i) => ( + setHoveredIndex(i)} + onMouseLeave={() => setHoveredIndex(null)} + /> + ))} + + {hoveredIndex !== null && ( +
+
+ {formatValue ? formatValue(data[hoveredIndex]) : data[hoveredIndex].value} +
+ {data[hoveredIndex].label && ( +
{data[hoveredIndex].label}
+ )} +
+ )} +
diff --git a/src/components/Chart.stories.jsx b/src/components/Chart.stories.jsx new file mode 100644 index 0000000..ce994d3 --- /dev/null +++ b/src/components/Chart.stories.jsx @@ -0,0 +1,50 @@ +import Chart from './Chart.jsx' +import { formatAmount } from '../utils/format.js' + +export default { + title: 'Components/Chart', + component: Chart, + tags: ['autodocs'], + argTypes: { + title: { control: 'text' }, + data: { control: 'object' }, + formatValue: { control: false } + } +} + +const sampleData = [ + { value: 200, label: 'amina@example.com', currency: 'USD' }, + { value: 120, label: 'GBQAZ7Z3X7...', currency: 'USD' }, + { value: 450, label: 'chidi@example.com', currency: 'USD' }, + { value: 80, label: 'devi@example.com', currency: 'USD' }, + { value: 310, label: 'emeka@example.com', currency: 'USD' } +] + +export const Default = { + args: { + title: 'Recent Transfer Amounts', + data: [ + { value: 200 }, + { value: 120 }, + { value: 450 }, + { value: 80 }, + { value: 310 } + ] + } +} + +export const WithFormattedValues = { + args: { + title: 'Recent Transfer Amounts', + data: sampleData, + formatValue: (d) => formatAmount(d.value, d.currency) + } +} + +export const SingleBar = { + args: { + title: 'Single Transfer', + data: [{ value: 250, label: 'juan@example.com', currency: 'USD' }], + formatValue: (d) => formatAmount(d.value, d.currency) + } +} diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx index 5cd18a2..901a527 100644 --- a/src/pages/Transfers.jsx +++ b/src/pages/Transfers.jsx @@ -1,6 +1,7 @@ import { useCallback, useMemo } from 'react' import { Link, useSearchParams } from 'react-router-dom' import Chart from '../components/Chart.jsx' +import { formatAmount } from '../utils/format.js' import TransferRow from '../components/TransferRow.jsx' import Skeleton from '../components/Skeleton.jsx' import ErrorMessage from '../components/ErrorMessage.jsx' @@ -157,7 +158,8 @@ export default function Transfers() { title="Recent Transfer Amounts" data={filteredTransfers .slice(0, 5) - .map((t) => ({ value: parseFloat(t.sendAmount) }))} + .map((t) => ({ value: parseFloat(t.sendAmount), label: t.recipient, currency: t.from }))} + formatValue={(d) => formatAmount(d.value, d.currency)} /> {filteredTransfers.map((t) => ( diff --git a/test/components/Chart.test.jsx b/test/components/Chart.test.jsx index e0dfbec..7b278e4 100644 --- a/test/components/Chart.test.jsx +++ b/test/components/Chart.test.jsx @@ -1,11 +1,70 @@ import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { describe, expect, it } from 'vitest' import Chart from '../../src/components/Chart.jsx' describe('Chart component', () => { - it('renders correctly', () => { + it('renders title', () => { const data = [{ value: 10 }, { value: 20 }] render() expect(screen.getByText('Test Chart')).toBeInTheDocument() }) + + it('renders the correct number of bars', () => { + const data = [{ value: 10 }, { value: 20 }, { value: 30 }] + const { container } = render() + const bars = container.querySelectorAll('.chart-bar') + expect(bars).toHaveLength(3) + }) + + it('shows tooltip on bar hover', async () => { + const user = userEvent.setup() + const data = [{ value: 10 }, { value: 20 }] + const { container } = render() + const bars = container.querySelectorAll('.chart-bar') + + await user.hover(bars[0]) + expect(screen.getByRole('tooltip')).toBeInTheDocument() + + await user.unhover(bars[0]) + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + }) + + it('displays raw value in tooltip by default', async () => { + const user = userEvent.setup() + const data = [{ value: 42 }, { value: 20 }] + const { container } = render() + const bars = container.querySelectorAll('.chart-bar') + + await user.hover(bars[0]) + expect(screen.getByRole('tooltip')).toHaveTextContent('42') + }) + + it('displays formatted value when formatValue is provided', async () => { + const user = userEvent.setup() + const data = [{ value: 42, currency: 'USD' }, { value: 20 }] + const { container } = render( + `$${d.value.toFixed(2)} ${d.currency || ''}`.trim()} + /> + ) + const bars = container.querySelectorAll('.chart-bar') + + await user.hover(bars[0]) + expect(screen.getByRole('tooltip')).toHaveTextContent('$42.00 USD') + }) + + it('displays label in tooltip when data items have a label', async () => { + const user = userEvent.setup() + const data = [{ value: 100, label: 'alice@example.com' }, { value: 200 }] + const { container } = render() + const bars = container.querySelectorAll('.chart-bar') + + await user.hover(bars[0]) + const tooltip = screen.getByRole('tooltip') + expect(tooltip).toHaveTextContent('100') + expect(tooltip).toHaveTextContent('alice@example.com') + }) })