import * as React from 'react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, it, expect, vi, beforeAll } from 'vitest' import { axe } from 'vitest-axe' import { ColorPicker, ColorPickerInput } from './color-picker' import { ColorArea } from '../color-area/color-area' import { ColorSlider } from '../color-slider/color-slider' import { ColorSwatch } from '../color-swatch/color-swatch' import { ColorSwatchPicker, ColorSwatchPickerItem } from '../color-swatch-picker/color-swatch-picker' import { type Color, getChannelValue } from '../../lib/color' beforeAll(() => { vi.stubGlobal( 'ResizeObserver', class { observe() {} disconnect() {} }, ) }) const trigger = () => screen.getByTestId('trigger') const hexField = () => screen.getByRole('textbox', { name: 'Hex' }) as HTMLInputElement const swatch = () => screen.getByRole('img') const triggerSwatch = () => trigger().querySelector('[data-slot=color-swatch]') as HTMLElement describe('ColorPicker', () => { it('renders a swatch trigger showing the color, and opens the default panel', async () => { const user = userEvent.setup() render() expect(trigger()).toHaveTextContent('#3b82f6') expect(trigger()).toHaveAccessibleName(/vivid blue/) expect(screen.queryByRole('slider')).not.toBeInTheDocument() await user.click(trigger()) expect(screen.getByRole('group', { name: 'Saturation and brightness' })).toBeInTheDocument() expect(screen.getByRole('slider', { name: 'Hue' })).toBeInTheDocument() expect(hexField()).toHaveValue('#3b82f6') }) it('adds an alpha slider and keeps the opacity in the value', async () => { const user = userEvent.setup() render() await user.click(trigger()) expect(screen.getByRole('slider', { name: 'Alpha' })).toBeInTheDocument() expect(hexField()).toHaveValue('#3b82f680') }) it('drops the alpha digits from an opaque color', async () => { const user = userEvent.setup() const { container } = render() expect(trigger()).toHaveTextContent('#3b82f6') expect(container.querySelector('input[type=hidden]')).toHaveValue('#3b82f6') await user.click(trigger()) expect(hexField()).toHaveValue('#3b82f6') await user.clear(hexField()) await user.type(hexField(), '#3b82f680') expect(hexField()).toHaveValue('#3b82f680') }) it('places the trigger swatch on either side of the label', () => { const { rerender } = render() expect(trigger().firstElementChild).toBe(triggerSwatch()) rerender() expect(trigger().querySelector('span + [data-slot=color-swatch]')).toBe(triggerSwatch()) }) it('previews the color in the panel only when there is no trigger to do it', async () => { const user = userEvent.setup() const { unmount } = render() await user.click(trigger()) expect(screen.queryByRole('img')).not.toBeInTheDocument() unmount() render() expect(swatch()).toHaveAccessibleName('vivid blue') }) it('renders a panel with no trigger of its own', () => { render() expect(screen.queryByRole('button')).not.toBeInTheDocument() expect(screen.getByRole('slider', { name: 'Hue' })).toBeInTheDocument() expect(document.querySelector('input[type=hidden]')).toHaveValue('#3b82f6') }) it('takes the button shell off the trigger when flush', () => { const { rerender } = render() expect(trigger().className).toMatch(/\bpx-5\b/) rerender() expect(trigger().className).not.toMatch(/\bpx-5\b/) expect(trigger().className).toMatch(/\bp-0\b/) // Nothing to pull the swatch out of, so it keeps its own box. expect(triggerSwatch().className).not.toMatch(/-ms-/) }) it('keeps the text input from deciding how wide the panel is', () => { render() // Without this an asks for 20 characters, which beats the area in `w-fit`. expect(hexField()).toHaveAttribute('size', '1') }) it('leaves alpha out by default', async () => { const user = userEvent.setup() render() await user.click(trigger()) expect(screen.queryByRole('slider', { name: 'Alpha' })).not.toBeInTheDocument() }) it('renders the panel in place, with no trigger, when inline', () => { render() expect(screen.queryByRole('button', { name: /blue/ })).not.toBeInTheDocument() expect(screen.getByRole('slider', { name: 'Hue' })).toBeInTheDocument() }) it('replaces the panel with custom children', () => { render( , ) expect(screen.getByRole('slider', { name: 'Hue' })).toBeInTheDocument() expect(screen.queryByRole('group', { name: 'Saturation and brightness' })).not.toBeInTheDocument() expect(screen.queryByRole('textbox')).not.toBeInTheDocument() }) it('shares one color between the composed controls', async () => { const user = userEvent.setup() render( , ) expect(swatch()).toHaveAccessibleName('vivid red') expect(screen.getByRole('slider', { name: 'Hue' })).toHaveValue('0') await user.click(screen.getByRole('option')) expect(swatch()).toHaveAccessibleName('vivid green') expect(screen.getByRole('slider', { name: 'Hue' })).toHaveValue('120') }) it('lets a control keep its own value inside a picker', async () => { const user = userEvent.setup() render( , ) await user.click(screen.getByRole('option')) // The palette stayed on its own controlled value; the picker still took the pick. expect(screen.getByRole('option')).toHaveAttribute('aria-selected', 'false') expect(swatch()).toHaveAccessibleName('vivid green') }) it('reports every change, and commits once a gesture settles', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() const onValueCommitted = vi.fn<(value: Color) => void>() render( , ) screen.getByRole('slider', { name: 'Hue' }).focus() await user.keyboard('{ArrowRight}{ArrowRight}') expect(onValueChange).toHaveBeenCalledTimes(2) expect(onValueCommitted).toHaveBeenCalledTimes(2) expect(getChannelValue(onValueCommitted.mock.calls[1]![0], 'hue')).toBe(2) }) it('holds a controlled value until the consumer updates it', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() render( , ) await user.click(screen.getByRole('option')) expect(onValueChange).toHaveBeenCalledOnce() expect(swatch()).toHaveAccessibleName('vivid red') }) it('dims the trigger and every control when disabled', async () => { const user = userEvent.setup() render() expect(trigger()).toBeDisabled() await user.click(trigger()) expect(screen.queryByRole('slider')).not.toBeInTheDocument() }) it('names a hidden input for form submission', () => { const { container } = render() expect(container.querySelector('input[type=hidden]')).toHaveValue('#3b82f680') }) it('has no accessibility violations', async () => { const { container } = render() expect(await axe(container)).toHaveNoViolations() }) it('has no accessibility violations with the panel open', async () => { const user = userEvent.setup() render() await user.click(trigger()) expect(await axe(document.body)).toHaveNoViolations() }) }) describe('ColorPickerInput', () => { it('takes any color string the library can parse', async () => { const user = userEvent.setup() render( , ) await user.clear(hexField()) await user.type(hexField(), 'rgb(0, 255, 0)') expect(screen.getByRole('slider', { name: 'Hue' })).toHaveValue('120') await user.tab() expect(hexField()).toHaveValue('#00ff00') }) it('accepts hex digits without the hash', async () => { const user = userEvent.setup() render() await user.clear(hexField()) await user.type(hexField(), '00f{Enter}') expect(hexField()).toHaveValue('#0000ff') }) it('restores the color when the text is left unparseable', async () => { const user = userEvent.setup() render() await user.clear(hexField()) await user.type(hexField(), 'not a color') expect(hexField()).toHaveValue('not a color') await user.tab() expect(hexField()).toHaveValue('#ff0000') }) it('writes the format it is given', () => { render( , ) expect(screen.getByRole('textbox', { name: 'HSL' })).toHaveValue('hsl(0, 100%, 50%)') }) it('refuses to render outside a picker', () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(() => render()).toThrow(/within /) error.mockRestore() }) })