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 { ColorSwatchPicker, ColorSwatchPickerItem } from './color-swatch-picker'
import { type Color, formatColor, parseColor } from '../../lib/color'
import { DirectionProvider } from '../../providers/direction-provider'
const PALETTE = ['#ef4444', '#f59e0b', '#22c55e', '#3b82f6']
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
disconnect() {}
},
)
})
const options = () => screen.getAllByRole('option')
const indicator = () => document.querySelector('[data-slot=color-swatch-picker-indicator]')
describe('ColorSwatchPicker', () => {
it('exposes the palette as a single-select listbox', () => {
render(
{PALETTE.map((color) => (
))}
,
)
expect(screen.getByRole('listbox')).toHaveAccessibleName('Brand color')
expect(options()).toHaveLength(4)
expect(options()[3]).toHaveAttribute('aria-selected', 'true')
expect(options()[0]).toHaveAttribute('aria-selected', 'false')
})
it('names each swatch from its color, and takes an override', () => {
render(
,
)
expect(options()[0]).toHaveAccessibleName('vivid blue')
expect(options()[1]).toHaveAccessibleName('Fire truck red')
})
it('selects on click and reports the color', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn<(value: Color) => void>()
render(
{PALETTE.map((color) => (
))}
,
)
await user.click(options()[2]!)
expect(formatColor(onValueChange.mock.lastCall![0], 'hex')).toBe('#22c55e')
expect(options()[2]).toHaveAttribute('aria-selected', 'true')
expect(options()[0]).toHaveAttribute('aria-selected', 'false')
})
it('matches the same color written in another space', () => {
render(
,
)
// Keyed on hexa, so hsl(0, 100%, 50%) and #ff0000 are one entry.
expect(options()[0]).toHaveAttribute('aria-selected', 'true')
})
it('stays put when controlled and the value does not change', async () => {
const user = userEvent.setup()
render(
{}}>
{PALETTE.map((color) => (
))}
,
)
await user.click(options()[2]!)
expect(options()[0]).toHaveAttribute('aria-selected', 'true')
})
it('keeps one tab stop, on the selected swatch', () => {
render(
{PALETTE.map((color) => (
))}
,
)
expect(options().map((option) => option.tabIndex)).toEqual([-1, -1, 0, -1])
})
it('falls back to the first swatch when nothing is selected', () => {
render(
{PALETTE.map((color) => (
))}
,
)
expect(options().map((option) => option.tabIndex)).toEqual([0, -1, -1, -1])
expect(indicator()).not.toBeInTheDocument()
})
it('moves selection with the arrow keys, and wraps', async () => {
const user = userEvent.setup()
render(
{PALETTE.map((color) => (
))}
,
)
options()[0]!.focus()
await user.keyboard('{ArrowRight}')
expect(options()[1]).toHaveAttribute('aria-selected', 'true')
expect(options()[1]).toHaveFocus()
await user.keyboard('{End}')
expect(options()[3]).toHaveAttribute('aria-selected', 'true')
await user.keyboard('{ArrowRight}')
expect(options()[0]).toHaveAttribute('aria-selected', 'true')
await user.keyboard('{Home}')
expect(options()[0]).toHaveAttribute('aria-selected', 'true')
})
it('reverses the horizontal arrows in RTL', async () => {
const user = userEvent.setup()
render(
{PALETTE.map((color) => (
))}
,
)
options()[1]!.focus()
await user.keyboard('{ArrowRight}')
expect(options()[0]).toHaveAttribute('aria-selected', 'true')
})
it('walks a stack with the vertical arrows', async () => {
const user = userEvent.setup()
render(
{PALETTE.map((color) => (
))}
,
)
expect(screen.getByRole('listbox')).toHaveAttribute('aria-orientation', 'vertical')
options()[0]!.focus()
await user.keyboard('{ArrowDown}')
expect(options()[1]).toHaveAttribute('aria-selected', 'true')
await user.keyboard('{ArrowUp}')
expect(options()[0]).toHaveAttribute('aria-selected', 'true')
})
it('jumps a row at a time in a wrapped grid', async () => {
const user = userEvent.setup()
render(
{PALETTE.map((color) => (
))}
,
)
// jsdom lays nothing out, so stand in two rows of two.
const boxes = [
[0, 0],
[50, 0],
[0, 50],
[50, 50],
]
options().forEach((option, index) => {
Object.defineProperty(option, 'offsetLeft', { value: boxes[index]![0], configurable: true })
Object.defineProperty(option, 'offsetTop', { value: boxes[index]![1], configurable: true })
})
options()[1]!.focus()
await user.keyboard('{ArrowDown}')
// Straight down, not the next in source order.
expect(options()[3]).toHaveAttribute('aria-selected', 'true')
await user.keyboard('{ArrowUp}')
expect(options()[1]).toHaveAttribute('aria-selected', 'true')
})
it('skips a disabled swatch and refuses to select it', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn<(value: Color) => void>()
render(
,
)
expect(options()[1]).toBeDisabled()
// The shared treatment: the swatch drops its color for the flat muted fill.
expect(options()[1]!.querySelector('[data-slot=color-swatch]')).toHaveAttribute('data-disabled')
expect(options()[1]!.querySelector('[data-slot=color-swatch]')!.style.backgroundColor).toBe('')
await user.click(options()[1]!)
expect(onValueChange).not.toHaveBeenCalled()
options()[0]!.focus()
await user.keyboard('{ArrowRight}')
expect(options()[2]).toHaveAttribute('aria-selected', 'true')
})
it('disables every swatch from the root', async () => {
const user = userEvent.setup()
const onValueChange = vi.fn<(value: Color) => void>()
render(
{PALETTE.map((color) => (
))}
,
)
expect(screen.getByRole('listbox')).toHaveAttribute('data-disabled')
await user.click(options()[1]!)
expect(onValueChange).not.toHaveBeenCalled()
})
it('sizes the swatch off the button, so the ring gap scales with it', () => {
render(
,
)
expect(screen.getByRole('listbox')).toHaveClass('text-[3rem]')
// The swatch's own preset has to lose to the 0.8em the item asks for, or the
// gap between ring and swatch would be fixed instead of proportional.
const swatch = document.querySelector('[data-slot=color-swatch]')!
expect(swatch.className).toContain('text-[0.8em]')
expect(swatch.className).not.toContain('text-[2.5rem]')
})
it('draws one indicator, not a border per swatch', () => {
render(
{PALETTE.map((color) => (
))}
,
)
expect(document.querySelectorAll('[data-slot=color-swatch-picker-indicator]')).toHaveLength(1)
expect(indicator()).toHaveAttribute('aria-hidden', 'true')
})
it('throws when an item is rendered outside a picker', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => render()).toThrow(/within /)
error.mockRestore()
})
it('has no accessibility violations', async () => {
const { container } = render(
{PALETTE.map((color) => (
))}
,
)
expect(await axe(container)).toHaveNoViolations()
})
})