Skip to content

Commit a3f2f1e

Browse files
authored
[WC-3350] Fix color picker on change (#2154)
2 parents 21210d8 + cec825f commit a3f2f1e

6 files changed

Lines changed: 191 additions & 20 deletions

File tree

packages/pluggableWidgets/color-picker-web/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- We fixed an issue with On change action not triggering in some cases.
12+
913
## [2.1.5] - 2026-03-06
1014

1115
### Fixed

packages/pluggableWidgets/color-picker-web/src/ColorPicker.editorConfig.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1+
import { hidePropertiesIn, hidePropertyIn, Properties, transformGroupsIntoTabs } from "@mendix/pluggable-widgets-tools";
12
import {
23
ContainerProps,
34
ImageProps,
45
RowLayoutProps,
56
StructurePreviewProps,
67
TextProps
78
} from "@mendix/widget-plugin-platform/preview/structure-preview-api";
8-
import { hidePropertiesIn, hidePropertyIn, Properties, transformGroupsIntoTabs } from "@mendix/pluggable-widgets-tools";
99

1010
import { ColorPickerPreviewProps } from "../typings/ColorPickerProps";
11-
import StructurePreviewSvg from "./assets/structure-preview.svg";
1211
import StructurePreviewSvgDark from "./assets/structure-preview-dark.svg";
12+
import StructurePreviewSvg from "./assets/structure-preview.svg";
1313

1414
const defaultColorTypes = ["block", "sketch", "circle", "compact", "twitter"];
1515

packages/pluggableWidgets/color-picker-web/src/components/Button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ReactElement } from "react";
21
import classNames from "classnames";
2+
import { ReactElement } from "react";
33
import { ModeEnum } from "../../typings/ColorPickerProps";
44

