import * as React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { axe } from 'vitest-axe'
import { Chip, ChipGroup, type ChipGroupHandle } from './chip'
describe('Chip', () => {
it('renders as a button by default with data-slot', () => {
render(Tag)
const el = screen.getByRole('button', { name: 'Tag' })
expect(el.tagName).toBe('BUTTON')
expect(el).toHaveAttribute('data-slot', 'chip')
})
it('renders as an anchor via render prop', () => {
render(}>Link chip)
const el = screen.getByRole('link', { name: 'Link chip' })
expect(el.tagName).toBe('A')
expect(el).toHaveAttribute('href', '/x')
})
it('exposes state to a render callback', () => {
render(
(
)}
>
Done
,
)
const el = screen.getByRole('button', { name: 'Done' })
expect(el).toHaveAttribute('data-test-variant', 'primary')
expect(el).toHaveAttribute('data-test-size', 'lg')
expect(el).toHaveAttribute('data-test-dismissible', 'false')
})
it('forwards className alongside variant + size classes', () => {
render(
Hi
,
)
const el = screen.getByRole('button', { name: 'Hi' })
expect(el.className).toContain('my-chip')
expect(el.className).toContain('bg-error')
expect(el.className).toContain('h-6')
})
it('uses soft variant and md size by default', () => {
render(Default)
const el = screen.getByRole('button', { name: 'Default' })
expect(el.className).toContain('h-8')
})
it('non-dismissible click invokes user onClick and does not unmount', async () => {
const onClick = vi.fn()
const user = userEvent.setup()
render(Click me)
await user.click(screen.getByRole('button', { name: 'Click me' }))
expect(onClick).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
})
it('dismissible click removes the chip and fires onDismiss after exit', async () => {
const onDismiss = vi.fn()
const user = userEvent.setup()
render(
Red
,
)
const chip = screen.getByRole('button', { name: /Red/ })
await user.click(chip)
await waitFor(() => {
expect(screen.queryByRole('button', { name: /Red/ })).toBeNull()
})
expect(onDismiss).toHaveBeenCalledTimes(1)
})
it('calls user onClick before dismissing and respects preventDefault', async () => {
const onDismiss = vi.fn()
const user = userEvent.setup()
render(
{
event.preventDefault()
}}
>
Blocked
,
)
await user.click(screen.getByRole('button', { name: /Blocked/ }))
// preventDefault should stop the dismiss path entirely.
expect(screen.getByRole('button', { name: /Blocked/ })).toBeInTheDocument()
expect(onDismiss).not.toHaveBeenCalled()
})
it('exposes closeLabel as sr-only text inside the chip', () => {
render(
Color: Red
,
)
expect(screen.getByText('Remove filter')).toHaveClass('sr-only')
})
it('controlled mode: open=false keeps the chip unmounted', () => {
render(
Hidden
,
)
expect(screen.queryByRole('button', { name: /Hidden/ })).toBeNull()
})
it('controlled mode: fires onOpenChange(false) but parent keeps it open', async () => {
const onOpenChange = vi.fn()
const user = userEvent.setup()
render(
Persistent
,
)
await user.click(screen.getByRole('button', { name: /Persistent/ }))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(screen.getByRole('button', { name: /Persistent/ })).toBeInTheDocument()
})
it('has no accessibility violations (default)', async () => {
const { container } = render(Accessible)
expect(await axe(container)).toHaveNoViolations()
})
it('has no accessibility violations (dismissible)', async () => {
const { container } = render(Accessible)
expect(await axe(container)).toHaveNoViolations()
})
})
describe('ChipGroup', () => {
it('renders with data-slot and wraps children', () => {
render(
One
Two
,
)
const group = screen.getByRole('button', { name: 'One' }).parentElement
expect(group).toHaveAttribute('data-slot', 'chip-group')
})
it('passes variant + size defaults to descendant chips', () => {
render(
Inherits
Override
,
)
const inheritsChip = screen.getByRole('button', { name: 'Inherits' })
const overrideChip = screen.getByRole('button', { name: 'Override' })
expect(inheritsChip.className).toContain('bg-primary')
expect(inheritsChip.className).toContain('h-10')
expect(inheritsChip.className).toContain('text-base')
// Override chip keeps its own size.
expect(overrideChip.className).toContain('h-6')
expect(overrideChip.className).toContain('text-xs')
})
it('clearAll() dismisses every dismissible chip and fires their onDismiss', async () => {
const onDismissA = vi.fn()
const onDismissB = vi.fn()
const onDismissC = vi.fn()
const ref = React.createRef()
render(
A
B
C
,
)
expect(screen.getByRole('button', { name: /A/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /B/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /C/ })).toBeInTheDocument()
ref.current?.clearAll()
await waitFor(() => {
expect(screen.queryByRole('button', { name: /A/ })).toBeNull()
expect(screen.queryByRole('button', { name: /B/ })).toBeNull()
expect(screen.queryByRole('button', { name: /C/ })).toBeNull()
})
expect(onDismissA).toHaveBeenCalledTimes(1)
expect(onDismissB).toHaveBeenCalledTimes(1)
expect(onDismissC).toHaveBeenCalledTimes(1)
})
it('clearAll() ignores non-dismissible chips', async () => {
const ref = React.createRef()
render(
Static
Dismissable
,
)
ref.current?.clearAll()
await waitFor(() => {
expect(screen.queryByRole('button', { name: /Dismissable/ })).toBeNull()
})
// Non-dismissible chip is still around.
expect(screen.getByRole('button', { name: 'Static' })).toBeInTheDocument()
})
})