From f42d444481f6feff96678b2107da8e6424f39d7f Mon Sep 17 00:00:00 2001
From: Hollujay <165713167+Hollujay@users.noreply.github.com>
Date: Wed, 22 Jul 2026 12:34:08 +0000
Subject: [PATCH] Add chart tooltip with formatted values (#211)
---
src/components/Chart.css | 50 ++++++++++++++++++++++++++
src/components/Chart.jsx | 45 +++++++++++++++--------
src/components/Chart.stories.jsx | 50 ++++++++++++++++++++++++++
src/pages/Transfers.jsx | 4 ++-
test/components/Chart.test.jsx | 61 +++++++++++++++++++++++++++++++-
5 files changed, 194 insertions(+), 16 deletions(-)
create mode 100644 src/components/Chart.stories.jsx
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}
-
+
+
+ {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')
+ })
})