fix: color picker controlled value (#47816)

* fix: fix control value not show in dom

* chore: update title

* add test case

* change test title

* fix: cleared color should change after controlled value changed

* chore: code clean

* test: change test

* comment

* fix: should respect empty string

* test: add test case

* chore: update demo

---------

Co-authored-by: tanghui <yoyo837@hotmail.com>
Co-authored-by: afc163 <afc163@gmail.com>
Co-authored-by: lijianan <574980606@qq.com>
This commit is contained in:
MadCcc 2024-03-25 20:10:47 +08:00 committed by GitHub
parent 6310bf2840
commit 1cb644d476
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 200 additions and 92 deletions

View File

@ -13,7 +13,7 @@ const getAllowClear = (allowClear: AllowClear): AllowClear => {
clearIcon: <CloseCircleFilled />, clearIcon: <CloseCircleFilled />,
}; };
} }
return mergedAllowClear; return mergedAllowClear;
}; };

View File

@ -1,9 +1,5 @@
import type { CSSProperties, FC } from 'react'; import React, { useContext, useMemo, useRef } from 'react';
import React, { useContext, useMemo, useRef, useState } from 'react'; import type { HsbaColorType } from '@rc-component/color-picker';
import type {
HsbaColorType,
ColorPickerProps as RcColorPickerProps,
} from '@rc-component/color-picker';
import classNames from 'classnames'; import classNames from 'classnames';
import useMergedState from 'rc-util/lib/hooks/useMergedState'; import useMergedState from 'rc-util/lib/hooks/useMergedState';
@ -15,7 +11,6 @@ import { ConfigContext } from '../config-provider/context';
import DisabledContext from '../config-provider/DisabledContext'; import DisabledContext from '../config-provider/DisabledContext';
import useCSSVarCls from '../config-provider/hooks/useCSSVarCls'; import useCSSVarCls from '../config-provider/hooks/useCSSVarCls';
import useSize from '../config-provider/hooks/useSize'; import useSize from '../config-provider/hooks/useSize';
import type { SizeType } from '../config-provider/SizeContext';
import { FormItemInputContext, NoFormStyle } from '../form/context'; import { FormItemInputContext, NoFormStyle } from '../form/context';
import type { PopoverProps } from '../popover'; import type { PopoverProps } from '../popover';
import Popover from '../popover'; import Popover from '../popover';
@ -23,50 +18,10 @@ import type { Color } from './color';
import ColorPickerPanel from './ColorPickerPanel'; import ColorPickerPanel from './ColorPickerPanel';
import ColorTrigger from './components/ColorTrigger'; import ColorTrigger from './components/ColorTrigger';
import useColorState from './hooks/useColorState'; import useColorState from './hooks/useColorState';
import type { import type { ColorPickerBaseProps, ColorPickerProps, TriggerPlacement } from './interface';
ColorFormat,
ColorPickerBaseProps,
ColorValueType,
PresetsItem,
TriggerPlacement,
TriggerType,
} from './interface';
import useStyle from './style'; import useStyle from './style';
import { genAlphaColor, generateColor, getAlphaColor } from './util'; import { genAlphaColor, generateColor, getAlphaColor } from './util';
export type ColorPickerProps = Omit<
RcColorPickerProps,
'onChange' | 'value' | 'defaultValue' | 'panelRender' | 'disabledAlpha' | 'onChangeComplete'
> & {
value?: ColorValueType;
defaultValue?: ColorValueType;
children?: React.ReactNode;
open?: boolean;
disabled?: boolean;
placement?: TriggerPlacement;
trigger?: TriggerType;
format?: keyof typeof ColorFormat;
defaultFormat?: keyof typeof ColorFormat;
allowClear?: boolean;
presets?: PresetsItem[];
arrow?: boolean | { pointAtCenter: boolean };
panelRender?: (
panel: React.ReactNode,
extra: { components: { Picker: FC; Presets: FC } },
) => React.ReactNode;
showText?: boolean | ((color: Color) => React.ReactNode);
size?: SizeType;
styles?: { popup?: CSSProperties; popupOverlayInner?: CSSProperties };
rootClassName?: string;
disabledAlpha?: boolean;
[key: `data-${string}`]: string;
onOpenChange?: (open: boolean) => void;
onFormatChange?: (format: ColorFormat) => void;
onChange?: (value: Color, hex: string) => void;
onClear?: () => void;
onChangeComplete?: (value: Color) => void;
} & Pick<PopoverProps, 'getPopupContainer' | 'autoAdjustOverflow' | 'destroyTooltipOnHide'>;
type CompoundedComponent = React.FC<ColorPickerProps> & { type CompoundedComponent = React.FC<ColorPickerProps> & {
_InternalPanelDoNotUseOrYouWillBeFired: typeof PurePanel; _InternalPanelDoNotUseOrYouWillBeFired: typeof PurePanel;
}; };
@ -109,7 +64,7 @@ const ColorPicker: CompoundedComponent = (props) => {
const contextDisabled = useContext(DisabledContext); const contextDisabled = useContext(DisabledContext);
const mergedDisabled = disabled ?? contextDisabled; const mergedDisabled = disabled ?? contextDisabled;
const [colorValue, setColorValue] = useColorState('', { const [colorValue, setColorValue, prevValue] = useColorState('', {
value, value,
defaultValue, defaultValue,
}); });
@ -124,8 +79,6 @@ const ColorPicker: CompoundedComponent = (props) => {
onChange: onFormatChange, onChange: onFormatChange,
}); });
const [colorCleared, setColorCleared] = useState(!value && !defaultValue);
const prefixCls = getPrefixCls('color-picker', customizePrefixCls); const prefixCls = getPrefixCls('color-picker', customizePrefixCls);
const isAlphaColor = useMemo(() => getAlphaColor(colorValue) < 100, [colorValue]); const isAlphaColor = useMemo(() => getAlphaColor(colorValue) < 100, [colorValue]);
@ -167,14 +120,16 @@ const ColorPicker: CompoundedComponent = (props) => {
const handleChange = (data: Color, type?: HsbaColorType, pickColor?: boolean) => { const handleChange = (data: Color, type?: HsbaColorType, pickColor?: boolean) => {
let color: Color = generateColor(data); let color: Color = generateColor(data);
// If color is cleared, reset alpha to 100
const isNull = value === null || (!value && defaultValue === null); const isNull = value === null || (!value && defaultValue === null);
if (colorCleared || isNull) { if (prevValue.current?.cleared || isNull) {
setColorCleared(false);
// ignore alpha slider // ignore alpha slider
if (getAlphaColor(colorValue) === 0 && type !== 'alpha') { if (getAlphaColor(colorValue) === 0 && type !== 'alpha') {
color = genAlphaColor(color); color = genAlphaColor(color);
} }
} }
// ignore alpha color // ignore alpha color
if (disabledAlpha && isAlphaColor) { if (disabledAlpha && isAlphaColor) {
color = genAlphaColor(color); color = genAlphaColor(color);
@ -192,7 +147,6 @@ const ColorPicker: CompoundedComponent = (props) => {
}; };
const handleClear = () => { const handleClear = () => {
setColorCleared(true);
onClear?.(); onClear?.();
}; };
@ -221,7 +175,6 @@ const ColorPicker: CompoundedComponent = (props) => {
prefixCls, prefixCls,
color: colorValue, color: colorValue,
allowClear, allowClear,
colorCleared,
disabled: mergedDisabled, disabled: mergedDisabled,
disabledAlpha, disabledAlpha,
presets, presets,
@ -262,13 +215,12 @@ const ColorPicker: CompoundedComponent = (props) => {
open={popupOpen} open={popupOpen}
className={mergedCls} className={mergedCls}
style={mergedStyle} style={mergedStyle}
color={value ? generateColor(value) : colorValue}
prefixCls={prefixCls} prefixCls={prefixCls}
disabled={mergedDisabled} disabled={mergedDisabled}
colorCleared={colorCleared}
showText={showText} showText={showText}
format={formatValue} format={formatValue}
{...rest} {...rest}
color={colorValue}
/> />
)} )}
</Popover>, </Popover>,

View File

@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { createEvent, fireEvent, render } from '@testing-library/react'; import { createEvent, fireEvent, render } from '@testing-library/react';
import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; import { spyElementPrototypes } from 'rc-util/lib/test/domHook';
@ -11,8 +11,9 @@ import ConfigProvider from '../../config-provider';
import Form from '../../form'; import Form from '../../form';
import theme from '../../theme'; import theme from '../../theme';
import type { Color } from '../color'; import type { Color } from '../color';
import type { ColorPickerProps } from '../ColorPicker';
import ColorPicker from '../ColorPicker'; import ColorPicker from '../ColorPicker';
import type { ColorPickerProps, ColorValueType } from '../interface';
import { generateColor } from '../util';
function doMouseMove( function doMouseMove(
container: HTMLElement, container: HTMLElement,
@ -607,4 +608,94 @@ describe('ColorPicker', () => {
const { container } = render(<ColorPicker />); const { container } = render(<ColorPicker />);
expect(container.querySelector('.ant-color-picker-clear')).toBeTruthy(); expect(container.querySelector('.ant-color-picker-clear')).toBeTruthy();
}); });
['', null].forEach((value) => {
it(`When controlled and without an initial value, then changing the controlled value to valid color should be reflected correctly on the DOM. [${String(
value,
)}]`, async () => {
const Demo = () => {
const [color, setColor] = useState<ColorValueType>(value);
useEffect(() => {
setColor(generateColor('red'));
}, []);
return <ColorPicker value={color} />;
};
const { container } = render(<Demo />);
await waitFakeTimer();
expect(container.querySelector('.ant-color-picker-color-block-inner')).toHaveStyle({
background: 'rgb(255, 0, 0)',
});
});
it(`When controlled and has an initial value, then changing the controlled value to cleared color should be reflected correctly on the DOM. [${String(
value,
)}]`, async () => {
const Demo = () => {
const [color, setColor] = useState<ColorValueType>(generateColor('red'));
useEffect(() => {
setColor(value);
}, []);
return <ColorPicker value={color} />;
};
const { container } = render(<Demo />);
await waitFakeTimer();
expect(container.querySelector('.ant-color-picker-clear')).toBeTruthy();
});
});
it('Controlled string value should work with allowClear correctly', async () => {
const Demo = (props: any) => {
const [color, setColor] = useState<ColorValueType>(generateColor('red'));
useEffect(() => {
if (typeof props.value !== 'undefined') {
setColor(props.value);
}
}, [props.value]);
return (
<ColorPicker value={color} onChange={(e) => setColor(e.toHexString())} open allowClear />
);
};
const { container, rerender } = render(<Demo />);
await waitFakeTimer();
expect(
container.querySelector('.ant-color-picker-trigger .ant-color-picker-clear'),
).toBeFalsy();
fireEvent.click(container.querySelector('.ant-color-picker-clear')!);
expect(
container.querySelector('.ant-color-picker-trigger .ant-color-picker-clear'),
).toBeTruthy();
rerender(<Demo value="#1677ff" />);
expect(
container.querySelector('.ant-color-picker-trigger .ant-color-picker-clear'),
).toBeFalsy();
});
it('Controlled value should work with allowClear correctly', async () => {
const Demo = (props: any) => {
const [color, setColor] = useState<ColorValueType>(generateColor('red'));
useEffect(() => {
if (typeof props.value !== 'undefined') {
setColor(props.value);
}
}, [props.value]);
return <ColorPicker value={color} onChange={(e) => setColor(e)} open allowClear />;
};
const { container, rerender } = render(<Demo />);
await waitFakeTimer();
expect(
container.querySelector('.ant-color-picker-trigger .ant-color-picker-clear'),
).toBeFalsy();
fireEvent.click(container.querySelector('.ant-color-picker-clear')!);
expect(
container.querySelector('.ant-color-picker-trigger .ant-color-picker-clear'),
).toBeTruthy();
rerender(<Demo value="#1677ff" />);
expect(
container.querySelector('.ant-color-picker-trigger .ant-color-picker-clear'),
).toBeFalsy();
});
}); });

