Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions assets/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions src/ColorPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<BaseColorPickerProps>['locale'] = {
picker: 'Color picker',
pickerDescription: '2D slider',
hue: 'Hue',
alpha: 'Alpha',
saturation: 'Saturation',
brightness: 'Brightness',
};

const HUE_COLORS = [
{
color: 'rgb(255, 0, 0)',
Expand Down Expand Up @@ -67,8 +76,14 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
disabledAlpha = false,
disabled = false,
components,
locale,
} = props;

const mergedLocale = useMemo(
() => ({ ...defaultLocale, ...locale }),
[locale],
);

// ========================== Components ==========================
const [Slider] = useComponent(components);

Expand Down Expand Up @@ -134,6 +149,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
<Picker
onChange={handleChange}
{...sharedSliderProps}
locale={mergedLocale}
onChangeComplete={onChangeComplete}
/>
<div className={`${prefixCls}-slider-container`}>
Expand All @@ -151,6 +167,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
value={colorValue.getHue()}
onChange={onHueChange}
onChangeComplete={onHueChangeComplete}
aria-label={mergedLocale.hue}
/>
{!disabledAlpha && (
<Slider
Expand All @@ -165,6 +182,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
value={colorValue.a * 100}
onChange={onAlphaChange}
onChangeComplete={onAlphaChangeComplete}
aria-label={mergedLocale.alpha}
/>
)}
</div>
Expand Down
14 changes: 3 additions & 11 deletions src/components/ColorBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,26 @@
import { clsx } from 'clsx';
import React from 'react';

export type ColorBlockProps = {
export type ColorBlockProps = React.HTMLAttributes<HTMLDivElement> & {
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<HTMLDivElement>;
};

const ColorBlock: React.FC<ColorBlockProps> = ({
color,
prefixCls,
className,
style,
innerClassName,
innerStyle,
onClick,
...props
}) => {
const colorBlockCls = `${prefixCls}-color-block`;
return (
<div
className={clsx(colorBlockCls, className)}
style={style}
onClick={onClick}
>
<div {...props} className={clsx(colorBlockCls, className)}>
<div
className={clsx(`${colorBlockCls}-inner`, innerClassName)}
style={{ background: color, ...innerStyle }}
Expand Down
216 changes: 212 additions & 4 deletions src/components/Handler.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,228 @@
import { omit } from '@rc-component/util';
import { clsx } from 'clsx';
import React from 'react';

type HandlerSize = 'default' | 'small';

const Handler: React.FC<{
/** Keyboard keys (handled natively by `<input type="range">`) 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 <div>. 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<HTMLInputElement>,
'size' | 'value' | 'onChange'
> {
value: number;
onChange: (value: number) => void;
onChangeComplete: (value: number) => void;
}

export interface HandlerProps {
size?: HandlerSize;
color?: string;
prefixCls?: string;
}> = ({ size = 'default', color, prefixCls }) => {
disabled?: boolean;
x: HandlerAxis;
y?: HandlerAxis;
}

type ValueRef = React.RefObject<number>;
type ChangedRef = React.RefObject<boolean>;

const Handler: React.FC<HandlerProps> = ({
size = 'default',
color,
prefixCls,
disabled,
x,
y,
}) => {
const is2D = !!y;

// Per-axis interaction state. Each input owns the value it is adjusting so a
// controlled parent re-rendering with a stale value mid-interaction cannot
// reset it. The `y` refs are inert for 1-D sliders.
const xValueRef = React.useRef(x.value);
const xPrevRef = React.useRef(x.value);
const xChangedRef = React.useRef(false);
const xInputRef = React.useRef<HTMLInputElement>(null);

const yValueRef = React.useRef(y?.value ?? 0);
const yPrevRef = React.useRef(y?.value ?? 0);
const yChangedRef = React.useRef(false);
const yInputRef = React.useRef<HTMLInputElement>(null);

// Roving tab index: the 2-D picker is a single tab stop whose focus moves
// between the two axis inputs as the user switches direction, so it reads as
// one control while each axis stays independently operable by AT.
const [activeAxis, setActiveAxis] = React.useState<'x' | 'y'>('x');

// Preserve the in-progress value across re-renders; only resync when the
// controlled prop *genuinely* changes, so a stale echo of the pre-interaction
// value cannot clobber what the user is currently adjusting.
if (x.value !== xPrevRef.current) {
xPrevRef.current = x.value;
xValueRef.current = x.value;
}
if (y && y.value !== yPrevRef.current) {
yPrevRef.current = y.value;
yValueRef.current = y.value;
}

const stepAxis = (
axis: HandlerAxis,
valueRef: ValueRef,
changedRef: ChangedRef,
direction: 1 | -1,
) => {
const stepSize = Number(axis.step ?? 1) || 1;
const min = Number(axis.min ?? 0);
const max = Number(axis.max ?? 100);
const current = valueRef.current;
const next = Math.min(max, Math.max(min, current + direction * stepSize));
// Clamped against a bound — nothing changed, so stay silent like a native
// range instead of emitting a redundant onChange.
if (next === current) {
return;
}
valueRef.current = next;
changedRef.current = true;
axis.onChange(next);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This calls onChange even when clamping leaves the value unchanged. For example, ArrowUp at 100% brightness still invokes onChange, and keyup then invokes onChangeComplete, although no value changed. That differs from native range behavior and can trigger unnecessary controlled updates or analytics. Please return early when next === current and only emit completion for an interaction that actually changed a value.

};

// Move DOM focus (and the roving tab stop) onto the axis being adjusted so
// the screen reader tracks and announces the value that actually changed.
const focusAxis = (axis: 'x' | 'y') => {
setActiveAxis(axis);
const input = axis === 'y' ? yInputRef.current : xInputRef.current;
if (input && document.activeElement !== input) {
input.focus();
}
};

// Left/Right always drives the x axis, Up/Down the y axis (or the single x
// axis on a 1-D slider). preventDefault stops the browser from also moving
// the focused input's native value.
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case 'ArrowRight':
focusAxis('x');
stepAxis(x, xValueRef, xChangedRef, 1);
break;
case 'ArrowLeft':
focusAxis('x');
stepAxis(x, xValueRef, xChangedRef, -1);
break;
case 'ArrowUp':
if (y) {
focusAxis('y');
stepAxis(y, yValueRef, yChangedRef, 1);
} else {
focusAxis('x');
stepAxis(x, xValueRef, xChangedRef, 1);
}
break;
case 'ArrowDown':
if (y) {
focusAxis('y');
stepAxis(y, yValueRef, yChangedRef, -1);
} else {
focusAxis('x');
stepAxis(x, xValueRef, xChangedRef, -1);
}
break;
default:
return;
}
event.preventDefault();
};

// Each input completes its own axis. Because focus follows the adjusted axis,
// key up fires on the input whose value just changed.
const completeAxis =
(axis: HandlerAxis, valueRef: ValueRef, changedRef: ChangedRef) =>
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (!VALUE_KEYS.includes(event.key) || !changedRef.current) {
return;
}
changedRef.current = false;
axis.onChangeComplete(valueRef.current);
};

// Native value changes (Home/End/PageUp/PageDown and AT set-value / increment
// actions) feed the same interaction value for the input's own axis.
const changeAxis =
(axis: HandlerAxis, valueRef: ValueRef, changedRef: ChangedRef) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
const next = Number(event.target.value);
valueRef.current = next;
changedRef.current = true;
axis.onChange(next);
};

return (
<div
className={clsx(`${prefixCls}-handler`, {
[`${prefixCls}-handler-sm`]: size === 'small',
})}
style={{ backgroundColor: color }}
/>
style={{ position: 'relative', backgroundColor: color }}
>
<input

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Using a single native range exposes only the x/saturation value to the accessibility tree. The y/brightness axis exists only in the JavaScript keydown branch, so assistive technologies that invoke native slider increment/decrement or set-value actions (for example VoiceOver and TalkBack) have no second adjustable value; aria-valuetext only describes it. This leaves brightness inoperable for non-keyboard AT. Please expose saturation and brightness as separately operable range semantics, such as two visually hidden range inputs controlling the same visible thumb and grouped/labeled as the 2D picker, and add coverage for operating each axis through its native control.

ref={xInputRef}
step={1}
{...omit(x, ['onChange', 'onChangeComplete'])}
type="range"
tabIndex={is2D ? (activeAxis === 'x' ? 0 : -1) : undefined}
className={`${prefixCls}-handler-range`}
style={RANGE_INPUT_STYLE}
disabled={disabled}
onChange={changeAxis(x, xValueRef, xChangedRef)}
onKeyDown={handleKeyDown}
onKeyUp={completeAxis(x, xValueRef, xChangedRef)}
onFocus={is2D ? () => setActiveAxis('x') : undefined}
/>
{y && (
<input
ref={yInputRef}
step={1}
{...omit(y, ['onChange', 'onChangeComplete'])}
type="range"
aria-orientation="vertical"
tabIndex={activeAxis === 'y' ? 0 : -1}
className={`${prefixCls}-handler-range`}
style={RANGE_INPUT_STYLE}
disabled={disabled}
onChange={changeAxis(y, yValueRef, yChangedRef)}
onKeyDown={handleKeyDown}
onKeyUp={completeAxis(y, yValueRef, yChangedRef)}
onFocus={() => setActiveAxis('y')}
/>
)}
</div>
);
};
Comment on lines +56 to 227

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>
  );
};


Expand Down
Loading