import { render, screen, act } from '@testing-library/react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { axe } from 'vitest-axe'
import { TextAnimate, type TextAnimateUnitContext } from './text-animate'
import { ReducedMotionProvider } from '../../providers/reduced-motion-provider'
afterEach(() => {
vi.useRealTimers()
})
describe('TextAnimate', () => {
it('always exposes the full text to assistive tech via an sr-only copy', () => {
render(
Hello world
,
)
// The visual layer is aria-hidden; the sr-only node carries the real text.
expect(screen.getByText('Hello world')).toHaveClass('sr-only')
})
it('splits into per-character units at full progress', () => {
const { container } = render(
abc
,
)
const units = container.querySelectorAll('[data-slot="text-animate-unit"]')
expect(units).toHaveLength(3)
expect(Array.from(units, (u) => u.textContent).join('')).toBe('abc')
})
it('splits by word when the effect/level calls for it', () => {
const { container } = render(
one two three
,
)
const units = container.querySelectorAll('[data-slot="text-animate-unit"]')
expect(units).toHaveLength(3)
expect(units[0]).toHaveTextContent('one')
expect(units[2]).toHaveTextContent('three')
})
it('sweeps the shimmer glare across the whole string and returns to the same frame at both ends of the loop', () => {
const { container, rerender } = render(
Generating response
,
)
const visual = () => container.querySelector('[aria-hidden="true"]') as HTMLElement
// The gradient paints one band over the whole string, so it lives on the wrapper, not the units.
expect(visual().style.backgroundClip).toBe('text')
expect(visual().style.backgroundImage).toContain('linear-gradient')
// Band parked off the leading edge.
expect(visual().style.backgroundPosition).toBe('100% 0px')
rerender(
Generating response
,
)
expect(visual().style.backgroundPosition).toBe('50% 0px')
// Parked off the trailing edge, which paints identically to progress 0.
rerender(
Generating response
,
)
expect(visual().style.backgroundPosition).toBe('0% 0px')
})
it('keeps the shimmer glare identical however the text is split, so `by` stays orthogonal', () => {
const positions = (['line', 'word', 'char'] as const).map((by) => {
const { container, unmount } = render(
Generating a response
,
)
const visual = container.querySelector('[aria-hidden="true"]') as HTMLElement
const units = container.querySelectorAll('[data-slot="text-animate-unit"]').length
const result = { by, units, position: visual.style.backgroundPosition, image: visual.style.backgroundImage }
unmount()
return result
})
// Splitting finer must change the unit count without touching the glare.
expect(positions.map((p) => p.units)).toEqual([1, 3, 19])
expect(new Set(positions.map((p) => p.position)).size).toBe(1)
expect(new Set(positions.map((p) => p.image)).size).toBe(1)
})
it('drops the shimmer gradient under reduced motion so the text keeps its own color', () => {
const { container } = render(
Generating response
,
)
const visual = container.querySelector('[aria-hidden="true"]') as HTMLElement
expect(visual.style.backgroundImage).toBe('')
expect(visual.style.webkitTextFillColor).toBe('')
expect(visual).toHaveTextContent('Generating response')
})
it('drives the animation from a controlled progress value', () => {
const { container, rerender } = render(
Hi
,
)
const visual = container.querySelector('[aria-hidden="true"]')!
// Nothing typed yet (aside from the caret element which has no text).
expect(visual.textContent).toBe('')
rerender(
Hi
,
)
expect(visual.textContent).toContain('Hi')
})
it('passes a custom effect function the local progress and unit context', () => {
const effect = vi.fn((p: number, _ctx: TextAnimateUnitContext) => ({ style: { opacity: p } }))
render(
ab
,
)
expect(effect).toHaveBeenCalled()
const [, ctx] = effect.mock.calls[0]!
expect(ctx).toMatchObject({ total: 2, by: 'char', globalProgress: 0.5 })
})
it('advances the built-in clock with requestAnimationFrame to reveal text over time', async () => {
// Each frame ticks the mock clock forward, so elapsed time actually grows.
let t = 0
vi.spyOn(performance, 'now').mockImplementation(() => t)
const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
t += 700
return setTimeout(() => cb(t), 0) as unknown as number
})
const { container } = render(
Hi
,
)
const visual = container.querySelector('[aria-hidden="true"]')!
expect(visual.textContent).toBe('')
for (let i = 0; i < 6; i++) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
}
expect(visual.textContent).toContain('Hi')
rafSpy.mockRestore()
})
it('renders the final, fully-revealed frame under reduced motion', () => {
const { container } = render(
Hello
,
)
const visual = container.querySelector('[aria-hidden="true"]')!
// reduced motion snaps the internal clock's output straight to the last frame.
expect(visual.textContent).toContain('Hello')
})
it('has no accessibility violations', async () => {
const { container } = render(
Accessible animated text
,
)
expect(await axe(container)).toHaveNoViolations()
})
})