diff --git a/README.md b/README.md index a8197c4b..893ec374 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ - Accepts string, number, RGB, RGBA, HSB, HSBA, and `Color` inputs. - Provides hue and alpha controls with change and drag-complete callbacks. - Exposes `Color` helpers for hex, RGB, and HSB conversions. +- Exposes keyboard-operable, screen-reader-labelled controls with overridable strings. ## Install @@ -61,16 +62,45 @@ Then open `http://localhost:8000`. ### ColorPicker -| Property | Description | Type | Default | -| ---------------- | --------------------------------------- | ---------------------------------------------------------------------------- | ----------------- | -| defaultValue | Default color value | `ColorGenInput` | - | -| disabled | Whether the color picker is disabled | boolean | false | -| disabledAlpha | Whether to hide the alpha slider | boolean | false | -| panelRender | Custom panel renderer | `(panel: React.ReactElement) => React.ReactElement` | - | -| prefixCls | Component class name prefix | string | `rc-color-picker` | -| value | Current color value | `ColorGenInput` | - | -| onChange | Callback when color changes | `(color: Color, info?: { type?: 'hue' \| 'alpha'; value?: number }) => void` | - | -| onChangeComplete | Callback when a drag operation finishes | `(color: Color, info?: { type?: 'hue' \| 'alpha'; value?: number }) => void` | - | +| Property | Description | Type | Default | +| ---------------- | -------------------------------------------- | ---------------------------------------------------------------------------- | ----------------- | +| defaultValue | Default color value | `ColorGenInput` | - | +| disabled | Whether the color picker is disabled | boolean | false | +| disabledAlpha | Whether to hide the alpha slider | boolean | false | +| locale | Accessible labels for the picker and sliders | [`Locale`](#locale) | - | +| panelRender | Custom panel renderer | `(panel: React.ReactElement) => React.ReactElement` | - | +| prefixCls | Component class name prefix | string | `rc-color-picker` | +| value | Current color value | `ColorGenInput` | - | +| onChange | Callback when color changes | `(color: Color, info?: { type?: 'hue' \| 'alpha'; value?: number }) => void` | - | +| onChangeComplete | Callback when a drag operation finishes | `(color: Color, info?: { type?: 'hue' \| 'alpha'; value?: number }) => void` | - | + +### Locale + +Overrides the accessible names the controls expose to assistive technology. Every key is optional and falls back to its default, so pass only the strings you need to translate. + +| Property | Description | Type | Default | +| ----------------- | ------------------------------------------------------------- | ------ | -------------- | +| alpha | `aria-label` of the alpha slider | string | `Alpha` | +| brightness | Channel name in the brightness axis `aria-valuetext` | string | `Brightness` | +| hue | `aria-label` of the hue slider | string | `Hue` | +| picker | `aria-label` of both saturation and brightness axes | string | `Color picker` | +| pickerDescription | `aria-roledescription` of both saturation and brightness axes | string | `2D slider` | +| saturation | Channel name in the saturation axis `aria-valuetext` | string | `Saturation` | + +```tsx | pure + +``` + +The 2-D area renders one range input per axis. Both share the `picker` name and the `pickerDescription` role description, while each announces its own channel through `aria-valuetext` — `Saturation: 91%` and `Brightness: 100%` — so `saturation` and `brightness` are channel names rather than whole labels. ### Color @@ -82,6 +112,33 @@ Then open `http://localhost:8000`. | toRgb | Convert to RGB object | `() => { r: number; g: number; b: number; a: number }` | | toRgbString | Convert to RGB color string | `() => string` | +### ColorBlock + +A standalone swatch, exported for building custom triggers and panels. + +```tsx | pure +import { ColorBlock } from '@rc-component/color-picker'; + +; +``` + +| Property | Description | Type | Default | +| --------- | ------------------------------- | -------------------------------------- | ------- | +| color | Color used to fill the swatch | string | - | +| prefixCls | Component class name prefix | string | - | +| className | Class name of the outer element | string | - | +| style | Style of the outer element | `React.CSSProperties` | - | +| ...rest | Forwarded to the outer `div` | `React.HTMLAttributes` | - | + +`ColorBlockProps` extends `React.HTMLAttributes`, so any standard div attribute or event handler — `onClick`, `title`, `role`, `tabIndex`, `data-*`, `aria-*` — reaches the outer element. `innerClassName` and `innerStyle` also exist on the type, but they are internal to antd's semantic structure; treat them as private. + +Passing `onClick` also makes the swatch a keyboard-accessible button: it receives `role="button"`, `tabIndex={0}`, an `aria-label` defaulting to `color`, and `Enter` / `Space` activate it just like a pointer click. Your own `role`, `tabIndex` or `aria-label` still wins over those defaults, and your `onKeyDown` is composed with the built-in activation rather than replacing it — call `event.preventDefault()` in it to suppress activation, as you would on a native button. Without `onClick` the swatch stays a plain, non-focusable, unlabelled `div`. + ## Development ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index f1b8228f..b459d4dd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,6 +21,7 @@ - 支持字符串、数字、RGB、RGBA、HSB、HSBA 和 `Color` 输入。 - 提供带有更改和拖动完成回调的色调和 alpha 控件。 - 暴露 `Color` 辅助方法,用于 hex、RGB 和 HSB 转换。 +- 提供可用键盘操作、带屏幕阅读器标签的控件,文案可覆盖。 ## 安装 @@ -61,16 +62,45 @@ npm start ### ColorPicker -| 参数 | 说明 | 类型 | 默认值 | -| ---------------- | -------------------- | ---------------------------------------------------------------------------- | ----------------- | -| defaultValue | 默认颜色值 | `ColorGenInput` | - | -| disabled | 颜色选择器是否禁用 | boolean | false | -| disabledAlpha | 是否隐藏 alpha 滑块 | boolean | false | -| panelRender | 自定义面板渲染器 | `(panel: React.ReactElement) => React.ReactElement` | - | -| prefixCls | 组件 className 前缀 | string | `rc-color-picker` | -| value | 当前颜色值 | `ColorGenInput` | - | -| onChange | 颜色变化时的回调 | `(color: Color, info?: { type?: 'hue' \| 'alpha'; value?: number }) => void` | - | -| onChangeComplete | 拖动操作完成时的回调 | `(color: Color, info?: { type?: 'hue' \| 'alpha'; value?: number }) => void` | - | +| 参数 | 说明 | 类型 | 默认值 | +| ---------------- | ------------------------ | ---------------------------------------------------------------------------- | ----------------- | +| defaultValue | 默认颜色值 | `ColorGenInput` | - | +| disabled | 颜色选择器是否禁用 | boolean | false | +| disabledAlpha | 是否隐藏 alpha 滑块 | boolean | false | +| locale | 选择器与滑块的无障碍标签 | [`Locale`](#locale) | - | +| panelRender | 自定义面板渲染器 | `(panel: React.ReactElement) => React.ReactElement` | - | +| prefixCls | 组件 className 前缀 | string | `rc-color-picker` | +| value | 当前颜色值 | `ColorGenInput` | - | +| onChange | 颜色变化时的回调 | `(color: Color, info?: { type?: 'hue' \| 'alpha'; value?: number }) => void` | - | +| onChangeComplete | 拖动操作完成时的回调 | `(color: Color, info?: { type?: 'hue' \| 'alpha'; value?: number }) => void` | - | + +### Locale + +覆盖控件向辅助技术暴露的无障碍名称。所有字段均为可选,未传入时回退到默认值,因此只需覆盖需要翻译的字段。 + +| 参数 | 说明 | 类型 | 默认值 | +| ----------------- | ----------------------------------------- | ------ | -------------- | +| alpha | alpha 滑块的 `aria-label` | string | `Alpha` | +| brightness | 明度轴 `aria-valuetext` 中的通道名称 | string | `Brightness` | +| hue | 色相滑块的 `aria-label` | string | `Hue` | +| picker | 饱和度轴与明度轴的 `aria-label` | string | `Color picker` | +| pickerDescription | 饱和度轴与明度轴的 `aria-roledescription` | string | `2D slider` | +| saturation | 饱和度轴 `aria-valuetext` 中的通道名称 | string | `Saturation` | + +```tsx | pure + +``` + +二维选择区域为每个轴渲染一个 range 输入框。两者共享 `picker` 名称与 `pickerDescription` 角色描述,并各自通过 `aria-valuetext` 播报自身通道(如 `Saturation: 91%`、`Brightness: 100%`),因此 `saturation` 与 `brightness` 是通道名称而非完整标签。 ### Color @@ -82,6 +112,33 @@ npm start | toRgb | 转换为 RGB 对象 | `() => { r: number; g: number; b: number; a: number }` | | toRgbString | 转换为 RGB 颜色字符串 | `() => string` | +### ColorBlock + +独立的色块组件,用于构建自定义触发器与面板。 + +```tsx | pure +import { ColorBlock } from '@rc-component/color-picker'; + +; +``` + +| 参数 | 说明 | 类型 | 默认值 | +| --------- | ---------------------- | -------------------------------------- | ------ | +| color | 用于填充色块的颜色 | string | - | +| prefixCls | 组件 className 前缀 | string | - | +| className | 最外层元素的 className | string | - | +| style | 最外层元素的样式 | `React.CSSProperties` | - | +| ...rest | 透传到最外层 `div` | `React.HTMLAttributes` | - | + +`ColorBlockProps` 继承 `React.HTMLAttributes`,因此任意标准 div 属性或事件处理器(`onClick`、`title`、`role`、`tabIndex`、`data-*`、`aria-*`)都会透传到最外层元素。类型上还存在 `innerClassName` 与 `innerStyle`,但它们是 antd 语义化结构的内部实现,请视为私有。 + +传入 `onClick` 时,色块还会变成可通过键盘操作的按钮:自动带上 `role="button"`、`tabIndex={0}`、默认取 `color` 的 `aria-label`,并且 `Enter` / `Space` 与鼠标点击等效。你自行传入的 `role`、`tabIndex` 或 `aria-label` 仍会覆盖这些默认值;你的 `onKeyDown` 会与内置的键盘激活组合执行,而不会将其替换——若要阻止激活,可在其中调用 `event.preventDefault()`,与原生按钮的行为一致。未传 `onClick` 时,色块保持为普通的、不可聚焦且无无障碍名称的 `div`。 + ## 本地开发 ```bash 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..d60a8ae5 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,22 @@ const ColorPicker = forwardRef( disabledAlpha = false, disabled = false, components, + locale, } = props; + const mergedLocale = useMemo( + () => ({ + picker: locale?.picker ?? defaultLocale.picker, + pickerDescription: + locale?.pickerDescription ?? defaultLocale.pickerDescription, + hue: locale?.hue ?? defaultLocale.hue, + alpha: locale?.alpha ?? defaultLocale.alpha, + saturation: locale?.saturation ?? defaultLocale.saturation, + brightness: locale?.brightness ?? defaultLocale.brightness, + }), + [locale], + ); + // ========================== Components ========================== const [Slider] = useComponent(components); @@ -134,6 +157,7 @@ const ColorPicker = forwardRef(
@@ -151,6 +175,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..f9626d2b 100644 --- a/src/components/ColorBlock.tsx +++ b/src/components/ColorBlock.tsx @@ -1,33 +1,48 @@ 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`; + + const onKeyDown: React.KeyboardEventHandler = event => { + // Compose instead of replace: a consumer handler still runs, and cancelling the event + // opts out of activation the same way it does on a native button. + props.onKeyDown?.(event); + + if (event.defaultPrevented) { + return; + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.currentTarget.click(); + } + }; + 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' | 'onKeyDown' | 'onKeyUp' | 'onFocus' +> { + 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. `valueRef` is the value the axis is being + // adjusted to, and is authoritative only while `changedRef` marks an + // interaction as in flight. Between interactions the controlled prop is the + // source of truth, so neither ref is ever synced during render: a parent that + // rejects a change simply gets the next press restarted from the value it + // committed, the way a native range snaps back, and nothing is written from a + // render that concurrent React may abandon. The `y` refs are inert for 1-D + // sliders. + const xValueRef = React.useRef(x.value); + const xChangedRef = React.useRef(false); + const xInputRef = React.useRef(null); + + const yValueRef = React.useRef(y?.value ?? 0); + const yChangedRef = React.useRef(false); + const yInputRef = React.useRef(null); + + // Whether a value key is currently held on this control. Tells the first + // press of a new interaction (restart from the prop) apart from a key repeat + // or a second axis within the same one (chain off `valueRef`). + const keyHeldRef = React.useRef(false); + + // 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. `null` + // until the control is first used, when the x axis holds the tab stop. + const [activeAxis, setActiveAxis] = React.useState<'x' | 'y' | null>(null); + // Whether the keyboard has moved a value since focus entered the control. + // Reveals both axes for the duration of the interaction — see the a11y-tree + // note below. + const [valueChangedViaKey, setValueChangedViaKey] = React.useState(false); + + 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 = changedRef.current ? valueRef.current : axis.value; + + // A native range only allows values on the `min + n * step` grid. Per the + // stepUp()/stepDown() algorithm, a value off that grid — a color channel + // that doesn't land on a whole percent — snaps to the neighbouring grid + // value in the direction of travel, and that snap *is* the step; only an + // already-aligned value advances by a full step. + const steps = (current - min) / stepSize; + const nearest = Math.round(steps); + const nextSteps = + Math.abs(steps - nearest) < STEP_EPSILON + ? nearest + direction + : direction > 0 + ? Math.ceil(steps) + : Math.floor(steps); + const next = Math.min(max, Math.max(min, min + nextSteps * 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; + setValueChangedViaKey(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) { + // The state update above is batched, so the input is still carrying the + // aria-hidden it last rendered with. Reveal it here rather than letting + // the commit do it, so focus never lands on a control that is hidden from + // the accessibility tree. Safe to do imperatively: the axis being focused + // is by definition the active one, so the pending render drops the + // attribute too and lands on the same value. + input.removeAttribute('aria-hidden'); + 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) => { + if (!VALUE_KEYS.includes(event.key)) { + return; + } + + // A press with no key already held opens a fresh interaction: drop the + // previous one's in-flight values so stepping restarts from what the parent + // actually committed. + if (!keyHeldRef.current) { + xChangedRef.current = false; + yChangedRef.current = false; + } + keyHeldRef.current = true; + + 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: + // Home/End/PageUp/PageDown: let the native range move the value and + // pick it up from the resulting change event. + return; + } + event.preventDefault(); + }; + + const getAxisState = (axis: 'x' | 'y') => + axis === 'y' + ? { axis: y, valueRef: yValueRef, changedRef: yChangedRef } + : { axis: x, valueRef: xValueRef, changedRef: xChangedRef }; + + // Both axes form a single 2-D interaction, so it completes once and both are + // dropped together — clearing only the axis that reported would leave the + // other pending for a later key up or blur to complete the same interaction a + // second time, off a color already handed back to the parent. Conversely, the + // axis the event fired on may not be the one that moved (a press clamped at a + // bound after the other axis changed), so a pending axis still completes even + // when it isn't the preferred one. Closing the interaction hands authority + // back to the controlled prop, so the next press starts from what the parent + // committed. + const completeInteraction = (preferred: 'x' | 'y') => { + const first = getAxisState(preferred); + const second = getAxisState(preferred === 'y' ? 'x' : 'y'); + const pending = + first.axis && first.changedRef.current + ? first + : second.axis && second.changedRef.current + ? second + : null; + + keyHeldRef.current = false; + xChangedRef.current = false; + yChangedRef.current = false; + + pending?.axis.onChangeComplete(pending.valueRef.current); + }; + + // Key up on either input closes the shared interaction. Because focus follows + // the adjusted axis, it fires on the input the user was last driving. + const completeAxis = + (axis: 'x' | 'y') => (event: React.KeyboardEvent) => { + if (!VALUE_KEYS.includes(event.key)) { + return; + } + completeInteraction(axis); + }; + + // Native value changes for the input's own axis, from two distinct sources. + const changeAxis = + (axis: HandlerAxis, valueRef: ValueRef, changedRef: ChangedRef) => + (event: React.ChangeEvent) => { + const next = Number(event.target.value); + + if (keyHeldRef.current) { + // Part of a keyboard interaction: Home/End/PageUp/PageDown are left to + // the native range, so their value arrives here and key up commits it. + valueRef.current = next; + changedRef.current = true; + setValueChangedViaKey(true); + axis.onChange(next); + return; + } + + // An AT set-value / increment action fires `change` with no key press + // around it, so no key up will follow to commit it. It is a whole + // interaction on its own: report and complete it here, leaving nothing + // in flight for an unrelated later key up to pick up. + changedRef.current = false; + axis.onChange(next); + axis.onChangeComplete(next); + }; + + // Focus leaving the control altogether — as opposed to moving between its two + // axes — ends the keyboard interaction. + const handleBlur = (event: React.FocusEvent) => { + if (event.currentTarget.contains(event.relatedTarget as Node | null)) { + return; + } + setValueChangedViaKey(false); + + // Focus can leave mid-press — a click elsewhere, or AT moving on — so no key + // up will follow to close the interaction. Commit it here instead: otherwise + // the parent never hears the interaction ended, and the value stays live for + // the next press to chain off, drifting away from what the parent committed. + completeInteraction(activeAxis ?? 'x'); + }; + + // A screen reader listing the form controls should find one "2D slider", not + // two identically named ones, so the axis without focus is hidden from the + // accessibility tree. While the keyboard is driving the value both axes are + // revealed, so the roving focus never lands on a hidden input and AT can read + // either channel. Mirrors react-aria's useColorArea, which likewise gives + // both axes the same name and separates them by aria-valuetext. + const xActive = !activeAxis || activeAxis === 'x'; + const yActive = activeAxis === 'y'; + return (
+ style={{ position: 'relative', backgroundColor: color }} + onBlur={handleBlur} + > + setActiveAxis('x') : undefined} + /> + {y && ( + setActiveAxis('y')} + /> + )} +
); }; diff --git a/src/components/Picker.tsx b/src/components/Picker.tsx index 4996c88c..cdc6193a 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,10 +17,30 @@ const Picker: FC = ({ prefixCls, onChangeComplete, disabled, + locale, }) => { const pickerRef = useRef(null); const transformRef = useRef(null); + // Candidate color for the keyboard/drag interaction in flight. Consulted on + // completion so the *latest* value is reported, even across several presses. const colorRef = useRef(color); + // Whether `colorRef` currently holds such a candidate. Outside an interaction + // the controlled `color` prop is the source of truth, so the ref is never + // synced during render: a stale echo of the pre-interaction color has nothing + // to clobber, and nothing is written from a render that concurrent React may + // abandon. + const activeRef = useRef(false); + + // The in-flight candidate while interacting, otherwise the controlled prop. + const getLatestColor = () => (activeRef.current ? colorRef.current : color); + + // Report the latest value, then hand authority back to the controlled prop so + // the next interaction starts from what the parent committed. + const completeColor = () => { + const latest = getLatestColor(); + activeRef.current = false; + onChangeComplete?.(latest); + }; const onDragChange = useEvent((offsetValue: TransformOffset) => { const calcColor = calculateColor({ @@ -30,6 +50,7 @@ const Picker: FC = ({ color, }); colorRef.current = calcColor; + activeRef.current = true; onChange(calcColor); }); @@ -39,9 +60,23 @@ const Picker: FC = ({ targetRef: transformRef, calculate: () => calcOffset(color), onDragChange, - onDragChangeComplete: () => onChangeComplete?.(colorRef.current), + onDragChangeComplete: completeColor, disabledDrag: disabled, }); + // ===================== Keyboard (2-D handler) ===================== + const hsb = color.toHsb(); + + // Build a new color from the *latest* one, so presses batched before the + // parent re-renders still chain off each other. + const changeColor = (channel: 's' | 'b', percent: number) => { + const next = generateColor({ + ...getLatestColor().toHsb(), + [channel]: percent / 100, + }); + colorRef.current = next; + activeRef.current = true; + onChange(next); + }; return (
= ({ > - + changeColor('s', percent), + onChangeComplete: completeColor, + }} + y={{ + 'aria-label': locale.picker, + 'aria-roledescription': locale.pickerDescription, + 'aria-valuetext': `${locale.brightness}: ${Math.round(hsb.b * 100)}%`, + min: 0, + max: 100, + value: Math.round(hsb.b * 100), + onChange: percent => changeColor('b', percent), + onChangeComplete: completeColor, + }} + />
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..25e94d8e 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);" + > + +
{ expect(innerDiv).toHaveClass('my-inner-class'); expect(innerDiv).toHaveStyle({ color: '#903' }); }); + + describe('ColorBlock onClick', () => { + it('should not be interactive when onClick is not passed', () => { + const onParentClick = vi.fn(); + + const { container } = render( +
+ +
, + ); + + const block = container.querySelector('.test-color-block'); + expect(block).not.toHaveAttribute('role'); + expect(block).not.toHaveAttribute('tabindex'); + + // No keyboard activation without `onClick` + fireEvent.keyDown(block, { key: 'Enter' }); + fireEvent.keyDown(block, { key: ' ' }); + expect(onParentClick).not.toHaveBeenCalled(); + }); + + it('should be a focusable button when onClick is passed', () => { + const onClick = vi.fn(); + + const { container } = render( + , + ); + + const block = container.querySelector('.test-color-block'); + expect(block).toHaveAttribute('role', 'button'); + expect(block).toHaveAttribute('tabindex', '0'); + + fireEvent.click(block); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it.each(['Enter', ' '])('should trigger onClick when pressing %s', key => { + const onClick = vi.fn(); + + const { container } = render( + , + ); + + const block = container.querySelector('.test-color-block'); + const event = createEvent.keyDown(block, { key }); + fireEvent(block, event); + + expect(onClick).toHaveBeenCalledTimes(1); + // Prevent page scroll on Space and form submit on Enter + expect(event.defaultPrevented).toBe(true); + }); + + it('should not trigger onClick for other keys', () => { + const onClick = vi.fn(); + + const { container } = render( + , + ); + + const block = container.querySelector('.test-color-block'); + fireEvent.keyDown(block, { key: 'Escape' }); + fireEvent.keyDown(block, { key: 'a' }); + + expect(onClick).not.toHaveBeenCalled(); + }); + + it('should keep customized role and tabIndex', () => { + const { container } = render( + , + ); + + const block = container.querySelector('.test-color-block'); + expect(block).toHaveAttribute('role', 'menuitem'); + expect(block).toHaveAttribute('tabindex', '-1'); + }); + + it('should compose a customized onKeyDown with keyboard activation', () => { + const onClick = vi.fn(); + const onKeyDown = vi.fn(); + + const { container } = render( + , + ); + + const block = container.querySelector('.test-color-block'); + fireEvent.keyDown(block, { key: 'Enter' }); + + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('should let a customized onKeyDown cancel keyboard activation', () => { + const onClick = vi.fn(); + const onKeyDown = vi.fn((event: React.KeyboardEvent) => { + event.preventDefault(); + }); + + const { container } = render( + , + ); + + const block = container.querySelector('.test-color-block'); + fireEvent.keyDown(block, { key: 'Enter' }); + + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('should still forward onKeyDown when onClick is not passed', () => { + const onKeyDown = vi.fn(); + + const { container } = render( + , + ); + + fireEvent.keyDown(container.querySelector('.test-color-block'), { + key: 'Enter', + }); + + expect(onKeyDown).toHaveBeenCalledTimes(1); + }); + + it('should fall back to the color as accessible name', () => { + const { container } = render( + , + ); + + expect(container.querySelector('.test-color-block')).toHaveAccessibleName( + 'red', + ); + }); + + it('should keep the customized aria-label as accessible name', () => { + const { container } = render( + , + ); + + expect(container.querySelector('.test-color-block')).toHaveAccessibleName( + 'Brand red', + ); + }); + + it('should not name a block without onClick', () => { + const { container } = render(); + + expect(container.querySelector('.test-color-block')).not.toHaveAttribute( + 'aria-label', + ); + }); + }); }); diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 44aef518..353ebb97 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1,6 +1,12 @@ /* eslint-disable @typescript-eslint/no-loop-func */ import { spyElementPrototypes } from '@rc-component/util'; -import { act, createEvent, fireEvent, render } from '@testing-library/react'; +import { + act, + createEvent, + fireEvent, + render, + screen, +} from '@testing-library/react'; import React, { useState } from 'react'; import { expect } from 'vitest'; import ColorPicker, { Color } from '../src'; @@ -333,40 +339,45 @@ describe('ColorPicker', () => { 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,729 @@ 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) and are + // distinguished by aria-valuetext, following react-aria's ColorArea; only + // one of them is exposed to the accessibility tree at a time. These queries + // match on the label attribute rather than the a11y tree, so they find both + // regardless of aria-hidden, and grab them positionally: [sat, brightness]. + const getSaturation = () => screen.getAllByLabelText('Color picker')[0]; + const getBrightness = () => screen.getAllByLabelText('Color picker')[1]; + + it('Should expose only the focused picker axis to the a11y tree', () => { + render(); + + const [saturation, brightness] = screen.getAllByLabelText('Color picker'); + + // Untouched, the x axis is the tab stop and the only axis a screen reader + // can see, so listing the form controls turns up a single "2D slider" + // rather than two identically named ones. + expect(saturation).not.toHaveAttribute('tabindex'); + expect(saturation).not.toHaveAttribute('aria-hidden'); + expect(brightness).toHaveAttribute('tabindex', '-1'); + expect(brightness).toHaveAttribute('aria-hidden', 'true'); + + // Once the keyboard drives a value both axes are revealed, so the roving + // focus never lands on a hidden input and AT can read either channel. + fireEvent.keyDown(saturation, { key: 'ArrowRight' }); + expect(brightness).not.toHaveAttribute('aria-hidden'); + fireEvent.keyUp(saturation, { key: 'ArrowRight' }); + + // Switching axis hands the tab stop to the y axis. The switch moves DOM + // focus within the control, which must not end the interaction. + fireEvent.keyDown(brightness, { key: 'ArrowDown' }); + expect(brightness).not.toHaveAttribute('tabindex'); + expect(saturation).toHaveAttribute('tabindex', '-1'); + expect(saturation).not.toHaveAttribute('aria-hidden'); + + // Focus leaving the control ends it, hiding whichever axis is no longer + // the active one. + fireEvent.focusOut(brightness, { relatedTarget: document.body }); + expect(saturation).toHaveAttribute('aria-hidden', 'true'); + expect(brightness).not.toHaveAttribute('aria-hidden'); + }); + + it('Should reveal an axis before moving focus onto it', () => { + render(); + + const saturation = getSaturation(); + const brightness = getBrightness(); + + // What focus actually lands on, as the DOM looked at that moment. The + // state update that reveals an axis is batched, so revealing it has to + // happen before focus moves — otherwise AT is handed a control that is + // still aria-hidden. + const hiddenOnFocus: (string | null)[] = []; + const record = (event: Event) => + hiddenOnFocus.push( + (event.target as HTMLElement).getAttribute('aria-hidden'), + ); + document.addEventListener('focusin', record); + + try { + // Onto the y axis, which starts hidden. + fireEvent.keyDown(saturation, { key: 'ArrowDown' }); + fireEvent.keyUp(saturation, { key: 'ArrowDown' }); + // Leaving hides the x axis; stepping it again focuses it back. + fireEvent.focusOut(brightness, { relatedTarget: document.body }); + fireEvent.keyDown(brightness, { key: 'ArrowRight' }); + } finally { + document.removeEventListener('focusin', record); + } + + expect(hiddenOnFocus).toEqual([null, null]); + expect(saturation).toHaveFocus(); + }); + + it('Should not hide or untab a 1-D slider', () => { + render(); + + // The exposure dance only applies to the 2-D picker; a lone hue/alpha + // range stays a plain tab stop throughout. + const hue = screen.getByLabelText('Hue'); + expect(hue).not.toHaveAttribute('tabindex'); + expect(hue).not.toHaveAttribute('aria-hidden'); + + fireEvent.keyDown(hue, { key: 'ArrowRight' }); + expect(hue).not.toHaveAttribute('tabindex'); + expect(hue).not.toHaveAttribute('aria-hidden'); + }); + + 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. The active one carries no + // tabindex at all — a range input is natively focusable — so the picker + // keeps its place in document order instead of being forced to 0. + expect(saturation).not.toHaveAttribute('tabindex'); + expect(brightness).toHaveAttribute('tabindex', '-1'); + + // Switching direction moves the tab stop onto the adjusted axis. + fireEvent.keyDown(saturation, { key: 'ArrowDown' }); + expect(brightness).not.toHaveAttribute('tabindex'); + 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 expose default aria-labels if locale values are undefined', () => { + render( + , + ); + + const [saturation, brightness] = screen.getAllByLabelText('Color picker'); + expect(screen.getAllByLabelText('Color picker')).toHaveLength(2); + expect(screen.getByLabelText('Hue')).toBeTruthy(); + expect(screen.getByLabelText('Alpha')).toBeTruthy(); + expect(saturation).toHaveAttribute('aria-valuetext', 'Saturation: 91%'); + expect(brightness).toHaveAttribute('aria-valuetext', 'Brightness: 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 restart a new press from the value a rejecting parent committed', () => { + const onChange = vi.fn(); + render(); + + const saturation = getSaturation(); + // Each press is its own interaction (key up in between). A parent that + // never accepts the change leaves the committed value at 91%, so every + // press re-emits 92% rather than drifting 92% -> 93% -> 94% off a value + // the parent rejected — the way a native range snaps back. + for (let i = 0; i < 3; i += 1) { + fireEvent.keyDown(saturation, { key: 'ArrowRight' }); + fireEvent.keyUp(saturation, { key: 'ArrowRight' }); + } + + expect( + onChange.mock.calls.map(([color]) => Math.round(color.toHsb().s * 100)), + ).toEqual([92, 92, 92]); + }); + + it('Should advance across separate presses when the parent accepts them', () => { + render(); + + const saturation = getSaturation(); + // Same three separate interactions, but each one is committed, so they + // step 91% -> 92% -> 93% -> 94%. + for (let i = 0; i < 3; i += 1) { + fireEvent.keyDown(saturation, { key: 'ArrowRight' }); + fireEvent.keyUp(saturation, { key: 'ArrowRight' }); + } + + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsb(215, 94%, 100%)', + ); + }); + + it('Should complete an AT set-value action and leave nothing in flight', () => { + const onChange = vi.fn(); + const onChangeComplete = vi.fn(); + render( + , + ); + + const alpha = screen.getByLabelText('Alpha'); + // An AT set-value action fires `change` with no key press around it, so + // no key up will follow: it has to commit on its own. + fireEvent.change(alpha, { target: { value: '0' } }); + expect(onChangeComplete).toHaveBeenCalledTimes(1); + expect(onChangeComplete.mock.calls.at(-1)[0].a).toBe(0); + + onChange.mockClear(); + onChangeComplete.mockClear(); + // ...and it must leave nothing in flight: the next press is clamped at + // min, so it stays silent instead of re-committing the earlier change. + fireEvent.keyDown(alpha, { key: 'ArrowLeft' }); + fireEvent.keyUp(alpha, { key: 'ArrowLeft' }); + + expect(onChange).not.toHaveBeenCalled(); + expect(onChangeComplete).not.toHaveBeenCalled(); + }); + + it('Should complete a native Home key change once, on key up', () => { + const onChangeComplete = vi.fn(); + render( + , + ); + + const alpha = screen.getByLabelText('Alpha'); + // Home is left to the native range, so the value arrives as a change + // event mid-press. Key up commits it — once, not twice. + fireEvent.keyDown(alpha, { key: 'Home' }); + fireEvent.change(alpha, { target: { value: '0' } }); + fireEvent.keyUp(alpha, { key: 'Home' }); + + expect(onChangeComplete).toHaveBeenCalledTimes(1); + expect(onChangeComplete.mock.calls.at(-1)[0].a).toBe(0); + }); + + it('Should complete and reset the interaction when focus leaves mid-press', () => { + const onChange = vi.fn(); + const onChangeComplete = vi.fn(); + render( + , + ); + + const saturation = getSaturation(); + // Focus can leave before key up — a click elsewhere, or AT moving on. The + // interaction still has to be committed... + fireEvent.keyDown(saturation, { key: 'ArrowRight' }); + fireEvent.focusOut(saturation, { relatedTarget: document.body }); + expect(onChangeComplete).toHaveBeenCalledTimes(1); + + // ...and dropped, so the next press restarts from the 91% this rejecting + // parent committed rather than chaining off the abandoned 92%. + fireEvent.focus(saturation); + fireEvent.keyDown(saturation, { key: 'ArrowRight' }); + fireEvent.keyUp(saturation, { key: 'ArrowRight' }); + + expect( + onChange.mock.calls.map(([color]) => Math.round(color.toHsb().s * 100)), + ).toEqual([92, 92]); + expect( + onChangeComplete.mock.calls.map(([color]) => + Math.round(color.toHsb().s * 100), + ), + ).toEqual([92, 92]); + }); + + it('Should not complete on blur when nothing is in flight', () => { + const onChangeComplete = vi.fn(); + render( + , + ); + + const brightness = getBrightness(); + // Brightness is already at 100%, so this press moves nothing — leaving the + // control must not manufacture a completion. + fireEvent.keyDown(brightness, { key: 'ArrowUp' }); + fireEvent.focusOut(brightness, { relatedTarget: document.body }); + + // Nor should a plain focus/blur with no press at all. + fireEvent.focus(brightness); + fireEvent.focusOut(brightness, { relatedTarget: document.body }); + + expect(onChangeComplete).not.toHaveBeenCalled(); + }); + + it('Should complete once on blur when both axes are in flight', () => { + const onChangeComplete = vi.fn(); + render( + , + ); + + const saturation = getSaturation(); + const brightness = getBrightness(); + // Both axes adjusted inside one interaction (no key up between), then + // focus leaves. The two axes describe one color, so a single completion + // carries both changes — completing each would report the second off a + // color already handed back to the parent. + fireEvent.keyDown(brightness, { key: 'ArrowDown' }); + fireEvent.keyDown(saturation, { key: 'ArrowLeft' }); + fireEvent.focusOut(saturation, { relatedTarget: document.body }); + + expect(onChangeComplete).toHaveBeenCalledTimes(1); + expect(onChangeComplete.mock.calls.at(-1)[0].toHsbString()).toBe( + 'hsb(215, 90%, 99%)', + ); + }); + + it('Should complete both axes once when key up ends a 2-D interaction', () => { + const onChangeComplete = vi.fn(); + render( + , + ); + + const saturation = getSaturation(); + const brightness = getBrightness(); + // Both axes adjusted inside one interaction, then key up on the axis that + // has focus. It completes the pair... + fireEvent.keyDown(brightness, { key: 'ArrowDown' }); + fireEvent.keyDown(saturation, { key: 'ArrowLeft' }); + fireEvent.keyUp(saturation, { key: 'ArrowLeft' }); + + expect(onChangeComplete).toHaveBeenCalledTimes(1); + expect(onChangeComplete.mock.calls.at(-1)[0].toHsbString()).toBe( + 'hsb(215, 90%, 99%)', + ); + + // ...and leaves nothing behind, so blurring cannot complete the same + // interaction a second time off a color already handed back. + fireEvent.focusOut(saturation, { relatedTarget: document.body }); + expect(onChangeComplete).toHaveBeenCalledTimes(1); + }); + + it('Should complete a pending axis when the key up axis was clamped', () => { + const onChangeComplete = vi.fn(); + // Saturation pinned at 0%, so Left cannot step. + const pinned = new Color({ h: 215, s: 0, b: 1, a: 1 }); + render( + , + ); + + const saturation = getSaturation(); + const brightness = getBrightness(); + // Brightness moves, then a clamped Left press takes focus to the x axis. + // Key up fires there, on the axis that did *not* change — the brightness + // change still has to be committed rather than left for a later blur. + fireEvent.keyDown(brightness, { key: 'ArrowDown' }); + fireEvent.keyDown(saturation, { key: 'ArrowLeft' }); + fireEvent.keyUp(saturation, { key: 'ArrowLeft' }); + + expect(onChangeComplete).toHaveBeenCalledTimes(1); + expect(onChangeComplete.mock.calls.at(-1)[0].toHsbString()).toBe( + 'hsb(215, 0%, 99%)', + ); + + fireEvent.focusOut(saturation, { relatedTarget: document.body }); + expect(onChangeComplete).toHaveBeenCalledTimes(1); + }); + + it('Should snap an unaligned value onto the native step grid', () => { + const onChange = vi.fn(); + // 50.5% alpha sits off the `min + n * step` grid a native range allows. + const unaligned = new Color({ h: 215, s: 0.91, b: 1, a: 0.505 }); + render(); + + const alpha = screen.getByLabelText('Alpha'); + // Stepping up snaps to the next grid value (51%) rather than adding a + // whole step to the unaligned one (51.5%), matching stepUp(). + fireEvent.keyDown(alpha, { key: 'ArrowRight' }); + fireEvent.keyUp(alpha, { key: 'ArrowRight' }); + expect(onChange.mock.calls.at(-1)[1]).toEqual({ + type: 'alpha', + value: 51, + }); + + // ...and stepping down snaps to the previous one (50%), not 49.5%. + fireEvent.keyDown(alpha, { key: 'ArrowLeft' }); + expect(onChange.mock.calls.at(-1)[1]).toEqual({ + type: 'alpha', + value: 50, + }); + }); + + 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', () => { + const onChangeComplete = vi.fn(); + render(); + + const alpha = screen.getByLabelText('Alpha'); + fireEvent.change(alpha, { target: { value: '50' } }); + fireEvent.keyUp(alpha, { key: 'ArrowRight' }); + + expect(document.querySelector('.pick-color').innerHTML).toBe( + 'hsba(215, 91%, 100%, 0.50)', + ); + expect(onChangeComplete).toHaveBeenCalled(); + }); + + 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%)', + ); + }); + }); });