Skip to content

feat: Improve keyboard & screen-reader accessibility#286

Open
Pareder wants to merge 1 commit into
react-component:masterfrom
Pareder:feat/improve-accessibility
Open

feat: Improve keyboard & screen-reader accessibility#286
Pareder wants to merge 1 commit into
react-component:masterfrom
Pareder:feat/improve-accessibility

Conversation

@Pareder

@Pareder Pareder commented Jun 5, 2026

Copy link
Copy Markdown

Summary

Makes the color picker fully operable by keyboard and meaningful to assistive technology. The saturation/brightness panel and the hue/alpha sliders previously responded to mouse/touch only; they now expose native slider semantics, full arrow-key control, focus indication, and localizable accessible names.

Motivation

The handles were non-focusable <div>s with no role or value, so keyboard and screen-reader users could not read or change the color. This addresses WCAG 2.1.1 (Keyboard), 2.4.7 (Focus Visible), and 4.1.2 (Name, Role, Value).

What changed

  1. Handles are now real sliders — each handle renders a visually-hidden native <input type="range"> (Handler.tsx). This gives us, for free, the slider role, aria-valuemin/max/now, and native keyboard handling (arrows, Home/End, PageUp/PageDown).
    • Hue / Alpha sliders (1-D): driven entirely by the native input.
    • Saturation/Brightness panel (2-D): one input drives the horizontal axis (saturation) natively; Up/Down are handled in a small onKeyDown to drive the vertical axis (brightness), since a native range is 1-D. Result: one focusable thumb where ←/→ = saturation and ↑/↓ = brightness.
  2. Hidden input ships hidden — the proxy input is hidden via inline styles (opacity: 0; pointer-events: none; position: absolute), not the stylesheet, so consumers who don't import assets/index.less (e.g. antd's own styling) get correct rendering with no setup. opacity (not display:none) keeps it focusable and in the a11y tree.
  3. Focus indication — added a :focus-visible / :focus-within outline on -handler so keyboard focus is visible on the thumb.
  4. New locale prop — all accessible names are localizable via a single locale prop on <ColorPicker>:
    locale?: {
      picker?: string;            // default 'Color picker'
      pickerDescription?: string; // default '2D slider' (aria-roledescription)
      hue?: string;               // default 'Hue'
      alpha?: string;             // default 'Alpha'
      saturation?: string;        // used in the panel's aria-valuetext
      brightness?: string;        // used in the panel's aria-valuetext
    };
    
  5. ColorBlock accepts standard div attributesColorBlockProps now extends React.HTMLAttributes<HTMLDivElement> and spreads them onto the root. This lets consumers attach interaction/ARIA props (role, tabIndex, aria-*, keyboard handlers) when using it as a clickable trigger, keeping the component itself presentational.

API additions (non-breaking)

  • ColorPicker / BaseColorPickerProps: new optional locale.
  • Slider (BaseSliderProps): new optional aria-label.
  • ColorBlock: props widened to HTMLAttributes<HTMLDivElement> (superset of the previous className/style/onClick).
  • New DOM element per handle: <input class="{prefix}-handler-range">.

Related issues

Fixes ant-design/ant-design#57935.

Summary by CodeRabbit

发布说明

  • 新功能

    • 添加多语言支持,允许自定义颜色选择器的文案显示
    • 增强键盘交互,支持使用方向键调整颜色属性
    • 改进无障碍访问性,添加屏幕阅读器支持和ARIA标签
    • 改进聚焦状态的视觉反馈,使键盘导航更清晰
  • 测试

    • 新增无障碍访问性相关测试用例

@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

ColorPicker 获得完整的键盘导航与无障碍支持,通过在 Handler 中引入隐藏的 range 输入来启用 2D 轴控制,新增基于 locale 的 aria 标签,实现键盘焦点样式,并通过全面的无障碍测试验证。

Changes

Keyboard Navigation & Accessibility

