import { render, screen, waitFor } from '@testing-library/react'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { axe } from 'vitest-axe'
import { Thumbnail } from './thumbnail'
// jsdom does not actually load images. Base UI Avatar uses a pre-loader that
// listens for `load`/`error` on a probe Image - without this stub the probe
// never resolves and the underlying
never mounts.
const ORIGINAL_SRC_DESCRIPTOR = Object.getOwnPropertyDescriptor(window.Image.prototype, 'src')
beforeAll(() => {
Object.defineProperty(window.Image.prototype, 'src', {
configurable: true,
set(value: string) {
this.setAttribute('src', value)
queueMicrotask(() => {
if (value.includes('broken')) {
this.dispatchEvent(new Event('error'))
this.onerror?.(new Event('error') as Event)
} else {
this.dispatchEvent(new Event('load'))
this.onload?.(new Event('load') as Event)
}
})
},
get() {
return this.getAttribute('src') ?? ''
},
})
})
afterAll(() => {
if (ORIGINAL_SRC_DESCRIPTOR) {
Object.defineProperty(window.Image.prototype, 'src', ORIGINAL_SRC_DESCRIPTOR)
}
})
const ROUNDED_RADIUS_CLASS = 'rounded-[calc(tan(atan2(var(--radius-md),2.5rem))*100%)]'
describe('Thumbnail', () => {
it('renders the root with data-slot and default md size + rounded shape', () => {
const { container } = render()
const root = container.querySelector('[data-slot="thumbnail"]') as HTMLElement
expect(root).not.toBeNull()
expect(root.tagName).toBe('DIV')
expect(root.className).toContain('size-[1em]')
expect(root.className).toContain('text-[2.5rem]')
expect(root.className).toContain(ROUNDED_RADIUS_CLASS)
expect(root.className).toContain('overflow-hidden')
expect(root.className).toContain('bg-background-muted')
})
it('applies the preset font-size class for each size', () => {
const { container } = render(
,
)
const root = container.querySelector('[data-slot="thumbnail"]') as HTMLElement
expect(root.className).toContain('text-[3rem]')
})
it('uses rounded-full when shape="circle"', () => {
const { container } = render()
const root = container.querySelector('[data-slot="thumbnail"]') as HTMLElement
expect(root.className).toContain('rounded-full')
expect(root.className).not.toContain(ROUNDED_RADIUS_CLASS)
})
it('drives numeric size via inline font-size and skips the preset text class', () => {
const { container } = render()
const root = container.querySelector('[data-slot="thumbnail"]') as HTMLElement
expect(root.style.fontSize).toBe('56px')
expect(root.className).toContain('size-[1em]')
expect(root.className).not.toContain('text-[2.5rem]')
})
it('lets a caller-provided style override the numeric font-size', () => {
const { container } = render()
const root = container.querySelector('[data-slot="thumbnail"]') as HTMLElement
expect(root.style.fontSize).toBe('80px')
})
it('renders an
with src/alt for the image variant', async () => {
const { container } = render()
const img = await waitFor(() => {
const el = container.querySelector('[data-slot="thumbnail-image"]') as HTMLImageElement | null
if (!el) throw new Error('thumbnail-image not mounted')
return el
})
expect(img.tagName).toBe('IMG')
expect(img.getAttribute('src')).toBe('https://example.com/peak.jpg')
expect(img.getAttribute('alt')).toBe('Mountain peak')
expect(img.className).toContain('object-cover')
expect(img.className).toContain('rounded-[inherit]')
})
it('shows the fallback icon when the image errors', async () => {
const { container } = render()
const fallback = await waitFor(() => {
const el = container.querySelector('[data-slot="thumbnail-fallback"]')
if (!el) throw new Error('thumbnail-fallback not mounted')
return el as HTMLElement
})
expect(fallback.querySelector('[data-slot="thumbnail-fallback-icon"]')).not.toBeNull()
})
it('fires onLoadingStatusChange when the image resolves', async () => {
const handler = vi.fn()
render()
await waitFor(() => {
expect(handler).toHaveBeenCalledWith('loaded')
})
})
it('lifts src/alt from a custom render element', async () => {
render(} />)
const lifted = await screen.findByTestId('lifted-img')
expect(lifted.getAttribute('src')).toBe('https://example.com/peak.jpg')
expect(lifted.getAttribute('data-slot')).toBe('thumbnail-image')
})
it('swaps the underlying element via a render function', async () => {
render(
}
/>,
)
const custom = await screen.findByTestId('custom-image')
expect(custom.tagName).toBe('DIV')
expect(custom.getAttribute('data-slot')).toBe('thumbnail-image')
expect(custom.className).toContain('object-cover')
expect(custom.className).toContain('rounded-[inherit]')
})
it('renders icon children for the icon-soft variant', () => {
const { container } = render(
,
)
const root = container.querySelector('[data-slot="thumbnail"]') as HTMLElement
expect(root.tagName).toBe('DIV')
expect(root.className).toContain('bg-background-muted')
expect(root.className).toContain('text-foreground-intense')
expect(screen.getByTestId('icon')).toBeTruthy()
// icon variants must NOT mount image/fallback slots
expect(container.querySelector('[data-slot="thumbnail-image"]')).toBeNull()
expect(container.querySelector('[data-slot="thumbnail-fallback"]')).toBeNull()
})
it('applies the icon-primary variant classes', () => {
const { container } = render(
,
)
const root = container.querySelector('[data-slot="thumbnail"]') as HTMLElement
expect(root.className).toContain('bg-primary')
expect(root.className).toContain('text-primary-foreground')
})
it('applies the icon-error variant classes', () => {
const { container } = render(
,
)
const root = container.querySelector('[data-slot="thumbnail"]') as HTMLElement
expect(root.className).toContain('bg-error-muted')
expect(root.className).toContain('text-error-foreground')
})
it('forwards className on the root', () => {
const { container } = render()
const root = container.querySelector('[data-slot="thumbnail"]') as HTMLElement
expect(root.className).toContain('my-thumb')
})
it('forwards arbitrary HTML attributes to the root', () => {
render(
,
)
const root = screen.getByTestId('root')
expect(root.getAttribute('aria-label')).toBe('Preview')
})
it('has no accessibility violations for the image variant', async () => {
const { container } = render()
expect(await axe(container)).toHaveNoViolations()
})
it('has no accessibility violations for an icon variant', async () => {
const { container } = render(
,
)
expect(await axe(container)).toHaveNoViolations()
})
})