import * as React from 'react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, it, expect, vi } from 'vitest' import { axe } from 'vitest-axe' import { ColorArea } from './color-area' import { type Color, convertColor, formatColor, getChannelValue, parseColor } from '../../lib/color' import { DirectionProvider } from '../../providers/direction-provider' const AREA_RECT = { x: 0, y: 0, top: 0, left: 0, right: 200, bottom: 200, width: 200, height: 200 } function stubAreaSize() { vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockImplementation( () => ({ ...AREA_RECT, toJSON: () => AREA_RECT }) as DOMRect, ) } function inputs() { return screen.getAllByRole('slider') as HTMLInputElement[] } describe('ColorArea', () => { it('exposes one range input per axis, labeled by channel', () => { render() const [x, y] = inputs() expect(x).toHaveAttribute('aria-label', 'Color picker, Saturation') expect(x).toHaveAttribute('aria-orientation', 'horizontal') expect(y).toHaveAttribute('aria-label', 'Color picker, Brightness') expect(y).toHaveAttribute('aria-orientation', 'vertical') }) it('derives the color space from the value, and the free channel from the axes', () => { render() expect(screen.getByRole('group')).toHaveAttribute('data-space', 'hsl') // Unspecified axes take the first free channels: hue on x, saturation on y. expect(inputs()[0]).toHaveValue('200') expect(inputs()[1]).toHaveValue('50') }) it('honors an explicit colorSpace over the space of the value', () => { render() expect(screen.getByRole('group')).toHaveAttribute('data-space', 'hsb') expect(inputs()[0]).toHaveValue('100') }) it('announces all three channels in aria-valuetext', () => { render() expect(inputs()[0]).toHaveAttribute('aria-valuetext', 'Hue 120°, Saturation 40%, Brightness 60%') }) it('moves the x channel on ArrowRight and the y channel on ArrowUp', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() render( , ) inputs()[0]?.focus() await user.keyboard('{ArrowRight}') expect(getChannelValue(onValueChange.mock.lastCall![0], 'saturation')).toBe(51) await user.keyboard('{ArrowUp}') expect(getChannelValue(onValueChange.mock.lastCall![0], 'brightness')).toBe(51) }) it('uses the page step when shift is held', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() render( , ) inputs()[0]?.focus() await user.keyboard('{Shift>}{ArrowRight}{/Shift}') expect(getChannelValue(onValueChange.mock.lastCall![0], 'saturation')).toBe(60) }) it('clamps at the ends of each channel range', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() render( , ) inputs()[0]?.focus() await user.keyboard('{ArrowLeft}{ArrowUp}') expect(onValueChange).not.toHaveBeenCalled() }) it('reverses the horizontal axis in RTL', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() render( , ) inputs()[0]?.focus() await user.keyboard('{ArrowRight}') expect(getChannelValue(onValueChange.mock.lastCall![0], 'saturation')).toBe(49) }) it('sets both channels from a pointer press, with y increasing upwards', async () => { stubAreaSize() const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() render( , ) // A quarter in from the left, a quarter down from the top. await user.pointer({ target: screen.getByRole('group'), coords: { clientX: 50, clientY: 50 }, keys: '[MouseLeft]' }) const value = onValueChange.mock.lastCall![0] expect(getChannelValue(value, 'saturation')).toBe(25) expect(getChannelValue(value, 'brightness')).toBe(75) }) it('fires onValueCommitted once the pointer is released', async () => { stubAreaSize() const user = userEvent.setup() const onValueCommitted = vi.fn<(value: Color) => void>() render( , ) await user.pointer({ target: screen.getByRole('group'), coords: { clientX: 50, clientY: 50 }, keys: '[MouseLeft]' }) expect(onValueCommitted).toHaveBeenCalledTimes(1) expect(formatColor(onValueCommitted.mock.lastCall![0], 'hsb')).toBe('hsb(0, 25%, 75%)') }) it('stays on the controlled value when the parent ignores the change', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() render( , ) inputs()[0]?.focus() await user.keyboard('{ArrowRight}') expect(onValueChange).toHaveBeenCalledTimes(1) expect(inputs()[0]).toHaveValue('50') }) it('follows a controlled value that the parent does update', async () => { function Controlled() { const [color, setColor] = React.useState(() => parseColor('hsb(0, 50%, 50%)')) return } const user = userEvent.setup() render() inputs()[0]?.focus() await user.keyboard('{ArrowRight}{ArrowRight}') expect(inputs()[0]).toHaveValue('52') }) it('ignores interaction when disabled', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() render() expect(screen.getByRole('group')).toHaveAttribute('data-disabled') expect(inputs()[0]).toBeDisabled() await user.click(screen.getByRole('group')) expect(onValueChange).not.toHaveBeenCalled() }) it('drops the gradient when disabled, so the flat fill can show', () => { render() const area = screen.getByRole('group') // An inline background-image would paint straight over the disabled class. expect(area.style.backgroundImage).toBe('') const knob = area.querySelector('[data-slot="color-area-thumb"]') as HTMLElement expect(knob.style.backgroundColor).toBe('') }) it('defaults to an rgb plane, including for a value whose space has none', () => { const { rerender } = render() expect(screen.getByRole('group')).toHaveAttribute('data-space', 'rgb') rerender() expect(screen.getByRole('group')).toHaveAttribute('data-space', 'rgb') }) it('leaves the picked color unchanged when converted to another format', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() render() inputs()[0]?.focus() await user.keyboard('{ArrowRight}{ArrowLeft}') // Format is independent of the space the area works in: a round trip through // the plane has to survive being written out in any of them. const picked = onValueChange.mock.lastCall![0] expect(formatColor(picked, 'hex')).toBe('#3b82f6') expect(convertColor(picked, 'hsb').space).toBe('hsb') }) it('names the hidden inputs for form submission', () => { render() expect(inputs()[0]).toHaveAttribute('name', 'red') expect(inputs()[1]).toHaveAttribute('name', 'green') }) it('has no accessibility violations', async () => { const { container } = render() expect(await axe(container)).toHaveNoViolations() }) })