import * as React from 'react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, it, expect, vi, afterEach } from 'vitest' import { axe } from 'vitest-axe' import { ColorSlider, type ColorSliderProps } from './color-slider' import { ColorArea } from '../color-area/color-area' import { type Color, getChannelValue, parseColor } from '../../lib/color' import { DirectionProvider } from '../../providers/direction-provider' const RECT = { x: 0, y: 0, top: 0, left: 0, right: 200, bottom: 200, width: 200, height: 200 } function stubSize() { vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockImplementation( () => ({ ...RECT, toJSON: () => RECT }) as DOMRect, ) } /** jsdom lays nothing out, so stand in a track thickness and a thumb size. */ function stubGeometry(track: number, thumb: number) { const extent = (element: HTMLElement) => (element.dataset.slot === 'color-slider-thumb' ? thumb : track) for (const property of ['offsetWidth', 'offsetHeight'] as const) { vi.spyOn(HTMLElement.prototype, property, 'get').mockImplementation(function (this: HTMLElement) { return extent(this) }) } } function renderSlider(props: ColorSliderProps) { return render() } const slider = () => screen.getByRole('slider') as HTMLInputElement const track = () => screen.getByTestId('track') const thumb = () => document.querySelector('[data-slot="color-slider-thumb"]') as HTMLElement const stopCount = () => (track().style.backgroundImage.match(/rgba?\(/g) ?? []).length describe('ColorSlider', () => { it('exposes the channel as a labeled range input', () => { renderSlider({ channel: 'hue', defaultValue: 'hsb(120, 50%, 50%)' }) expect(slider()).toHaveAttribute('aria-label', 'Hue') expect(slider()).toHaveAttribute('min', '0') expect(slider()).toHaveAttribute('max', '360') expect(slider()).toHaveValue('120') }) it('reads a channel the value space lacks from that channel home space', () => { // A hex value is RGB, which has no hue, so hue comes from HSB. renderSlider({ channel: 'hue', defaultValue: '#ff0000' }) expect(slider()).toHaveValue('0') }) it('keeps the value space when it already carries the channel', () => { renderSlider({ channel: 'lightness', defaultValue: 'hsl(200, 50%, 40%)' }) expect(slider()).toHaveValue('40') }) it('leaves the space alone for alpha, which every space carries', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() renderSlider({ channel: 'alpha', defaultValue: 'hsb(217, 76%, 96%)', onValueChange }) slider().focus() await user.keyboard('{ArrowLeft}') // Converting to RGB here would strip the channels a sibling area reads. expect(onValueChange.mock.lastCall![0].space).toBe('hsb') }) it('moves by step, by page step with shift, and jumps with Home and End', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() renderSlider({ channel: 'hue', defaultValue: 'hsb(120, 50%, 50%)', onValueChange }) slider().focus() await user.keyboard('{ArrowRight}') expect(getChannelValue(onValueChange.mock.lastCall![0], 'hue')).toBe(121) await user.keyboard('{Shift>}{ArrowRight}{/Shift}') expect(getChannelValue(onValueChange.mock.lastCall![0], 'hue')).toBe(136) await user.keyboard('{End}') expect(getChannelValue(onValueChange.mock.lastCall![0], 'hue')).toBe(360) await user.keyboard('{Home}') expect(getChannelValue(onValueChange.mock.lastCall![0], 'hue')).toBe(0) }) it('reverses the horizontal arrow keys in RTL', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() render( , ) slider().focus() await user.keyboard('{ArrowRight}') expect(getChannelValue(onValueChange.mock.lastCall![0], 'hue')).toBe(119) }) it('leaves the vertical arrow keys alone in RTL', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() render( , ) slider().focus() await user.keyboard('{ArrowUp}') expect(getChannelValue(onValueChange.mock.lastCall![0], 'hue')).toBe(121) }) it('sets the channel from a pointer press along the track', async () => { stubSize() const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() renderSlider({ channel: 'hue', defaultValue: 'hsb(0, 50%, 50%)', onValueChange }) await user.pointer({ target: track(), coords: { clientX: 50, clientY: 10 }, keys: '[MouseLeft]' }) expect(getChannelValue(onValueChange.mock.lastCall![0], 'hue')).toBe(90) }) it('fills a vertical track from the bottom', async () => { stubSize() const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() renderSlider({ channel: 'hue', orientation: 'vertical', defaultValue: 'hsb(0, 50%, 50%)', onValueChange }) // A quarter down from the top is three quarters up the track. await user.pointer({ target: track(), coords: { clientX: 10, clientY: 50 }, keys: '[MouseLeft]' }) expect(getChannelValue(onValueChange.mock.lastCall![0], 'hue')).toBe(270) }) it('fires onValueCommitted once per gesture', async () => { stubSize() const user = userEvent.setup() const onValueCommitted = vi.fn<(value: Color) => void>() renderSlider({ channel: 'hue', defaultValue: 'hsb(0, 50%, 50%)', onValueCommitted }) await user.pointer({ target: track(), coords: { clientX: 50, clientY: 10 }, keys: '[MouseLeft]' }) expect(onValueCommitted).toHaveBeenCalledTimes(1) }) it('ignores interaction when disabled', async () => { const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() renderSlider({ channel: 'hue', defaultValue: 'hsb(0, 50%, 50%)', disabled: true, onValueChange }) expect(track()).toHaveAttribute('data-disabled') expect(slider()).toBeDisabled() await user.click(track()) expect(onValueChange).not.toHaveBeenCalled() }) it('drops the gradient when disabled, so the flat fill can show', () => { renderSlider({ channel: 'alpha', defaultValue: 'rgba(255, 0, 0, 0.5)', disabled: true }) // An inline background-image would paint straight over the disabled class. expect(track().style.backgroundImage).toBe('') expect(track().style.backgroundSize).toBe('') expect(thumb().style.backgroundColor).toBe('') }) it('names the hidden input for form submission', () => { renderSlider({ channel: 'hue', defaultValue: 'hsb(0, 50%, 50%)', name: 'hue' }) expect(slider()).toHaveAttribute('name', 'hue') }) it('has no accessibility violations', async () => { const { container } = renderSlider({ channel: 'hue', defaultValue: 'hsb(0, 50%, 50%)', 'aria-label': 'Hue' }) expect(await axe(container)).toHaveNoViolations() }) }) describe('ColorSlider thumb inset', () => { // The geometry stubs sit on HTMLElement.prototype, so they have to come back off. afterEach(() => vi.restoreAllMocks()) it('holds the thumb inside the track at both ends', () => { stubGeometry(20, 18) const { rerender } = renderSlider({ channel: 'saturation', value: 'hsb(200, 0%, 100%)' }) // Half a thumb (9) plus the 1px of track it already clears, at each end. expect(thumb().style.insetInlineStart).toBe('10px') rerender() expect(thumb().style.insetInlineStart).toBe('calc(100% - 10px)') }) it('insets a vertical track along its own axis', () => { stubGeometry(20, 18) renderSlider({ channel: 'saturation', orientation: 'vertical', value: 'hsb(200, 100%, 100%)' }) expect(thumb().style.top).toBe('10px') expect(thumb().style.insetInlineStart).toBe('50%') }) it('maps a press against the inset track, not the full width', async () => { stubGeometry(20, 18) stubSize() const user = userEvent.setup() const onValueChange = vi.fn<(value: Color) => void>() renderSlider({ channel: 'hue', defaultValue: 'hsb(0, 50%, 50%)', onValueChange }) // 200px wide with 10px off each end, so the middle of the travel is x = 100. await user.pointer({ target: track(), coords: { clientX: 100, clientY: 10 }, keys: '[MouseLeft]' }) expect(getChannelValue(onValueChange.mock.lastCall![0], 'hue')).toBe(180) // The very edge still reaches the minimum rather than stopping short of it. await user.pointer({ target: track(), coords: { clientX: 0, clientY: 10 }, keys: '[MouseLeft]' }) expect(getChannelValue(onValueChange.mock.lastCall![0], 'hue')).toBe(0) }) // The ramp's matching inset has no test here: jsdom's CSS parser rejects any // `calc()` inside a gradient color stop and drops the whole declaration, so // `style.backgroundImage` never sees it. Verified in the browser instead. })