Skip to content

Commit 0bb345a

Browse files
committed
fix(color-picker): re-dispatch mouseup in capture phase to prevent stuck drag in popups
1 parent 1564477 commit 0bb345a

3 files changed

Lines changed: 85 additions & 6 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 where the color picker would enter a stuck drag mode when placed inside a popup page, causing the color to keep changing on mouse move without holding the mouse button.
12+
913
## [2.1.6] - 2026-05-07
1014

1115
### Fixed

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

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,16 @@ export const ColorPicker = (props: ColorPickerProps): ReactElement => {
8383
[onColorChange, abortCompleteColorChange]
8484
);
8585

86-
const validateColor = (colorValue: string): void => {
87-
const message = validateColorFormat(colorValue, format);
88-
const validProps = validateProps(props);
89-
const alertMessage = message ? invalidFormatMessage?.replaceAll(":colors:", message) : undefined;
90-
setAlertMessage(validProps || alertMessage);
91-
};
86+
const validateColor = useCallback(
87+
(colorValue: string): void => {
88+
const message = validateColorFormat(colorValue, format);
89+
const validProps = validateProps(props);
90+
const alertMessage = message ? invalidFormatMessage?.replaceAll(":colors:", message) : undefined;
91+
setAlertMessage(validProps || alertMessage);
92+
},
93+
// eslint-disable-next-line react-hooks/exhaustive-deps
94+
[format, invalidFormatMessage]
95+
);
9296

9397
const setColorPickerHidden = useCallback(
9498
(hide: boolean): void => {
@@ -174,6 +178,24 @@ export const ColorPicker = (props: ColorPickerProps): ReactElement => {
174178
validateColor(color);
175179
}
176180
}, [color]);
181+
182+
useEffect(() => {
183+
if (hidden) {
184+
return undefined;
185+
}
186+
187+
// react-color binds its mouseup cleanup to window in the bubble phase.
188+
// A Mendix dialog calls stopPropagation on mouseup, preventing it from
189+
// reaching window — leaving the picker stuck in drag mode.
190+
// Re-dispatching in the capture phase ensures react-color always sees
191+
// the release event regardless of dialog interference.
192+
const releaseDrag = (): void => {
193+
window.dispatchEvent(new MouseEvent("mouseup"));
194+
};
195+
document.addEventListener("mouseup", releaseDrag, true);
196+
return () => document.removeEventListener("mouseup", releaseDrag, true);
197+
}, [hidden]);
198+
177199
return (
178200
<div
179201
className={classNames("widget-color-picker widget-color-picker-picker", {

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,59 @@ describe("ColorPicker", () => {
4040
expect(colorPickerProps.onColorChange).toHaveBeenCalled();
4141
});
4242

43+
describe("stuck drag fix (popup mouseup re-dispatch)", () => {
44+
it("attaches capture-phase mouseup listener on document when picker is visible", async () => {
45+
const addSpy = jest.spyOn(document, "addEventListener");
46+
const { getByRole } = renderColorPicker({ mode: "popover" });
47+
await user.click(getByRole("button"));
48+
49+
expect(addSpy).toHaveBeenCalledWith("mouseup", expect.any(Function), true);
50+
addSpy.mockRestore();
51+
});
52+
53+
it("re-dispatches mouseup on window when document fires mouseup", async () => {
54+
const { getByRole } = renderColorPicker({ mode: "popover" });
55+
await user.click(getByRole("button"));
56+
57+
const windowDispatchSpy = jest.spyOn(window, "dispatchEvent");
58+
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
59+
60+
expect(windowDispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: "mouseup" }));
61+
windowDispatchSpy.mockRestore();
62+
});
63+
64+
it("does not attach listener when picker is hidden", () => {
65+
const addSpy = jest.spyOn(document, "addEventListener");
66+
renderColorPicker({ mode: "popover" });
67+
68+
const mouseupCalls = addSpy.mock.calls.filter(
69+
([event, , capture]) => event === "mouseup" && capture === true
70+
);
71+
expect(mouseupCalls).toHaveLength(0);
72+
addSpy.mockRestore();
73+
});
74+
75+
it("removes capture listener when picker is hidden again", async () => {
76+
const removeSpy = jest.spyOn(document, "removeEventListener");
77+
const { getByRole } = renderColorPicker({ mode: "popover" });
78+
const button = getByRole("button");
79+
80+
await user.click(button);
81+
await user.click(button);
82+
83+
expect(removeSpy).toHaveBeenCalledWith("mouseup", expect.any(Function), true);
84+
removeSpy.mockRestore();
85+
});
86+
87+
it("attaches listener for inline mode (picker always visible)", () => {
88+
const addSpy = jest.spyOn(document, "addEventListener");
89+
renderColorPicker({ mode: "inline" });
90+
91+
expect(addSpy).toHaveBeenCalledWith("mouseup", expect.any(Function), true);
92+
addSpy.mockRestore();
93+
});
94+
});
95+
4396
describe("renders a picker of type", () => {
4497
it("sketch", async () => {
4598
const { container, getByRole } = renderColorPicker({ type: "sketch" });

0 commit comments

Comments
 (0)