import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { axe } from 'vitest-axe'
import { Sparkline, SparklineChart, SparklineValue, SparklineLabel } from './sparkline'
import { DirectionProvider } from '../../providers/direction-provider'
const DATA = [4, 8, 6, 10, 7, 12]
const LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
/** jsdom returns a 0×0 rect; give the chart a real width so pointer mapping runs. */
function stubRect(el: Element, width = 100) {
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
left: 0,
top: 0,
right: width,
bottom: 48,
width,
height: 48,
toJSON: () => ({}),
} as DOMRect)
}
describe('Sparkline', () => {
it('renders a line path for the line variant', () => {
const { container } = render(
,
)
expect(container.querySelector('[data-slot=sparkline-line]')).toBeInTheDocument()
expect(container.querySelector('[data-slot=sparkline-area]')).not.toBeInTheDocument()
})
it('renders a baseline-anchored fill for the area variant', () => {
const { container } = render(
,
)
expect(container.querySelector('[data-slot=sparkline-fill]')).toBeInTheDocument()
expect(container.querySelector('[data-slot=sparkline-baseline]')).toBeInTheDocument()
expect(container.querySelector('[data-slot=sparkline-line]')).toBeInTheDocument()
})
it('line variant is stroke-only by default, gaining a gradient fill only when `fill` is set', () => {
const { container, rerender } = render(
,
)
expect(container.querySelector('[data-slot=sparkline-fill]')).not.toBeInTheDocument()
expect(container.querySelector('[data-slot=sparkline-baseline]')).not.toBeInTheDocument()
rerender(
,
)
expect(container.querySelector('[data-slot=sparkline-fill]')).toBeInTheDocument()
expect(container.querySelector('[data-slot=sparkline-baseline]')).not.toBeInTheDocument()
})
it('renders one column per data point plus a baseline for the column variant', () => {
const { container } = render(
,
)
expect(container.querySelectorAll('[data-slot=sparkline-column]')).toHaveLength(DATA.length)
expect(container.querySelector('[data-slot=sparkline-baseline]')).toBeInTheDocument()
})
it('column highlight is gated on `indicator`, independent of the tooltip', () => {
const { container } = render(
,
)
const chart = screen.getByRole('img')
stubRect(chart, 100)
fireEvent.pointerMove(chart, { clientX: 50 })
// Tooltip renders, but with indicator off no column is highlighted.
expect(container.querySelector('[data-slot=sparkline-tooltip]')).toBeInTheDocument()
expect(container.querySelector('[data-active]')).not.toBeInTheDocument()
})
it('columns pivot on the baseline: positive bars round the top, negatives round the bottom', () => {
const { container } = render(
,
)
const bars = container.querySelectorAll('[data-slot=sparkline-column]')
expect(bars[0]!.className).toContain('rounded-t-')
expect(bars[1]!.className).toContain('rounded-b-')
})
it('sets the accent color as a CSS variable on the root', () => {
render(
,
)
expect(screen.getByTestId('root').style.getPropertyValue('--sparkline-color')).toBe('var(--color-success)')
})
it('SparklineValue shows the last value by default, formatted', () => {
render(
,
)
expect(screen.getByText('$12')).toBeInTheDocument()
})
it('SparklineLabel renders nothing without labels and the last label with them', () => {
const { rerender } = render(
,
)
expect(screen.queryByTestId('label')).not.toBeInTheDocument()
rerender(
,
)
expect(screen.getByTestId('label')).toHaveTextContent('Sat')
})
it('does not render the tooltip until hovered', () => {
const { container } = render(
,
)
expect(container.querySelector('[data-slot=sparkline-tooltip]')).not.toBeInTheDocument()
})
it('activates the nearest point on hover: updates value, tooltip, and onActiveChange', () => {
const onActiveChange = vi.fn()
const { container } = render(
,
)
// Mount must NOT fire a phantom null before any interaction.
expect(onActiveChange).not.toHaveBeenCalled()
const chart = screen.getByRole('img')
stubRect(chart, 100)
// clientX 0 → t=0 → nearest index 0 (value 4).
fireEvent.pointerMove(chart, { clientX: 0 })
expect(onActiveChange).toHaveBeenLastCalledWith({ index: 0, value: 4, label: 'Mon' })
expect(container.querySelector('[data-slot=sparkline-value]')).toHaveTextContent('4')
expect(container.querySelector('[data-slot=sparkline-tooltip]')).toHaveTextContent('Mon')
// clientX 100 → t=1 → last index 5 (value 12).
fireEvent.pointerMove(chart, { clientX: 100 })
expect(onActiveChange).toHaveBeenLastCalledWith({ index: 5, value: 12, label: 'Sat' })
fireEvent.pointerLeave(chart)
expect(onActiveChange).toHaveBeenLastCalledWith(null)
})
it('activates on a stationary touch tap and clears when the finger lifts', () => {
const onActiveChange = vi.fn()
render(
,
)
const chart = screen.getByRole('img')
stubRect(chart, 100)
// A touch tap fires pointerdown without a preceding move - it must activate immediately.
fireEvent.pointerDown(chart, { clientX: 100, pointerType: 'touch' })
expect(onActiveChange).toHaveBeenLastCalledWith({ index: 5, value: 12, label: undefined })
// Lifting the finger dismisses (there is no pointerleave on touch).
fireEvent.pointerUp(chart, { pointerType: 'touch' })
expect(onActiveChange).toHaveBeenLastCalledWith(null)
})
it('keeps a mouse reading visible after click (pointerup does not clear for mouse)', () => {
const onActiveChange = vi.fn()
render(
,
)
const chart = screen.getByRole('img')
stubRect(chart, 100)
fireEvent.pointerMove(chart, { clientX: 0, pointerType: 'mouse' })
fireEvent.pointerUp(chart, { pointerType: 'mouse' })
expect(onActiveChange).toHaveBeenLastCalledWith({ index: 0, value: 4, label: undefined })
})
it('mirrors the active index under RTL', () => {
const onActiveChange = vi.fn()
render(
,
)
const chart = screen.getByRole('img')
stubRect(chart, 100)
// Pointer at the left edge maps to the last index when mirrored.
fireEvent.pointerMove(chart, { clientX: 0 })
expect(onActiveChange).toHaveBeenLastCalledWith(expect.objectContaining({ index: DATA.length - 1 }))
})
it('forwards className onto the chart wrapper', () => {
render(
,
)
expect(screen.getByRole('img', { name: 'Revenue' }).className).toContain('custom-chart')
})
it('renders nothing for an empty dataset', () => {
const { container } = render(
,
)
expect(container.querySelector('[data-slot=sparkline-svg]')).not.toBeInTheDocument()
})
it('has no accessibility violations', async () => {
const { container } = render(
,
)
expect(await axe(container)).toHaveNoViolations()
})
})