55
export interface ButtonProps {

packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { ReactElement, useCallback, useEffect, useState } from "react";
2-
import { Alert } from "@mendix/widget-plugin-component-kit/Alert";
3-
import { DefaultColorsType, FormatEnum, ModeEnum, TypeEnum } from "../../typings/ColorPickerProps";
1+
import classNames from "classnames";
2+
import { ReactElement, useCallback, useEffect, useMemo, useState, useRef } from "react";
43
import {
54
BlockPickerProps,
65
ChromePickerProps,
@@ -15,10 +14,12 @@ import {
1514
SwatchesPickerProps,
1615
TwitterPickerProps
1716
} from "react-color";
18-
import classNames from "classnames";
17+
import { Alert } from "@mendix/widget-plugin-component-kit/Alert";
18+
import { debounce } from "@mendix/widget-plugin-platform/utils/debounce";
19+
import { DefaultColorsType, FormatEnum, ModeEnum, TypeEnum } from "../../typings/ColorPickerProps";
1920
import { getColorPicker, parseColor, validateColorFormat, validateProps } from "../utils";
20-
import { Input } from "./Input";
2121
import { Button } from "./Button";
22+
import { Input } from "./Input";
2223

2324
export interface ColorPickerProps {
2425
id: string;
@@ -53,18 +54,33 @@ export const ColorPicker = (props: ColorPickerProps): ReactElement => {
5354
rgb: "rgb(255,255,255)",
5455
rgba: "rgb(255,255,255,1)"
5556
};
56-
const { type, mode, disabled, defaultColors, color, format, invalidFormatMessage, onColorChange } = props;
57+
const {
58+
type,
59+
mode,
60+
disabled,
61+
defaultColors,
62+
color,
63+
format,
64+
invalidFormatMessage,
65+
onColorChange,
66+
onChange: onColorChangeComplete
67+
} = props;
5768
const ColorElement = getColorPicker(type);
5869
const [hidden, setHidden] = useState(mode !== "inline");
59-
const [currentColor, setCurrentColor] = useState<string | undefined>(color);
70+
const currentColor = useRef<string>(color);
6071
const [alertMessage, setAlertMessage] = useState<string | undefined>();
6172

73+
const [completeColorChange, abortCompleteColorChange] = useMemo(() => {
74+
return debounce(onColorChangeComplete, 500);
75+
}, [onColorChangeComplete]);
76+
6277
const submitColor = useCallback(
6378
(color: string): void => {
64-
setCurrentColor(color);
79+
currentColor.current = color;
6580
onColorChange(color);
81+
abortCompleteColorChange();
6682
},
67-
[onColorChange]
83+
[onColorChange, abortCompleteColorChange]
6884
);
6985

7086
const validateColor = (colorValue: string): void => {
@@ -93,7 +109,7 @@ export const ColorPicker = (props: ColorPickerProps): ReactElement => {
93109
);
94110

95111
const renderInput = (): ReactElement => {
96-
const colorValue = currentColor || color;
112+
const colorValue = currentColor.current || color;
97113
return (
98114
<Input
99115
color={colorValue}
@@ -108,11 +124,15 @@ export const ColorPicker = (props: ColorPickerProps): ReactElement => {
108124
</Input>
109125
);
110126
};
111-
const onChangeComplete = (color: ColorState): void => {
112-
if (currentColor !== parseColor(color, format)) {
113-
props.onChange();
114-
}
115-
};
127+
const onChangeComplete = useCallback(
128+
(color: ColorState): void => {
129+
if (currentColor.current === parseColor(color, format)) {
130+
completeColorChange();
131+
}
132+
},
133+
[format, completeColorChange]
134+
);
135+
116136
const renderButton = (): ReactElement => {
117137
return <Button mode={mode} disabled={disabled} onClick={() => setColorPickerHidden(!hidden)} color={color} />;
118138
};
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import "@testing-library/jest-dom";
2+
import { render } from "@testing-library/react";
3+
import userEvent, { UserEvent } from "@testing-library/user-event";
4+
import { ColorPicker, ColorPickerProps } from "../ColorPicker";
5+
6+
/**
7+
* Mock all react-color pickers to expose controllable onChange / onChangeComplete
8+
* buttons, while still rendering with the expected CSS class names so the
9+
* existing picker-type tests continue to pass.
10+
*/
11+
jest.mock("react-color", () => {
12+
const colorState = {
13+
hex: "#FF0000",
14+
rgb: { r: 255, g: 0, b: 0, a: 1 },
15+
hsl: { h: 0, s: 1, l: 0.5, a: 1 }
16+
};
17+
18+
type MockPickerProps = {
19+
onChange: (c: typeof colorState) => void;
20+
onChangeComplete: (c: typeof colorState) => void;
21+
};
22+
23+
const createMockPicker = (cssClass: string) =>
24+
function MockPicker({ onChange, onChangeComplete }: MockPickerProps) {
25+
return (
26+
<div className={cssClass}>
27+
<button data-testid="trigger-change" onClick={() => onChange(colorState)}>
28+
change
29+
</button>
30+
<button data-testid="trigger-change-complete" onClick={() => onChangeComplete(colorState)}>
31+
complete
32+
</button>
33+
</div>
34+
);
35+
};
36+
37+
return {
38+
SketchPicker: createMockPicker("sketch-picker"),
39+
ChromePicker: createMockPicker("chrome-picker"),
40+
BlockPicker: createMockPicker("block-picker"),
41+
GithubPicker: createMockPicker("github-picker"),
42+
TwitterPicker: createMockPicker("twitter-picker"),
43+
CirclePicker: createMockPicker("circle-picker"),
44+
HuePicker: createMockPicker("hue-picker"),
45+
SliderPicker: createMockPicker("slider-picker"),
46+
CompactPicker: createMockPicker("compact-picker"),
47+
MaterialPicker: createMockPicker("material-picker"),
48+
SwatchesPicker: createMockPicker("swatches-picker")
49+
};
50+
});
51+
52+
describe("ColorPicker – debounced onChange behavior", () => {
53+
/**
54+
* color="#FF0000" matches the hex value emitted by the mock picker so that
55+
* the guard in onChangeComplete (`currentColor.current === parseColor(color, format)`)
56+
* passes and completeColorChange is actually scheduled.
57+
*/
58+
const baseProps: ColorPickerProps = {
59+
id: "color-picker",
60+
name: "color picker",
61+
color: "#FF0000",
62+
disabled: false,
63+
defaultColors: [],
64+
format: "hex",
65+
mode: "inline",
66+
type: "sketch",
67+
onChange: jest.fn(),
68+
onColorChange: jest.fn()
69+
};
70+
71+
let user: UserEvent;
72+
73+
beforeEach(() => {
74+
jest.useFakeTimers();
75+
// advanceTimers keeps userEvent in sync with fake timers
76+
user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
77+
baseProps.onChange = jest.fn();
78+
baseProps.onColorChange = jest.fn();
79+
});
80+
81+
afterEach(() => {
82+
jest.useRealTimers();
83+
});
84+
85+
it("does not call onChange immediately after onChangeComplete fires", async () => {
86+
const { getByTestId } = render(<ColorPicker {...baseProps} />);
87+
88+
// onChange sets currentColor.current so the guard in onChangeComplete passes
89+
await user.click(getByTestId("trigger-change"));
90+
await user.click(getByTestId("trigger-change-complete"));
91+
92+
// Debounce delay has not elapsed yet
93+
expect(baseProps.onChange).not.toHaveBeenCalled();
94+
});
95+
96+
it("calls onChange once after the 500 ms debounce delay", async () => {
97+
const { getByTestId } = render(<ColorPicker {...baseProps} />);
98+
99+
await user.click(getByTestId("trigger-change"));
100+
await user.click(getByTestId("trigger-change-complete"));
101+
102+
expect(baseProps.onChange).not.toHaveBeenCalled();
103+
104+
jest.advanceTimersByTime(500);
105+
106+
expect(baseProps.onChange).toHaveBeenCalledTimes(1);
107+
});
108+
109+
it("calls onChange only once when multiple rapid color changes occur", async () => {
110+
const { getByTestId } = render(<ColorPicker {...baseProps} />);
111+
112+
// Each iteration: onChange aborts the previous pending debounce and
113+
// onChangeComplete schedules a new one. Only the last scheduled call
114+
// survives.
115+
for (let i = 0; i < 5; i++) {
116+
await user.click(getByTestId("trigger-change"));
117+
await user.click(getByTestId("trigger-change-complete"));
118+
// Advance less than the debounce window so the timer never fires mid-loop
119+
jest.advanceTimersByTime(100);
120+
}
121+
122+
// Let the last pending debounce fire
123+
jest.advanceTimersByTime(500);
124+
125+
expect(baseProps.onChange).toHaveBeenCalledTimes(1);
126+
});
127+
128+
it("does not call onChange if abortCompleteColorChange is called before the delay elapses", async () => {
129+
const { getByTestId } = render(<ColorPicker {...baseProps} />);
130+
131+
// Schedule a completeColorChange via onChangeComplete
132+
await user.click(getByTestId("trigger-change"));
133+
await user.click(getByTestId("trigger-change-complete"));
134+
135+
// Advance partway through the debounce window
136+
jest.advanceTimersByTime(200);
137+
138+
// Triggering onChange calls submitColor → abortCompleteColorChange,
139+
// cancelling the previously scheduled debounce
140+
await user.click(getByTestId("trigger-change"));
141+
142+
// Advance past the original debounce deadline
143+
jest.advanceTimersByTime(500);
144+
145+
expect(baseProps.onChange).not.toHaveBeenCalled();
146+
});
147+
});

packages/pluggableWidgets/color-picker-web/src/utils/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import { FunctionComponent } from "react";
2+
import { ColorState } from "react-color";
13
import * as Picker from "react-color/lib";
24
import { FormatEnum, TypeEnum } from "../../typings/ColorPickerProps";
3-
import { ColorState } from "react-color";
45
import { ColorPickerConfigProps, ColorPickerProps } from "../components/ColorPicker";
5-
import { FunctionComponent } from "react";
66

77
export function getColorPicker(type: TypeEnum): FunctionComponent<ColorPickerConfigProps> {
88
switch (type) {

0 commit comments

Comments
 (0)