View File

@ -11,16 +11,21 @@ export interface Color
extends Pick< extends Pick<
RcColor, RcColor,
'toHsb' | 'toHsbString' | 'toHex' | 'toHexString' | 'toRgb' | 'toRgbString' 'toHsb' | 'toHsbString' | 'toHex' | 'toHexString' | 'toRgb' | 'toRgbString'
> {} > {
cleared: boolean | 'controlled';
}
export class ColorFactory { export class ColorFactory implements Color {
/** Original Color object */ /** Original Color object */
private metaColor: RcColor; private metaColor: RcColor;
public cleared: boolean = false;
constructor(color: ColorGenInput<Color>) { constructor(color: ColorGenInput<Color>) {
this.metaColor = new RcColor(color as ColorGenInput); this.metaColor = new RcColor(color as ColorGenInput);
if (!color) { if (!color) {
this.metaColor.setAlpha(0); this.metaColor.setAlpha(0);
this.cleared = true;
} }
} }

View File

@ -4,17 +4,18 @@ import type { Color } from '../color';
import type { ColorPickerBaseProps } from '../interface'; import type { ColorPickerBaseProps } from '../interface';
import { generateColor } from '../util'; import { generateColor } from '../util';
interface ColorClearProps extends Pick<ColorPickerBaseProps, 'prefixCls' | 'colorCleared'> { interface ColorClearProps extends Pick<ColorPickerBaseProps, 'prefixCls'> {
value?: Color; value?: Color;
onChange?: (value: Color) => void; onChange?: (value: Color) => void;
} }
const ColorClear: FC<ColorClearProps> = ({ prefixCls, value, colorCleared, onChange }) => { const ColorClear: FC<ColorClearProps> = ({ prefixCls, value, onChange }) => {
const handleClick = () => { const handleClick = () => {
if (value && !colorCleared) { if (value && !value.cleared) {
const hsba = value.toHsb(); const hsba = value.toHsb();
hsba.a = 0; hsba.a = 0;
const genColor = generateColor(hsba); const genColor = generateColor(hsba);
genColor.cleared = true;
onChange?.(genColor); onChange?.(genColor);
} }
}; };

View File

@ -2,14 +2,13 @@ import { ColorBlock } from '@rc-component/color-picker';
import classNames from 'classnames'; import classNames from 'classnames';
import type { CSSProperties, MouseEventHandler } from 'react'; import type { CSSProperties, MouseEventHandler } from 'react';
import React, { forwardRef, useMemo } from 'react'; import React, { forwardRef, useMemo } from 'react';
import type { ColorPickerProps } from '../ColorPicker'; import type { ColorPickerProps, ColorPickerBaseProps } from '../interface';
import type { ColorPickerBaseProps } from '../interface';
import { getAlphaColor } from '../util'; import { getAlphaColor } from '../util';
import ColorClear from './ColorClear'; import ColorClear from './ColorClear';
interface colorTriggerProps export interface ColorTriggerProps
extends Pick<ColorPickerBaseProps, 'prefixCls' | 'colorCleared' | 'disabled' | 'format'> { extends Pick<ColorPickerBaseProps, 'prefixCls' | 'disabled' | 'format'> {
color: Exclude<ColorPickerBaseProps['color'], undefined>; color: NonNullable<ColorPickerBaseProps['color']>;
open?: boolean; open?: boolean;
showText?: ColorPickerProps['showText']; showText?: ColorPickerProps['showText'];
className?: string; className?: string;
@ -19,19 +18,18 @@ interface colorTriggerProps
onMouseLeave?: MouseEventHandler<HTMLDivElement>; onMouseLeave?: MouseEventHandler<HTMLDivElement>;
} }
const ColorTrigger = forwardRef<HTMLDivElement, colorTriggerProps>((props, ref) => { const ColorTrigger = forwardRef<HTMLDivElement, ColorTriggerProps>((props, ref) => {
const { color, prefixCls, open, colorCleared, disabled, format, className, showText, ...rest } = const { color, prefixCls, open, disabled, format, className, showText, ...rest } = props;
props;
const colorTriggerPrefixCls = `${prefixCls}-trigger`; const colorTriggerPrefixCls = `${prefixCls}-trigger`;
const containerNode = useMemo<React.ReactNode>( const containerNode = useMemo<React.ReactNode>(
() => () =>
colorCleared ? ( color.cleared ? (
<ColorClear prefixCls={prefixCls} /> <ColorClear prefixCls={prefixCls} />
) : ( ) : (
<ColorBlock prefixCls={prefixCls} color={color.toRgbString()} /> <ColorBlock prefixCls={prefixCls} color={color.toRgbString()} />
), ),
[color, colorCleared, prefixCls], [color, prefixCls],
); );
const genColorString = () => { const genColorString = () => {

View File

@ -7,11 +7,12 @@ import { PanelPickerContext } from '../context';
import type { ColorPickerBaseProps } from '../interface'; import type { ColorPickerBaseProps } from '../interface';
import ColorClear from './ColorClear'; import ColorClear from './ColorClear';
import ColorInput from './ColorInput'; import ColorInput from './ColorInput';
import { generateColor } from '../util';
export interface PanelPickerProps export interface PanelPickerProps
extends Pick< extends Pick<
ColorPickerBaseProps, ColorPickerBaseProps,
'prefixCls' | 'colorCleared' | 'allowClear' | 'disabledAlpha' | 'onChangeComplete' 'prefixCls' | 'allowClear' | 'disabledAlpha' | 'onChangeComplete'
> { > {
value?: Color; value?: Color;
onChange?: (value?: Color, type?: HsbaColorType, pickColor?: boolean) => void; onChange?: (value?: Color, type?: HsbaColorType, pickColor?: boolean) => void;
@ -21,7 +22,6 @@ export interface PanelPickerProps
const PanelPicker: FC = () => { const PanelPicker: FC = () => {
const { const {
prefixCls, prefixCls,
colorCleared,
allowClear, allowClear,
value, value,
disabledAlpha, disabledAlpha,
@ -36,7 +36,6 @@ const PanelPicker: FC = () => {
<ColorClear <ColorClear
prefixCls={prefixCls} prefixCls={prefixCls}
value={value} value={value}
colorCleared={colorCleared}
onChange={(clearColor) => { onChange={(clearColor) => {
onChange?.(clearColor); onChange?.(clearColor);
onClear?.(); onClear?.();
@ -48,8 +47,12 @@ const PanelPicker: FC = () => {
prefixCls={prefixCls} prefixCls={prefixCls}
value={value?.toHsb()} value={value?.toHsb()}
disabledAlpha={disabledAlpha} disabledAlpha={disabledAlpha}
onChange={(colorValue, type) => onChange?.(colorValue, type, true)} onChange={(colorValue, type) => {
onChangeComplete={onChangeComplete} onChange?.(generateColor(colorValue), type, true);
}}
onChangeComplete={(colorValue) => {
onChangeComplete?.(generateColor(colorValue));
}}
/> />
<ColorInput <ColorInput
value={value} value={value}

View File

@ -1,4 +1,16 @@
import React from 'react'; import React from 'react';
import { ColorPicker } from 'antd'; import { ColorPicker } from 'antd';
export default () => <ColorPicker defaultValue="#1677ff" allowClear />; export default () => {
const [color, setColor] = React.useState<string>('#1677ff');
return (
<ColorPicker
value={color}
allowClear
onChange={(c) => {
setColor(c.toHexString());
}}
/>
);
};

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { Color } from '../color'; import type { Color } from '../color';
import type { ColorValueType } from '../interface'; import type { ColorValueType } from '../interface';
@ -11,9 +11,10 @@ function hasValue(value?: ColorValueType) {
const useColorState = ( const useColorState = (
defaultStateValue: ColorValueType, defaultStateValue: ColorValueType,
option: { defaultValue?: ColorValueType; value?: ColorValueType }, option: { defaultValue?: ColorValueType; value?: ColorValueType },
): readonly [Color, React.Dispatch<React.SetStateAction<Color>>] => { ) => {
const { defaultValue, value } = option; const { defaultValue, value } = option;
const [colorValue, setColorValue] = useState<Color>(() => { const prevColor = useRef<Color>(generateColor(''));
const [colorValue, _setColorValue] = useState<Color>(() => {
let mergedState: ColorValueType | undefined; let mergedState: ColorValueType | undefined;
if (hasValue(value)) { if (hasValue(value)) {
mergedState = value; mergedState = value;
@ -22,16 +23,27 @@ const useColorState = (
} else { } else {
mergedState = defaultStateValue; mergedState = defaultStateValue;
} }
return generateColor(mergedState || ''); const color = generateColor(mergedState || '');
prevColor.current = color;
return color;
}); });
const setColorValue: typeof _setColorValue = (color: Color) => {
_setColorValue(color);
prevColor.current = color;
};
useEffect(() => { useEffect(() => {
if (value) { if (hasValue(value)) {
setColorValue(generateColor(value)); const newColor = generateColor(value || '');
if (prevColor.current.cleared === true) {
newColor.cleared = 'controlled';
}
setColorValue(newColor);
} }
}, [value]); }, [value]);
return [colorValue, setColorValue] as const; return [colorValue, setColorValue, prevColor] as const;
}; };
export default useColorState; export default useColorState;

View File

@ -1,6 +1,6 @@
import ColorPicker from './ColorPicker'; import ColorPicker from './ColorPicker';
export type { ColorPickerProps } from './ColorPicker'; export type { ColorPickerProps } from './interface';
export type { Color } from './color'; export type { Color } from './color';
export default ColorPicker; export default ColorPicker;

View File

@ -1,6 +1,8 @@
import type { ReactNode } from 'react'; import type { CSSProperties, FC, ReactNode } from 'react';
import type { ColorPickerProps } from './ColorPicker';
import type { Color } from './color'; import type { Color } from './color';
import type { ColorPickerProps as RcColorPickerProps } from '@rc-component/color-picker';
import type { SizeType } from '../config-provider/SizeContext';
import type { PopoverProps } from '../popover';
export enum ColorFormat { export enum ColorFormat {
hex = 'hex', hex = 'hex',
@ -32,7 +34,6 @@ export interface ColorPickerBaseProps {
prefixCls: string; prefixCls: string;
format?: keyof typeof ColorFormat; format?: keyof typeof ColorFormat;
allowClear?: boolean; allowClear?: boolean;
colorCleared?: boolean;
disabled?: boolean; disabled?: boolean;
disabledAlpha?: boolean; disabledAlpha?: boolean;
presets?: PresetsItem[]; presets?: PresetsItem[];
@ -42,3 +43,36 @@ export interface ColorPickerBaseProps {
} }
export type ColorValueType = Color | string | null; export type ColorValueType = Color | string | null;
export type ColorPickerProps = Omit<
RcColorPickerProps,
'onChange' | 'value' | 'defaultValue' | 'panelRender' | 'disabledAlpha' | 'onChangeComplete'
> & {
value?: ColorValueType;
defaultValue?: ColorValueType;
children?: React.ReactNode;
open?: boolean;
disabled?: boolean;
placement?: TriggerPlacement;
trigger?: TriggerType;
format?: keyof typeof ColorFormat;
defaultFormat?: keyof typeof ColorFormat;
allowClear?: boolean;
presets?: PresetsItem[];
arrow?: boolean | { pointAtCenter: boolean };
panelRender?: (
panel: React.ReactNode,
extra: { components: { Picker: FC; Presets: FC } },
) => React.ReactNode;
showText?: boolean | ((color: Color) => React.ReactNode);
size?: SizeType;
styles?: { popup?: CSSProperties; popupOverlayInner?: CSSProperties };
rootClassName?: string;
disabledAlpha?: boolean;
[key: `data-${string}`]: string;
onOpenChange?: (open: boolean) => void;
onFormatChange?: (format: ColorFormat) => void;
onChange?: (value: Color, hex: string) => void;
onClear?: () => void;
onChangeComplete?: (value: Color) => void;
} & Pick<PopoverProps, 'getPopupContainer' | 'autoAdjustOverflow' | 'destroyTooltipOnHide'>;