diff --git a/assets/index.less b/assets/index.less index 5fcda105..4306e65b 100644 --- a/assets/index.less +++ b/assets/index.less @@ -55,6 +55,11 @@ border: @handler-border-size solid #fff; border-radius: 50%; box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.06); + &:focus-visible, + &:focus-within { + outline: 2px solid #1677ff; + outline-offset: 1px; + } } &-handler-sm { width: @sm-handler-size; diff --git a/src/ColorPicker.tsx b/src/ColorPicker.tsx index 64d52e76..aaf26d61 100644 --- a/src/ColorPicker.tsx +++ b/src/ColorPicker.tsx @@ -10,6 +10,15 @@ import useColorState from './hooks/useColorState'; import useComponent, { type Components } from './hooks/useComponent'; import type { BaseColorPickerProps, ColorGenInput } from './interface'; +const defaultLocale: Required['locale'] = { + picker: 'Color picker', + pickerDescription: '2D slider', + hue: 'Hue', + alpha: 'Alpha', + saturation: 'Saturation', + brightness: 'Brightness', +}; + const HUE_COLORS = [ { color: 'rgb(255, 0, 0)', @@ -67,8 +76,14 @@ const ColorPicker = forwardRef( disabledAlpha = false, disabled = false, components, + locale, } = props; + const mergedLocale = useMemo( + () => ({ ...defaultLocale, ...locale }), + [locale], + ); + // ========================== Components ========================== const [Slider] = useComponent(components); @@ -134,6 +149,7 @@ const ColorPicker = forwardRef(
@@ -151,6 +167,7 @@ const ColorPicker = forwardRef( value={colorValue.getHue()} onChange={onHueChange} onChangeComplete={onHueChangeComplete} + aria-label={mergedLocale.hue} /> {!disabledAlpha && ( ( value={colorValue.a * 100} onChange={onAlphaChange} onChangeComplete={onAlphaChangeComplete} + aria-label={mergedLocale.alpha} /> )}
diff --git a/src/components/ColorBlock.tsx b/src/components/ColorBlock.tsx index e78b9f3e..77e0a51f 100644 --- a/src/components/ColorBlock.tsx +++ b/src/components/ColorBlock.tsx @@ -1,34 +1,26 @@ import { clsx } from 'clsx'; import React from 'react'; -export type ColorBlockProps = { +export type ColorBlockProps = React.HTMLAttributes & { color: string; prefixCls?: string; - className?: string; - style?: React.CSSProperties; /** Internal usage. Only used in antd ColorPicker semantic structure only */ innerClassName?: string; /** Internal usage. Only used in antd ColorPicker semantic structure only */ innerStyle?: React.CSSProperties; - onClick?: React.MouseEventHandler; }; const ColorBlock: React.FC = ({ color, prefixCls, className, - style, innerClassName, innerStyle, - onClick, + ...props }) => { const colorBlockCls = `${prefixCls}-color-block`; return ( -
+
`) that mutate the value. */ +const VALUE_KEYS = [ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'Home', + 'End', + 'PageUp', + 'PageDown', +]; + +// The range input is a keyboard / screen-reader proxy only — the visible thumb +// is the wrapping
. It must stay focusable and in the a11y tree, so it is +// hidden with `opacity` (not `display`/`visibility`). These styles are inlined +// rather than left to the stylesheet so consumers that don't ship our CSS +// (e.g. antd's own styling) still get a hidden input out of the box. +const RANGE_INPUT_STYLE: React.CSSProperties = { + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + margin: 0, + padding: 0, + opacity: 0, + pointerEvents: 'none', +}; + +interface HandlerAxis extends Omit< + React.InputHTMLAttributes, + 'size' | 'value' | 'onChange' +> { + value: number; + onChange: (value: number) => void; + onChangeComplete: (value: number) => void; +} + +export interface HandlerProps { size?: HandlerSize; color?: string; prefixCls?: string; -}> = ({ size = 'default', color, prefixCls }) => { + disabled?: boolean; + x: HandlerAxis; + y?: HandlerAxis; +} + +type ValueRef = React.RefObject; +type ChangedRef = React.RefObject; + +const Handler: React.FC = ({ + size = 'default', + color, + prefixCls, + disabled, + x, + y, +}) => { + const is2D = !!y; + + // Per-axis interaction state. Each input owns the value it is adjusting so a + // controlled parent re-rendering with a stale value mid-interaction cannot + // reset it. The `y` refs are inert for 1-D sliders. + const xValueRef = React.useRef(x.value); + const xPrevRef = React.useRef(x.value); + const xChangedRef = React.useRef(false); + const xInputRef = React.useRef(null); + + const yValueRef = React.useRef(y?.value ?? 0); + const yPrevRef = React.useRef(y?.value ?? 0); + const yChangedRef = React.useRef(false); + const yInputRef = React.useRef(null); + + // Roving tab index: the 2-D picker is a single tab stop whose focus moves + // between the two axis inputs as the user switches direction, so it reads as + // one control while each axis stays independently operable by AT. + const [activeAxis, setActiveAxis] = React.useState<'x' | 'y'>('x'); + + // Preserve the in-progress value across re-renders; only resync when the + // controlled prop *genuinely* changes, so a stale echo of the pre-interaction + // value cannot clobber what the user is currently adjusting. + if (x.value !== xPrevRef.current) { + xPrevRef.current = x.value; + xValueRef.current = x.value; + } + if (y && y.value !== yPrevRef.current) { + yPrevRef.current = y.value; + yValueRef.current = y.value; + } + + const stepAxis = ( + axis: HandlerAxis, + valueRef: ValueRef, + changedRef: ChangedRef, + direction: 1 | -1, + ) => { + const stepSize = Number(axis.step ?? 1) || 1; + const min = Number(axis.min ?? 0); + const max = Number(axis.max ?? 100); + const current = valueRef.current; + const next = Math.min(max, Math.max(min, current + direction * stepSize)); + // Clamped against a bound — nothing changed, so stay silent like a native + // range instead of emitting a redundant onChange. + if (next === current) { + return; + } + valueRef.current = next; + changedRef.current = true; + axis.onChange(next); + }; + + // Move DOM focus (and the roving tab stop) onto the axis being adjusted so + // the screen reader tracks and announces the value that actually changed. + const focusAxis = (axis: 'x' | 'y') => { + setActiveAxis(axis); + const input = axis === 'y' ? yInputRef.current : xInputRef.current; + if (input && document.activeElement !== input) { + input.focus(); + } + }; + + // Left/Right always drives the x axis, Up/Down the y axis (or the single x + // axis on a 1-D slider). preventDefault stops the browser from also moving + // the focused input's native value. + const handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case 'ArrowRight': + focusAxis('x'); + stepAxis(x, xValueRef, xChangedRef, 1); + break; + case 'ArrowLeft': + focusAxis('x'); + stepAxis(x, xValueRef, xChangedRef, -1); + break; + case 'ArrowUp': + if (y) { + focusAxis('y'); + stepAxis(y, yValueRef, yChangedRef, 1); + } else { + focusAxis('x'); + stepAxis(x, xValueRef, xChangedRef, 1); + } + break; + case 'ArrowDown': + if (y) { + focusAxis('y'); + stepAxis(y, yValueRef, yChangedRef, -1); + } else { + focusAxis('x'); + stepAxis(x, xValueRef, xChangedRef, -1); + } + break; + default: + return; + } + event.preventDefault(); + }; + + // Each input completes its own axis. Because focus follows the adjusted axis, + // key up fires on the input whose value just changed. + const completeAxis = + (axis: HandlerAxis, valueRef: ValueRef, changedRef: ChangedRef) => + (event: React.KeyboardEvent) => { + if (!VALUE_KEYS.includes(event.key) || !changedRef.current) { + return; + } + changedRef.current = false; + axis.onChangeComplete(valueRef.current); + }; + + // Native value changes (Home/End/PageUp/PageDown and AT set-value / increment + // actions) feed the same interaction value for the input's own axis. + const changeAxis = + (axis: HandlerAxis, valueRef: ValueRef, changedRef: ChangedRef) => + (event: React.ChangeEvent) => { + const next = Number(event.target.value); + valueRef.current = next; + changedRef.current = true; + axis.onChange(next); + }; + return (
+ style={{ position: 'relative', backgroundColor: color }} + > + setActiveAxis('x') : undefined} + /> + {y && ( + setActiveAxis('y')} + /> + )} +
); }; diff --git a/src/components/Picker.tsx b/src/components/Picker.tsx index 4996c88c..fa6e64be 100644 --- a/src/components/Picker.tsx +++ b/src/components/Picker.tsx @@ -1,8 +1,9 @@ import type { FC } from 'react'; import React, { useRef } from 'react'; +import type { Color } from '../color'; import useColorDrag from '../hooks/useColorDrag'; import type { BaseColorPickerProps, TransformOffset } from '../interface'; -import { calcOffset, calculateColor } from '../util'; +import { calcOffset, calculateColor, generateColor } from '../util'; import { useEvent } from '@rc-component/util'; import Handler from './Handler'; @@ -11,16 +12,37 @@ import Transform from './Transform'; export type PickerProps = BaseColorPickerProps; +// A stable string identity for a color, used to tell a genuinely new controlled +// value apart from a stale echo of the same value. +const getColorKey = (color: Color) => { + const { h, s, b, a } = color.toHsb(); + return `${h},${s},${b},${a}`; +}; + const Picker: FC = ({ color, onChange, prefixCls, onChangeComplete, disabled, + locale, }) => { const pickerRef = useRef(null); const transformRef = useRef(null); + // Candidate color for the active keyboard/drag interaction. Consulted on + // completion so the *latest* value is reported, even across several presses. const colorRef = useRef(color); + // Key of the controlled color seen on the previous render. Used to accept a + // genuinely new controlled color while ignoring a stale echo of the + // pre-interaction color that a controlled parent may re-render with before + // key up (validation, debouncing, an unrelated state update). Overwriting the + // ref with that stale value would make completion report the old color. + const prevColorKeyRef = useRef(getColorKey(color)); + const nextColorKey = getColorKey(color); + if (nextColorKey !== prevColorKeyRef.current) { + prevColorKeyRef.current = nextColorKey; + colorRef.current = color; + } const onDragChange = useEvent((offsetValue: TransformOffset) => { const calcColor = calculateColor({ @@ -42,6 +64,18 @@ const Picker: FC = ({ onDragChangeComplete: () => onChangeComplete?.(colorRef.current), disabledDrag: disabled, }); + // ===================== Keyboard (2-D handler) ===================== + const hsb = color.toHsb(); + + // Build a new color from the *latest* one (the ref, not the render-time prop) + const changeColor = (channel: 's' | 'b', percent: number) => { + const next = generateColor({ + ...colorRef.current.toHsb(), + [channel]: percent / 100, + }); + colorRef.current = next; + onChange(next); + }; return (
= ({ > - + changeColor('s', percent), + onChangeComplete: () => onChangeComplete?.(colorRef.current), + }} + y={{ + 'aria-label': locale.picker, + 'aria-roledescription': locale.pickerDescription, + 'aria-valuetext': `${locale.brightness}: ${Math.round(hsb.b * 100)}%`, + min: 0, + max: 100, + value: hsb.b * 100, + onChange: percent => changeColor('b', percent), + onChangeComplete: () => onChangeComplete?.(colorRef.current), + }} + />
void; type: HsbaColorType; color: Color; + 'aria-label'?: string; } const Slider: React.FC = props => { @@ -33,6 +34,10 @@ const Slider: React.FC = props => { onChangeComplete, color, type, + min, + max, + value, + 'aria-label': ariaLabel, } = props; const sliderRef = useRef(null); @@ -103,6 +108,15 @@ const Slider: React.FC = props => { size="small" color={handleColor.toHexString()} prefixCls={prefixCls} + disabled={disabled} + x={{ + 'aria-label': ariaLabel, + min, + max, + value, + onChange, + onChangeComplete, + }} /> diff --git a/src/interface.ts b/src/interface.ts index 4fd9077c..ecc29844 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -40,6 +40,14 @@ export interface BaseColorPickerProps { color?: Color; prefixCls?: string; disabled?: boolean; + locale?: { + picker?: string; + pickerDescription?: string; + hue?: string; + alpha?: string; + saturation?: string; + brightness?: string; + }; onChange?: ( color: Color, info?: { type?: HsbaColorType; value?: number }, diff --git a/tests/__snapshots__/index.test.tsx.snap b/tests/__snapshots__/index.test.tsx.snap index 14632137..b8fd43be 100644 --- a/tests/__snapshots__/index.test.tsx.snap +++ b/tests/__snapshots__/index.test.tsx.snap @@ -17,8 +17,36 @@ exports[`ColorPicker > Should component onChange work on no control mode 1`] = ` >
+ style="position: relative; background-color: rgb(22, 119, 255);" + > + + +
Should component onChange work on no control mode 1`] = ` >
+ style="position: relative; background-color: rgb(0, 106, 255);" + > + +
Should component onChange work on no control mode 1`] = ` >
+ style="position: relative; background-color: rgb(22, 119, 255);" + > + +
Should component render correct 1`] = ` >
+ style="position: relative; background-color: rgb(22, 119, 255);" + > + + +
Should component render correct 1`] = ` >
+ style="position: relative; background-color: rgb(0, 106, 255);" + > + +
Should component render correct 1`] = ` >
+ style="position: relative; background-color: rgb(22, 119, 255);" + > + +
Should custom panel work 1`] = ` >
+ style="position: relative; background-color: rgb(22, 119, 255);" + > + + +
Should custom panel work 1`] = ` >
+ style="position: relative; background-color: rgb(0, 106, 255);" + > + +
Should custom panel work 1`] = ` >
+ style="position: relative; background-color: rgb(22, 119, 255);" + > + +
Should disabled alpha work 1`] = ` >
+ style="position: relative; background-color: rgb(22, 119, 255);" + > + + +
Should disabled alpha work 1`] = ` >
+ style="position: relative; background-color: rgb(0, 106, 255);" + > + +
Should prefixCls work 1`] = ` >
+ style="position: relative; background-color: rgb(22, 119, 255);" + > + + +
Should prefixCls work 1`] = ` >
+ style="position: relative; background-color: rgb(0, 106, 255);" + > + +
Should prefixCls work 1`] = ` >
+ style="position: relative; background-color: rgb(22, 119, 255);" + > + +
{ const App = () => ; const { container } = render(); expect( - container.querySelector('.rc-color-picker-handler').getAttribute('style'), - ).toEqual('background-color: rgb(23, 120, 255);'); + (container.querySelector('.rc-color-picker-handler') as HTMLElement).style + .backgroundColor, + ).toEqual('rgb(23, 120, 255)'); }); it('Should rgb string work', () => { const App = () => ; const { container } = render(); expect( - container.querySelector('.rc-color-picker-handler').getAttribute('style'), - ).toEqual('background-color: rgb(23, 120, 255);'); + (container.querySelector('.rc-color-picker-handler') as HTMLElement).style + .backgroundColor, + ).toEqual('rgb(23, 120, 255)'); }); it('Should hex string work', () => { const App = () => ; const { container } = render(); expect( - container.querySelector('.rc-color-picker-handler').getAttribute('style'), - ).toEqual('background-color: rgb(23, 120, 255);'); + (container.querySelector('.rc-color-picker-handler') as HTMLElement).style + .backgroundColor, + ).toEqual('rgb(23, 120, 255)'); }); it('Should hsb obj work', () => { const App = () => ; const { container } = render(); expect( - container.querySelector('.rc-color-picker-handler').getAttribute('style'), - ).toEqual('background-color: rgb(23, 120, 255);'); + (container.querySelector('.rc-color-picker-handler') as HTMLElement).style + .backgroundColor, + ).toEqual('rgb(23, 120, 255)'); }); it('Should rgb obj work', () => { const App = () => ; const { container } = render(); expect( - container.querySelector('.rc-color-picker-handler').getAttribute('style'), - ).toEqual('background-color: rgb(23, 120, 255);'); + (container.querySelector('.rc-color-picker-handler') as HTMLElement).style + .backgroundColor, + ).toEqual('rgb(23, 120, 255)'); }); it('Should disabled work', () => { @@ -497,4 +508,378 @@ describe('ColorPicker', () => { spy.mockRestore(); }); + + describe('Accessibility tests', () => { + const Controlled = (props: Record) => { + const [value, setValue] = useState(defaultColor); + return ( + <> + +
{value.toHsbString()}
+ + ); + }; + + // The two picker axes share the control name (aria-label); each input is + // distinguished by its axis via aria-valuetext (the react-aria ColorArea + // pattern), so the tests grab them positionally: [saturation, brightness]. + const getSaturation = () => screen.getAllByLabelText('Color picker')[0]; + const getBrightness = () => screen.getAllByLabelText('Color picker')[1]; + + it('Should expose default aria-labels on the handles', () => { + render(); + + // Both picker axes share the "Color picker" name; the axis is conveyed + // through aria-valuetext rather than a distinct label. + expect(screen.getAllByLabelText('Color picker')).toHaveLength(2); + expect(screen.getByLabelText('Hue')).toBeTruthy(); + expect(screen.getByLabelText('Alpha')).toBeTruthy(); + }); + + it('Should expose saturation and brightness as separate range inputs', () => { + render(); + + // Two separately operable native ranges, each describing its own axis via + // aria-valuetext and sharing the 2-D slider role description, so AT can + // adjust each one on its own. + const [saturation, brightness] = screen.getAllByLabelText('Color picker'); + expect(saturation).toHaveAttribute('aria-valuetext', 'Saturation: 91%'); + expect(saturation).toHaveAttribute('aria-roledescription', '2D slider'); + expect(brightness).toHaveAttribute('aria-valuetext', 'Brightness: 100%'); + expect(brightness).toHaveAttribute('aria-roledescription', '2D slider'); + expect(brightness).toHaveAttribute('aria-orientation', 'vertical'); + }); + + it('Should adjust brightness with Up/Down while on the saturation axis', () => { + render(); + + const saturation = getSaturation(); + const brightness = getBrightness(); + saturation.focus(); + + // The two inputs act as one 2-D control: Up/Down drives brightness even + // while the saturation input is the focused one. + fireEvent.keyDown(saturation, { key: 'ArrowDown' }); + + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(215, 91%, 99%)', + ); + // Focus follows the axis that changed. + expect(brightness).toHaveFocus(); + }); + + it('Should keep the 2-D picker a single tab stop (roving tabindex)', () => { + render(); + + const saturation = getSaturation(); + const brightness = getBrightness(); + + // Only one axis is in the tab order at a time. + expect(saturation).toHaveAttribute('tabindex', '0'); + expect(brightness).toHaveAttribute('tabindex', '-1'); + + // Switching direction moves the tab stop onto the adjusted axis. + fireEvent.keyDown(saturation, { key: 'ArrowDown' }); + expect(brightness).toHaveAttribute('tabindex', '0'); + expect(saturation).toHaveAttribute('tabindex', '-1'); + }); + + it('Should describe saturation & brightness via aria-valuetext by default', () => { + render(); + + expect(getSaturation()).toHaveAttribute( + 'aria-valuetext', + 'Saturation: 91%', + ); + expect(getBrightness()).toHaveAttribute( + 'aria-valuetext', + 'Brightness: 100%', + ); + }); + + it('Should override the aria-labels through the locale prop', () => { + render( + , + ); + + const [saturation, brightness] = screen.getAllByLabelText('Sélecteur'); + expect(screen.getAllByLabelText('Sélecteur')).toHaveLength(2); + expect(screen.getByLabelText('Teinte')).toBeTruthy(); + expect(screen.getByLabelText('Transparence')).toBeTruthy(); + expect(saturation).toHaveAttribute('aria-valuetext', 'Sat: 91%'); + expect(brightness).toHaveAttribute('aria-valuetext', 'Lum: 100%'); + }); + + it('Should change brightness with the Down arrow on the brightness axis', () => { + const onChangeComplete = vi.fn(); + render(); + + const brightness = getBrightness(); + fireEvent.keyDown(brightness, { key: 'ArrowDown' }); + fireEvent.keyUp(brightness, { key: 'ArrowDown' }); + + // brightness starts at 100% and steps down to 99% + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(215, 91%, 99%)', + ); + expect(onChangeComplete).toHaveBeenCalled(); + }); + + it('Should operate brightness through its native range control', () => { + const onChangeComplete = vi.fn(); + render(); + + const brightness = getBrightness(); + // Emulate an assistive-technology set-value action on the native range, + // then complete the interaction on key up. + fireEvent.change(brightness, { target: { value: '80' } }); + fireEvent.keyUp(brightness, { key: 'ArrowDown' }); + + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(215, 91%, 80%)', + ); + expect(onChangeComplete).toHaveBeenCalled(); + }); + + it('Should not emit changes when the arrow key clamps at a bound', () => { + const onChange = vi.fn(); + const onChangeComplete = vi.fn(); + render( + , + ); + + const brightness = getBrightness(); + // brightness is already at 100%, so Up cannot step further. + fireEvent.keyDown(brightness, { key: 'ArrowUp' }); + fireEvent.keyUp(brightness, { key: 'ArrowUp' }); + + // A clamped press must not fire onChange (no value moved) nor complete. + expect(onChange).not.toHaveBeenCalled(); + expect(onChangeComplete).not.toHaveBeenCalled(); + }); + + it('Should increase saturation on the picker (Arrow Right)', () => { + const onChangeComplete = vi.fn(); + render(); + + const saturation = getSaturation(); + fireEvent.change(saturation, { target: { value: '92' } }); + fireEvent.keyUp(saturation, { key: 'ArrowRight' }); + + // saturation starts at 91% and steps up to 92% + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(215, 92%, 100%)', + ); + expect(onChangeComplete).toHaveBeenCalled(); + }); + + it('Should decrease saturation on the picker (Arrow Left)', () => { + const onChangeComplete = vi.fn(); + render(); + + const saturation = getSaturation(); + fireEvent.change(saturation, { target: { value: '90' } }); + fireEvent.keyUp(saturation, { key: 'ArrowLeft' }); + + // saturation starts at 91% and steps down to 90% + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(215, 90%, 100%)', + ); + expect(onChangeComplete).toHaveBeenCalled(); + }); + + // A controlled parent that keeps the color pinned while still re-rendering + // (validation, debouncing, an unrelated state update) reproduces the race + // where the stale prop echoes back mid-interaction. + const StalePinned = ({ onChangeComplete }: Record) => { + const [, force] = useState(0); + return ( + force(n => n + 1)} + onChangeComplete={onChangeComplete as (color: Color) => void} + /> + ); + }; + + it('Should complete the picker with the latest value despite a stale re-render', () => { + const onChangeComplete = vi.fn(); + render(); + + const saturation = getSaturation(); + // ArrowRight steps 91% -> 92% and triggers a re-render that echoes the + // stale 91% prop before key up. Completion must still report 92%. + fireEvent.keyDown(saturation, { key: 'ArrowRight' }); + fireEvent.keyUp(saturation, { key: 'ArrowRight' }); + + const [completedColor] = onChangeComplete.mock.calls.at(-1); + expect(completedColor.toHsbString()).toBe('hsb(215, 92%, 100%)'); + }); + + it('Should complete a slider with the latest value despite a stale re-render', () => { + const onChangeComplete = vi.fn(); + render(); + + const hue = screen.getByLabelText('Hue'); + // ArrowRight steps hue 215 -> 216 while the prop is pinned at 215. + fireEvent.keyDown(hue, { key: 'ArrowRight' }); + fireEvent.keyUp(hue, { key: 'ArrowRight' }); + + const [completedColor] = onChangeComplete.mock.calls.at(-1); + expect(completedColor.getHue()).toBe(216); + }); + + it('Should step from the latest value on rapid saturation presses', () => { + render(); + + const saturation = getSaturation(); + + // Two Left presses dispatched in the same batch (before the parent + // re-renders). Reading the stale prop would step 91% -> 90% twice; the + // latest-value ref keeps them stepping 91% -> 89%. + act(() => { + saturation.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }), + ); + saturation.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }), + ); + }); + + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(215, 89%, 100%)', + ); + }); + + it('Should step from the latest value on rapid brightness presses', () => { + render(); + + const brightness = getBrightness(); + + // Two Down presses dispatched in the same batch (before the parent + // re-renders). Reading the stale prop would step 100% -> 99% twice; the + // latest-value ref keeps them stepping 100% -> 98%. + act(() => { + brightness.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), + ); + brightness.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), + ); + }); + + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(215, 91%, 98%)', + ); + }); + + it('Should step from the latest value on rapid saturation and brightness presses', () => { + render(); + + const saturation = getSaturation(); + const brightness = getBrightness(); + + // One Down (brightness 100% -> 99%) and one Left (saturation 91% -> 90%) + // dispatched in the same batch. Deriving the second change from the stale + // prop would revert the first axis; the shared color ref keeps both. + act(() => { + brightness.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), + ); + saturation.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }), + ); + }); + + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(215, 90%, 99%)', + ); + }); + + it('Should change hue when the hue slider value changes via keyboard', () => { + const onChangeComplete = vi.fn(); + render(); + + const hue = screen.getByLabelText('Hue'); + fireEvent.change(hue, { target: { value: '100' } }); + fireEvent.keyUp(hue, { key: 'ArrowRight' }); + + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(100, 91%, 100%)', + ); + expect(onChangeComplete).toHaveBeenCalled(); + }); + + it('Should change alpha when the alpha slider value changes via keyboard', () => { + render(); + + const alpha = screen.getByLabelText('Alpha'); + fireEvent.change(alpha, { target: { value: '50' } }); + + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsba(215, 91%, 100%, 0.50)', + ); + }); + + it('Should ignore keys that do not change the value', () => { + const onChange = vi.fn(); + const onChangeComplete = vi.fn(); + render( + , + ); + + const saturation = getSaturation(); + // A non-value key (e.g. Tab) must neither step (keydown) nor commit (keyup). + fireEvent.keyDown(saturation, { key: 'Tab' }); + fireEvent.keyUp(saturation, { key: 'Tab' }); + + expect(onChange).not.toHaveBeenCalled(); + expect(onChangeComplete).not.toHaveBeenCalled(); + }); + + it('Should increase saturation with the Right arrow on the picker', () => { + render(); + + const saturation = getSaturation(); + fireEvent.keyDown(saturation, { key: 'ArrowRight' }); + + // saturation steps 91% -> 92% + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(215, 92%, 100%)', + ); + }); + + it('Should step a 1-D slider with the Up/Down arrows', () => { + render(); + + const hue = screen.getByLabelText('Hue'); + // No vertical axis on a slider, so Up/Down drive its single (hue) axis. + fireEvent.keyDown(hue, { key: 'ArrowUp' }); + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(216, 91%, 100%)', + ); + + fireEvent.keyDown(hue, { key: 'ArrowDown' }); + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(215, 91%, 100%)', + ); + }); + }); });