import { fireEvent, 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 { DirectionProvider } from '../../providers/direction-provider' import { Rating } from './rating' describe('Rating', () => { it('renders a radiogroup with one radio per item', () => { render() expect(screen.getByRole('radiogroup', { name: 'Rating' })).toHaveAttribute('data-slot', 'rating') expect(screen.getAllByRole('radio')).toHaveLength(5) }) it('honors count and marks the item holding the value as checked', () => { render() const radios = screen.getAllByRole('radio') expect(radios).toHaveLength(3) expect(radios[1]).toBeChecked() expect(radios[0]).not.toBeChecked() }) it('selects on click and reports the new value', async () => { const user = userEvent.setup() const onValueChange = vi.fn() render() await user.click(screen.getAllByRole('radio')[3]) expect(onValueChange).toHaveBeenCalledWith(4) expect(screen.getAllByRole('radio')[3]).toBeChecked() }) it('stays on the controlled value until the consumer updates it', async () => { const user = userEvent.setup() const onValueChange = vi.fn() render() await user.click(screen.getAllByRole('radio')[4]) expect(onValueChange).toHaveBeenCalledWith(5) expect(screen.getAllByRole('radio')[1]).toBeChecked() expect(screen.getAllByRole('radio')[4]).not.toBeChecked() }) it('clears the rating when clearable and the current item is picked again', async () => { const user = userEvent.setup() const onValueChange = vi.fn() render() await user.click(screen.getAllByRole('radio')[2]) expect(onValueChange).toHaveBeenCalledWith(0) expect(screen.getAllByRole('radio').some((radio) => radio.getAttribute('aria-checked') === 'true')).toBe(false) }) it('moves the value by step with the arrow keys', async () => { const user = userEvent.setup() const onValueChange = vi.fn() render() await user.tab() await user.keyboard('{ArrowRight}') expect(onValueChange).toHaveBeenLastCalledWith(2.5) await user.keyboard('{ArrowLeft}{ArrowLeft}') expect(onValueChange).toHaveBeenLastCalledWith(1.5) await user.keyboard('{End}') expect(onValueChange).toHaveBeenLastCalledWith(5) await user.keyboard('{Home}') expect(onValueChange).toHaveBeenLastCalledWith(0.5) }) it('flips the horizontal arrow keys in RTL', async () => { const user = userEvent.setup() const onValueChange = vi.fn() render( , ) await user.tab() await user.keyboard('{ArrowRight}') expect(onValueChange).toHaveBeenLastCalledWith(2) }) it('steps to the next item with Down/Right and the previous with Up/Left', async () => { const user = userEvent.setup() const onValueChange = vi.fn() render() await user.tab() await user.keyboard('{ArrowDown}') expect(onValueChange).toHaveBeenLastCalledWith(4) await user.keyboard('{ArrowUp}{ArrowUp}') expect(onValueChange).toHaveBeenLastCalledWith(2) }) it('renders vertically and fills from the top', async () => { const onValueChange = vi.fn() const { container } = render( , ) const root = screen.getByRole('radiogroup') expect(root).toHaveAttribute('data-orientation', 'vertical') expect(root).toHaveAttribute('aria-orientation', 'vertical') const clips = [...container.querySelectorAll('[data-slot=rating-item-fill]')].map( (el) => el.style.clipPath, ) expect(clips).toEqual(['inset(0 0 0% 0)', 'inset(0 0 0% 0)', 'inset(0 0 100% 0)']) vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ top: 0, height: 300 } as DOMRect) fireEvent.pointerMove(root, { clientY: 250 }) fireEvent.click(screen.getAllByRole('radio')[2], { detail: 1 }) expect(onValueChange).toHaveBeenCalledWith(3) }) it('keeps a single tab stop on the checked item', async () => { const user = userEvent.setup() render() await user.tab() expect(screen.getAllByRole('radio')[3]).toHaveFocus() }) it('exposes a read-only rating as one labeled image', () => { render() expect(screen.getByRole('img', { name: '4.5 out of 5' })).toHaveAttribute('data-readonly', '') expect(screen.queryAllByRole('radio')).toHaveLength(0) }) it('blocks interaction when disabled', async () => { const user = userEvent.setup() const onValueChange = vi.fn() render() await user.click(screen.getAllByRole('radio')[2]) expect(onValueChange).not.toHaveBeenCalled() expect(screen.getByRole('radiogroup')).toHaveAttribute('data-disabled', '') }) it('submits the value through a hidden input', () => { const { container } = render() expect(container.querySelector('input[name="score"]')).toHaveValue('3') }) it('renders the built-in star by default and swaps in a custom icon pair', () => { const { container, rerender } = render() expect(container.querySelectorAll('svg')).toHaveLength(2) rerender( , filled: }} variant="outline" />, ) expect(screen.getByTestId('empty')).toBeInTheDocument() expect(screen.getByTestId('filled')).toBeInTheDocument() }) it('still fills the selection when the hover preview is off', async () => { const { container } = render() const root = screen.getByRole('radiogroup') vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({ left: 0, width: 300 } as DOMRect) fireEvent.pointerMove(root, { clientX: 150 }) fireEvent.click(screen.getAllByRole('radio')[1], { detail: 1 }) await waitFor(() => { const clips = [...container.querySelectorAll('[data-slot=rating-item-fill]')].map( (el) => el.style.clipPath, ) expect(clips).toEqual(['inset(0 0% 0 0)', 'inset(0 0% 0 0)', 'inset(0 100% 0 0)']) }) }) it('holds the pressed item scaled down until the pointer is released', async () => { const { container } = render() const icon = () => container.querySelectorAll('[data-slot=rating-item] > span')[1]! const scaleOf = (el: HTMLElement) => Number(/scale\(([\d.]+)\)/.exec(el.style.transform)?.[1] ?? 1) fireEvent.pointerDown(screen.getAllByRole('radio')[1]) await waitFor(() => expect(scaleOf(icon())).toBeLessThan(1)) fireEvent.pointerUp(screen.getAllByRole('radio')[1]) await waitFor(() => expect(scaleOf(icon())).toBe(1)) }) it('presses an item even while another one holds focus', async () => { const { container } = render() const radios = screen.getAllByRole('radio') const icon = () => container.querySelectorAll('[data-slot=rating-item] > span')[0]! const scaleOf = (el: HTMLElement) => Number(/scale\(([\d.]+)\)/.exec(el.style.transform)?.[1] ?? 1) radios[2].focus() fireEvent.pointerDown(radios[0]) fireEvent.blur(radios[2]) await waitFor(() => expect(scaleOf(icon())).toBeLessThan(1)) }) it('clips each item fill to the value it holds', () => { const { container } = render() const clips = [...container.querySelectorAll('[data-slot=rating-item-fill]')].map( (el) => el.style.clipPath, ) expect(clips).toEqual(['inset(0 0% 0 0)', 'inset(0 0% 0 0)', 'inset(0 60% 0 0)']) }) it('sizes the icons from a number of pixels or any CSS length', () => { const { container, rerender } = render() const root = () => container.querySelector('[data-slot=rating]')! expect(root().style.getPropertyValue('--rating-size')).toBe('24px') rerender() expect(root().style.getPropertyValue('--rating-size')).toBe('40px') rerender() expect(root().style.getPropertyValue('--rating-size')).toBe('1.5em') expect(root().style.opacity).toBe('0.5') }) it('merges className with the layout classes', () => { render() const root = screen.getByRole('radiogroup') expect(root.className).toContain('custom-class') expect(root.className).toContain('inline-flex') }) it('has no accessibility violations', async () => { const { container } = render() expect(await axe(container)).toHaveNoViolations() }) })