Layer / File(s) Summary
Type definitions and accessibility contracts
src/interface.ts, src/components/Handler.tsx
New locale property in BaseColorPickerProps defines accessibility strings. HandlerAxis and HandlerProps types introduce 2D axis abstraction with value, min/max/step, and onChange/onChangeComplete callbacks.
Handler 2D keyboard navigation
src/components/Handler.tsx
Handler transforms from styled div to hidden range input with keyboard event handlers. Supports horizontal (x) and vertical (y) axis control via ArrowLeft/Right/Up/Down keys, with step-based value changes and clamping.
Picker Handler integration with HSB channels
src/components/Picker.tsx
Imports generateColor utility. Introduces changeColor helper to update individual HSB channels (saturation/brightness) on keyboard input. Configures Handler x/y axes with aria attributes, value ranges, and color-specific onChange/onChangeComplete callbacks.
Locale and aria-label propagation
src/ColorPicker.tsx, src/components/Slider.tsx
ColorPicker defines defaultLocale with accessibility text and merges it with user locale via useMemo. Propagates merged locale and aria-labels through Picker and Slider components to Handler for keyboard and screen-reader annotations on hue, alpha, saturation, and brightness controls.
Focus styles and HTML attribute passthrough
assets/index.less, src/components/ColorBlock.tsx
CSS adds :focus-visible and :focus-within outline styles to Handler for keyboard focus feedback. ColorBlock props type expanded to inherit React.HTMLAttributes<HTMLDivElement> for generic event and attribute forwarding.
Accessibility test coverage
tests/index.test.tsx
Updates test library imports and handler rendering assertions. Adds comprehensive accessibility test suite verifying aria-labels, aria-valuetext, keyboard navigation of picker handle and sliders, locale customization, and color value updates from keyboard input.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • react-component/color-picker#273: 修改 src/components/ColorBlock.tsx 的 props 类型和内部属性处理逻辑,两者都涉及 ColorBlockProps 的结构化调整。

Poem

🐰 键盘舞动,焦点闪耀,
隐藏的 range 精准导航,
色彩随心跳,a11y 护航,
无障碍之梦,现已启航! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题准确概括了此次变更的核心内容——改进键盘和屏幕阅读器的无障碍支持。
Linked Issues check ✅ Passed 变更通过新增 Handler 的隐藏 range input、aria-label 本地化、焦点可见样式、ColorBlock props 扩展等方式,全面满足 #57935 中的四项无障碍要求。
Out of Scope Changes check ✅ Passed 所有代码变更均与改进无障碍支持相关,包括键盘交互、屏幕阅读器支持和焦点管理,未发现超出 #57935 范围的修改。
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/ColorPicker.tsx

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

src/components/ColorBlock.tsx

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

src/components/Handler.tsx

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.

  • 4 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request enhances the accessibility of the ColorPicker component by introducing keyboard navigation (2D slider handler) for saturation and brightness, adding customizable locale support for screen reader labels, and updating components like Handler, Picker, and Slider to support range inputs and ARIA attributes. Feedback was provided to address potential race conditions during rapid keyboard interactions in controlled mode by using mutable refs to track the latest color and vertical axis values.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/components/Picker.tsx
Comment on lines +47 to +55
// ===================== 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);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In a controlled component, rapid keyboard interactions (like pressing or holding arrow keys) can trigger multiple state updates before the parent component re-renders and passes down the updated color prop. Because changeColor derives the next color from the potentially stale color prop, rapid changes to one channel (e.g., saturation) can revert changes made to the other channel (e.g., brightness) in rapid succession.

To prevent this race condition, we should keep colorRef.current in sync with the color prop during render, and derive the next color from colorRef.current instead of the color prop.

Suggested change
// ===================== 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);
};
// ===================== Keyboard (2-D handler) =====================
colorRef.current = color;
// Build a new color from the current one with a single HSB channel changed.
const changeColor = (channel: 's' | 'b', percent: number) => {
const currentHsb = colorRef.current.toHsb();
const next = generateColor({ ...currentHsb, [channel]: percent / 100 });
colorRef.current = next;
onChange(next);
};

Comment on lines +56 to 107
const Handler: React.FC<HandlerProps> = ({
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<HTMLInputElement>) => {
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 (
<div
className={clsx(`${prefixCls}-handler`, {
[`${prefixCls}-handler-sm`]: size === 'small',
})}
style={{ backgroundColor: color }}
/>
style={{ position: 'relative', backgroundColor: color }}
>
<input
step={1}
{...omit(x, ['onChangeComplete'])}
type="range"
className={`${prefixCls}-handler-range`}
style={RANGE_INPUT_STYLE}
disabled={disabled}
onChange={event => 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));
}
}}
/>
</div>
);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since the vertical axis (y) is not a native range input, its value changes are handled entirely in JavaScript via onKeyDown. In controlled mode, rapid key presses or holding down the arrow keys can cause handleKeyDown and onKeyUp to read stale y.value props before the parent component has committed the state update and re-rendered.

