import { render, screen, waitFor } from '@testing-library/react'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { axe } from 'vitest-axe'
import { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarImage } from './avatar'
// 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 Image
// never transitions out of "loading", and Avatar.Image is never mounted.
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(() => {
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('Avatar', () => {
it('renders a root with data-slot and default md size + circle shape', () => {
const { container } = render(
JD
,
)
const root = container.querySelector('[data-slot="avatar"]') as HTMLElement
expect(root).not.toBeNull()
expect(root.className).toContain('size-[1em]')
expect(root.className).toContain('text-[2.5rem]')
expect(root.className).toContain('rounded-full')
expect(root.className).toContain('bg-background-strong')
expect(root.className).toContain('relative')
})
it('applies the per-preset font-size class', () => {
const { container } = render(
JD
,
)
const root = container.querySelector('[data-slot="avatar"]') as HTMLElement
expect(root.className).toContain('text-[3rem]')
expect(root.className).toContain(ROUNDED_RADIUS_CLASS)
})
it('uses rounded-full when shape="circle"', () => {
const { container } = render(
JD
,
)
const root = container.querySelector('[data-slot="avatar"]') as HTMLElement
expect(root.className).toContain('rounded-full')
expect(root.className).not.toContain(ROUNDED_RADIUS_CLASS)
})
it('drives numeric size via inline font-size (no preset text class)', () => {
const { container } = render(
JD
,
)
const root = container.querySelector('[data-slot="avatar"]') as HTMLElement
expect(root.style.fontSize).toBe('56px')
expect(root.className).toContain('size-[1em]')
expect(root.className).toContain(ROUNDED_RADIUS_CLASS)
expect(root.className).not.toContain('text-[2.5rem]')
})
it('lets a caller-provided style override numeric defaults', () => {
const { container } = render(
JD
,
)
const root = container.querySelector('[data-slot="avatar"]') as HTMLElement
expect(root.style.fontSize).toBe('80px')
})
it('renders the fallback content when no image is provided', () => {
render(
JD
,
)
expect(screen.getByText('JD')).toBeTruthy()
})
it('shrinks textual fallback content to 0.4em', () => {
const { container } = render(
JD
,
)
const fb = container.querySelector('[data-slot="avatar-fallback"]') as HTMLElement
expect(fb.className).toContain('text-[0.4em]')
expect(fb.className).toContain('has-[svg]:text-[1em]')
})
it('renders AvatarImage as an
with src/alt and data-slot', async () => {
const { container } = render(
JD
,
)
const img = await waitFor(() => {
const el = container.querySelector('[data-slot="avatar-image"]') as HTMLImageElement | null
if (!el) throw new Error('avatar-image not mounted')
return el
})
expect(img.tagName).toBe('IMG')
expect(img.getAttribute('src')).toBe('https://example.com/jane.jpg')
expect(img.getAttribute('alt')).toBe('Jane Doe')
expect(img.className).toContain('object-cover')
expect(img.className).toContain('rounded-[inherit]')
})
it('lifts src from the render element so Base UI mounts without redeclaring it', async () => {
// No `src` on AvatarImage - only on the render element. The wrapper should
// lift it across so Base UI's pre-loader fires and the cloned element mounts.
render(
} />
JD
,
)
const lifted = await screen.findByTestId('lifted-img')
expect(lifted.getAttribute('src')).toBe('https://example.com/jane.jpg')
expect(lifted.getAttribute('data-slot')).toBe('avatar-image')
})
it('lets AvatarImage swap the underlying element via render prop', async () => {
render(
}
/>
JD
,
)
const custom = await screen.findByTestId('custom-image')
expect(custom.tagName).toBe('DIV')
expect(custom.getAttribute('data-slot')).toBe('avatar-image')
expect(custom.className).toContain('object-cover')
expect(custom.className).toContain('rounded-[inherit]')
})
it('AvatarBadge renders a dot without ping span by default', () => {
const { container } = render(
JD
,
)
const badge = container.querySelector('[data-slot="avatar-badge"]') as HTMLElement
expect(badge).not.toBeNull()
expect(badge.className).toContain('size-[30%]')
expect(badge.className).toContain('rounded-full')
expect(badge.querySelectorAll('span').length).toBe(1)
expect(badge.querySelector('.animate-ping-paced')).toBeNull()
})
it('AvatarBadge adds the ping span when animate is true', () => {
const { container } = render(
JD
,
)
const badge = container.querySelector('[data-slot="avatar-badge"]') as HTMLElement
expect(badge.querySelector('.animate-ping-paced')).not.toBeNull()
})
it('AvatarBadge merges a caller className', () => {
const { container } = render(
JD
,
)
const badge = container.querySelector('[data-slot="avatar-badge"]') as HTMLElement
expect(badge.className).toContain('text-error-emphasis')
})
it('forwards className on the Root', () => {
const { container } = render(
JD
,
)
const root = container.querySelector('[data-slot="avatar"]') as HTMLElement
expect(root.className).toContain('my-avatar')
})
it('has no accessibility violations', async () => {
const { container } = render(
JD
,
)
expect(await axe(container)).toHaveNoViolations()
})
})
describe('AvatarGroup', () => {
it('renders a data-slot wrapper with horizontal layout and ring selectors by default', () => {
const { container } = render(
JD
AB
,
)
const group = container.querySelector('[data-slot="avatar-group"]') as HTMLElement
expect(group).not.toBeNull()
expect(group.className).toContain('flex')
expect(group.className).toContain('space-x-[-0.2em]')
expect(group.className).toContain('*:data-[slot=avatar]:ring-[calc(1em/12)]')
expect(group.className).toContain('*:data-[slot=avatar]:ring-background')
})
it('switches to flex-col + -space-y when orientation="vertical"', () => {
const { container } = render(
JD
,
)
const group = container.querySelector('[data-slot="avatar-group"]') as HTMLElement
expect(group.className).toContain('flex-col')
expect(group.className).toContain('space-y-[-0.2em]')
expect(group.className).not.toContain('space-x-[-0.2em]')
})
it('propagates size to child Avatars', () => {
const { container } = render(
JD
,
)
const avatar = container.querySelector('[data-slot="avatar"]') as HTMLElement
expect(avatar.className).toContain('text-[3rem]')
})
it('propagates shape to child Avatars', () => {
const { container } = render(
JD
,
)
const avatar = container.querySelector('[data-slot="avatar"]') as HTMLElement
expect(avatar.className).toContain(ROUNDED_RADIUS_CLASS)
expect(avatar.className).not.toContain('rounded-full')
})
it('lets an explicit prop on an inner Avatar override the group default', () => {
const { container } = render(
JD
AB
,
)
const overridden = container.querySelector('[data-testid="overridden"]') as HTMLElement
expect(overridden.className).toContain('text-[2rem]')
expect(overridden.className).toContain('rounded-full')
expect(overridden.className).not.toContain(ROUNDED_RADIUS_CLASS)
const inherits = container.querySelector('[data-testid="inherits"]') as HTMLElement
expect(inherits.className).toContain('text-[3rem]')
expect(inherits.className).toContain(ROUNDED_RADIUS_CLASS)
})
it('merges caller className on the wrapper', () => {
const { container } = render(
JD
,
)
const group = container.querySelector('[data-slot="avatar-group"]') as HTMLElement
expect(group.className).toContain('my-group')
})
it('passes non-Avatar children through without injecting size/shape', () => {
const { container } = render(
extra
JD
,
)
const passthrough = container.querySelector('[data-testid="passthrough"]') as HTMLElement
expect(passthrough.getAttribute('size')).toBeNull()
expect(passthrough.getAttribute('shape')).toBeNull()
expect(passthrough.textContent).toBe('extra')
})
it('has no accessibility violations', async () => {
const { container } = render(
JD
AB
,
)
expect(await axe(container)).toHaveNoViolations()
})
})