diff --git a/assets/index.less b/assets/index.less index 5fcda10..4306e65 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 64d52e7..aaf26d6 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 e78b9f3..77e0a51 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', +]; + +const isVerticalKey = (key: string) => key === 'ArrowUp' || key === 'ArrowDown'; + +// 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; +} + +const Handler: React.FC = ({ + size = 'default', + color, + prefixCls, + disabled, + x, + y, +}) => { + // The browser ignores Up/Down on a horizontal range, so the vertical axis is + // handled here: clamp to its own [min, max] and emit through its callbacks. + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!y || !isVerticalKey(event.key)) { + return; + } + event.preventDefault(); + const step = Number(y.step ?? 1) || 1; + const min = Number(y.min ?? 0); + const max = Number(y.max ?? 100); + const delta = event.key === 'ArrowUp' ? step : -step; + y.onChange(Math.min(max, Math.max(min, y.value + delta))); + }; + return (
+ style={{ position: 'relative', backgroundColor: color }} + > + x.onChange(Number(event.target.value))} + onKeyDown={y ? handleKeyDown : undefined} + onKeyUp={event => { + if (!VALUE_KEYS.includes(event.key)) { + return; + } + if (y && isVerticalKey(event.key)) { + y.onChangeComplete(y.value); + } else { + x.onChangeComplete(Number(event.currentTarget.value)); + } + }} + /> +
); }; diff --git a/src/components/Picker.tsx b/src/components/Picker.tsx index 93a85eb..8b0155d 100644 --- a/src/components/Picker.tsx +++ b/src/components/Picker.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react'; import React, { useRef } from 'react'; 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'; @@ -17,6 +17,7 @@ const Picker: FC = ({ prefixCls, onChangeComplete, disabled, + locale, }) => { const pickerRef = useRef(); const transformRef = useRef(); @@ -43,6 +44,16 @@ const Picker: FC = ({ disabledDrag: disabled, }); + // ===================== Keyboard (2-D handler) ===================== + const hsb = color.toHsb(); + + // Build a new color from the current one with a single HSB channel changed. + const changeColor = (channel: 's' | 'b', percent: number) => { + const next = generateColor({ ...hsb, [channel]: percent / 100 }); + colorRef.current = next; + onChange(next); + }; + return (
= ({ > - + changeColor('s', percent), + onChangeComplete: () => onChangeComplete?.(colorRef.current), + }} + y={{ + 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 4fd9077..ecc2984 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 1b28a51..d7753f9 100644 --- a/tests/__snapshots__/index.test.tsx.snap +++ b/tests/__snapshots__/index.test.tsx.snap @@ -17,8 +17,21 @@ 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,137 @@ describe('ColorPicker', () => { spy.mockRestore(); }); + + describe('Accessibility tests', () => { + const Controlled = (props: Record) => { + const [value, setValue] = useState(defaultColor); + return ( + <> + +
{value.toHsbString()}
+ + ); + }; + + it('Should expose default aria-labels on the handles', () => { + render(); + + expect(screen.getByLabelText('Color picker')).toBeTruthy(); + expect(screen.getByLabelText('Hue')).toBeTruthy(); + expect(screen.getByLabelText('Alpha')).toBeTruthy(); + }); + + it('Should describe saturation & brightness via aria-valuetext by default', () => { + render(); + + expect(screen.getByLabelText('Color picker')).toHaveAttribute( + 'aria-valuetext', + 'Saturation 91%, Brightness 100%', + ); + }); + + it('Should override the aria-labels through the locale prop', () => { + render( + , + ); + + expect(screen.getByLabelText('Sélecteur')).toBeTruthy(); + expect(screen.getByLabelText('Teinte')).toBeTruthy(); + expect(screen.getByLabelText('Transparence')).toBeTruthy(); + expect(screen.getByLabelText('Sélecteur')).toHaveAttribute( + 'aria-valuetext', + 'Sat 91%, Lum 100%', + ); + }); + + it('Should change brightness with the Down arrow on the picker handle', () => { + const onChangeComplete = vi.fn(); + render(); + + const picker = screen.getByLabelText('Color picker'); + fireEvent.keyDown(picker, { key: 'ArrowDown' }); + fireEvent.keyUp(picker, { 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 clamp brightness at 100% when pressing the Up arrow', () => { + render(); + + const picker = screen.getByLabelText('Color picker'); + fireEvent.keyDown(picker, { key: 'ArrowUp' }); + + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(215, 91%, 100%)', + ); + }); + + it('Should increase saturation on the picker (Arrow Right)', () => { + const onChangeComplete = vi.fn(); + render(); + + const picker = screen.getByLabelText('Color picker'); + fireEvent.change(picker, { target: { value: '92' } }); + fireEvent.keyUp(picker, { 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 picker = screen.getByLabelText('Color picker'); + fireEvent.change(picker, { target: { value: '90' } }); + fireEvent.keyUp(picker, { 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(); + }); + + 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)', + ); + }); + }); });