To ensure smooth and correct keyboard navigation for the vertical axis, we should track the latest value in a mutable ref (lastValueRef) and update it synchronously on each key down/up event.

const Handler: React.FC<HandlerProps> = ({
  size = 'default',
  color,
  prefixCls,
  disabled,
  x,
  y,
}) => {
  const lastValueRef = React.useRef(y?.value);
  if (y && y.value !== lastValueRef.current) {
    lastValueRef.current = y.value;
  }

  // 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<HTMLInputElement>) => {
    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;
    const nextValue = Math.min(max, Math.max(min, (lastValueRef.current ?? y.value) + delta));
    lastValueRef.current = nextValue;
    y.onChange(nextValue);
  };

  return (
    <div
      className={clsx(prefixCls + "-handler", {
        [prefixCls + "-handler-sm"]: size === "small",
      })}
      style={{ position: 'relative', backgroundColor: color }}
    >
      <input
        step={1}
        {...omit(x, ['onChangeComplete'])}
        type="range"
        className={prefixCls + "-handler-range"}
        style={RANGE_INPUT_STYLE}
        disabled={disabled}
        onChange={event => 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(lastValueRef.current ?? y.value);
          } else {
            x.onChangeComplete(Number(event.currentTarget.value));
          }
        }}
      />
    </div>
  );
};

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/Picker.tsx (1)

20-75: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

请为 locale 增加本地兜底,避免可选值被直接解引用导致崩溃

Line 20 的 locale 是可选,但 Line 71-75 直接读取 locale.picker / locale.saturation 等;一旦该层收到 undefined,会触发运行时异常并中断交互。

建议修复(示例)
 const Picker: FC<PickerProps> = ({
   color,
   onChange,
   prefixCls,
   onChangeComplete,
   disabled,
   locale,
 }) => {
+  const pickerLabel = locale?.picker ?? 'Color picker';
+  const pickerDescription = locale?.pickerDescription;
+  const saturationLabel = locale?.saturation ?? 'Saturation';
+  const brightnessLabel = locale?.brightness ?? 'Brightness';
+
   const pickerRef = useRef();
   const transformRef = useRef();
   const colorRef = useRef(color);
@@
             x={{
-              'aria-label': locale.picker,
-              'aria-roledescription': locale.pickerDescription,
-              'aria-valuetext': `${locale.saturation} ${Math.round(
+              'aria-label': pickerLabel,
+              'aria-roledescription': pickerDescription,
+              'aria-valuetext': `${saturationLabel} ${Math.round(
                 hsb.s * 100,
-              )}%, ${locale.brightness} ${Math.round(hsb.b * 100)}%`,
+              )}%, ${brightnessLabel} ${Math.round(hsb.b * 100)}%`,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Picker.tsx` around lines 20 - 75, The Picker component reads
optional prop locale directly (used in Handler aria attributes like
locale.picker, locale.saturation, locale.brightness) which can be undefined and
cause runtime crashes; fix by providing a safe default fallback for locale at
the top of the component (e.g., const safeLocale = locale ?? DEFAULT_LOCALE) or
via destructuring with defaults, then replace direct uses of locale.* with
safeLocale.* (ensure DEFAULT_LOCALE is defined or imported) so aria labels
always have values.
🧹 Nitpick comments (2)
tests/index.test.tsx (1)

633-642: ⚡ Quick win

建议添加 onChangeComplete 回调验证以保持测试一致性。

前面的键盘导航测试(色调、饱和度、亮度)都会验证 onChangeComplete 是否被调用,但此测试仅检查透明度值的变化,缺少对 onChangeComplete 的验证。

为了保持测试套件的一致性并确保透明度滑块的 onChangeComplete 行为得到覆盖,建议添加相应的断言。

🧪 建议的测试增强
 it('Should change alpha when the alpha slider value changes via keyboard', () => {
-  render(<Controlled />);
+  const onChangeComplete = vi.fn();
+  render(<Controlled onChangeComplete={onChangeComplete} />);

   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();
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/index.test.tsx` around lines 633 - 642, The alpha-slider test ("Should
change alpha when the alpha slider value changes via keyboard") currently only
asserts the DOM color change; add a mock for onChangeComplete and pass it into
the Controlled test component (Controlled) so you can assert it was called when
the alpha input is changed; specifically, create a jest.fn() for
onChangeComplete, render <Controlled onChangeComplete={onChangeCompleteMock} />,
fireEvent.change on the element grabbed by screen.getByLabelText('Alpha'), then
assert onChangeCompleteMock was called (and optionally called with the expected
alpha/hsba payload) in addition to the existing
document.querySelector('.pick-color').innerHTML check.
assets/index.less (1)

58-62: 💤 Low value

建议验证焦点轮廓的对比度。

当前焦点样式使用 outline: 2px solid #1677ff。虽然 `outline-offset: 1px` 和处理器的白色边框(`border: 2px solid `#fff)为轮廓提供了一定的背景对比,但在某些极浅或白色的色调下,蓝色轮廓的可见性可能仍然不足。

考虑添加一个双层轮廓(例如使用 box-shadow 添加外层深色描边)以确保在所有颜色背景下都有足够的对比度。

♻️ 建议的增强方案
 &:focus-visible,
 &:focus-within {
   outline: 2px solid `#1677ff`;
   outline-offset: 1px;
+  box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.8);
 }

这会在蓝色轮廓外添加一个半透明白色光晕,确保在深色和浅色背景下都有良好的可见性。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@assets/index.less` around lines 58 - 62, The focus outline (selectors
&:focus-visible and &:focus-within) uses only outline: 2px solid `#1677ff` which
may lack contrast on some backgrounds; update these selectors to add a
double-layer visual (keep the existing blue outline but add an outer ring via
box-shadow or an extra outline) so the focus is always visible—for example, add
a box-shadow that renders a thin semi-opaque white or dark halo outside the blue
outline (e.g., an outer 2–3px ring using rgba(...) for light/dark backgrounds)
and keep outline-offset as needed; modify the &:focus-visible and &:focus-within
block to include this additional visual ring while preserving the existing
outline and offset.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/components/Picker.tsx`:
- Around line 20-75: The Picker component reads optional prop locale directly
(used in Handler aria attributes like locale.picker, locale.saturation,
locale.brightness) which can be undefined and cause runtime crashes; fix by
providing a safe default fallback for locale at the top of the component (e.g.,
const safeLocale = locale ?? DEFAULT_LOCALE) or via destructuring with defaults,
then replace direct uses of locale.* with safeLocale.* (ensure DEFAULT_LOCALE is
defined or imported) so aria labels always have values.

---

Nitpick comments:
In `@assets/index.less`:
- Around line 58-62: The focus outline (selectors &:focus-visible and
&:focus-within) uses only outline: 2px solid `#1677ff` which may lack contrast on
some backgrounds; update these selectors to add a double-layer visual (keep the
existing blue outline but add an outer ring via box-shadow or an extra outline)
so the focus is always visible—for example, add a box-shadow that renders a thin
semi-opaque white or dark halo outside the blue outline (e.g., an outer 2–3px
ring using rgba(...) for light/dark backgrounds) and keep outline-offset as
needed; modify the &:focus-visible and &:focus-within block to include this
additional visual ring while preserving the existing outline and offset.

In `@tests/index.test.tsx`:
- Around line 633-642: The alpha-slider test ("Should change alpha when the
alpha slider value changes via keyboard") currently only asserts the DOM color
change; add a mock for onChangeComplete and pass it into the Controlled test
component (Controlled) so you can assert it was called when the alpha input is
changed; specifically, create a jest.fn() for onChangeComplete, render
<Controlled onChangeComplete={onChangeCompleteMock} />, fireEvent.change on the
element grabbed by screen.getByLabelText('Alpha'), then assert
onChangeCompleteMock was called (and optionally called with the expected
alpha/hsba payload) in addition to the existing
document.querySelector('.pick-color').innerHTML check.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8732dbee-7a87-4d9f-9eb1-9ef62c29c1b9

📥 Commits

Reviewing files that changed from the base of the PR and between 77e1e7e and 0a1b080.

⛔ Files ignored due to path filters (1)
  • tests/__snapshots__/index.test.tsx.snap is excluded by !**/*.snap
📒 Files selected for processing (8)
  • assets/index.less
  • src/ColorPicker.tsx
  • src/components/ColorBlock.tsx
  • src/components/Handler.tsx
  • src/components/Picker.tsx
  • src/components/Slider.tsx
  • src/interface.ts
  • tests/index.test.tsx

@Pareder

Pareder commented Jun 8, 2026

Copy link
Copy Markdown
Author

@afc163 @zombieJ @meet-student @QDyanbing Could you please review?

1 similar comment
@Pareder

Pareder commented Jun 12, 2026

Copy link
Copy Markdown
Author

@afc163 @zombieJ @meet-student @QDyanbing Could you please review?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ColorPicker: Improve accessibility

1 participant