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
4 changes: 4 additions & 0 deletions packages/pluggableWidgets/color-picker-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Fixed

- 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.

## [2.1.6] - 2026-05-07

### Fixed
Expand Down
6 changes: 6 additions & 0 deletions packages/pluggableWidgets/color-picker-web/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js");

module.exports = {
...base,
setupFilesAfterEnv: [...base.setupFilesAfterEnv, "<rootDir>/jest.setup.ts"]
};
2 changes: 1 addition & 1 deletion packages/pluggableWidgets/color-picker-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"publish-marketplace": "rui-publish-marketplace",
"release": "cross-env MPKOUTPUT=ColorPicker.mpk pluggable-widgets-tools release:web",
"start": "cross-env MPKOUTPUT=ColorPicker.mpk pluggable-widgets-tools start:server",
"test": "pluggable-widgets-tools test:unit:web",
"test": "jest",
"update-changelog": "rui-update-changelog-widget",
"verify": "rui-verify-package-format"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,16 @@
[onColorChange, abortCompleteColorChange]
);

const validateColor = (colorValue: string): void => {
const message = validateColorFormat(colorValue, format);
const validProps = validateProps(props);
const alertMessage = message ? invalidFormatMessage?.replaceAll(":colors:", message) : undefined;
setAlertMessage(validProps || alertMessage);
};
const validateColor = useCallback(
(colorValue: string): void => {
const message = validateColorFormat(colorValue, format);
const validProps = validateProps(props);
const alertMessage = message ? invalidFormatMessage?.replaceAll(":colors:", message) : undefined;
setAlertMessage(validProps || alertMessage);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[format, invalidFormatMessage]
);

const setColorPickerHidden = useCallback(
(hide: boolean): void => {
Expand Down Expand Up @@ -173,7 +177,25 @@
if (color) {
validateColor(color);
}
}, [color]);

Check warning on line 180 in packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx

View workflow job for this annotation

GitHub Actions / Run code quality check

React Hook useEffect has a missing dependency: 'validateColor'. Either include it or remove the dependency array

useEffect(() => {
if (hidden) {
return undefined;
}

// react-color binds its mouseup cleanup to window in the bubble phase.
// A Mendix dialog calls stopPropagation on mouseup, preventing it from
// reaching window — leaving the picker stuck in drag mode.
// Re-dispatching in the capture phase ensures react-color always sees
// the release event regardless of dialog interference.
const releaseDrag = (): void => {
window.dispatchEvent(new MouseEvent("mouseup"));
};
document.addEventListener("mouseup", releaseDrag, true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The code here implies that we going to dispatch mouseup up until event is removed.

return () => document.removeEventListener("mouseup", releaseDrag, true);
}, [hidden]);

return (
<div
className={classNames("widget-color-picker widget-color-picker-picker", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,59 @@ describe("ColorPicker", () => {
expect(colorPickerProps.onColorChange).toHaveBeenCalled();
});

describe("stuck drag fix (popup mouseup re-dispatch)", () => {
it("attaches capture-phase mouseup listener on document when picker is visible", async () => {
const addSpy = jest.spyOn(document, "addEventListener");
const { getByRole } = renderColorPicker({ mode: "popover" });
await user.click(getByRole("button"));

expect(addSpy).toHaveBeenCalledWith("mouseup", expect.any(Function), true);
addSpy.mockRestore();
});

it("re-dispatches mouseup on window when document fires mouseup", async () => {
const { getByRole } = renderColorPicker({ mode: "popover" });
await user.click(getByRole("button"));

const windowDispatchSpy = jest.spyOn(window, "dispatchEvent");
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));

expect(windowDispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: "mouseup" }));
windowDispatchSpy.mockRestore();
});

it("does not attach listener when picker is hidden", () => {
const addSpy = jest.spyOn(document, "addEventListener");
renderColorPicker({ mode: "popover" });

const mouseupCalls = addSpy.mock.calls.filter(
([event, , capture]) => event === "mouseup" && capture === true
);
expect(mouseupCalls).toHaveLength(0);
addSpy.mockRestore();
});

it("removes capture listener when picker is hidden again", async () => {
const removeSpy = jest.spyOn(document, "removeEventListener");
const { getByRole } = renderColorPicker({ mode: "popover" });
const button = getByRole("button");

await user.click(button);
await user.click(button);

expect(removeSpy).toHaveBeenCalledWith("mouseup", expect.any(Function), true);
removeSpy.mockRestore();
});

it("attaches listener for inline mode (picker always visible)", () => {
const addSpy = jest.spyOn(document, "addEventListener");
renderColorPicker({ mode: "inline" });

expect(addSpy).toHaveBeenCalledWith("mouseup", expect.any(Function), true);
addSpy.mockRestore();
});
});

describe("renders a picker of type", () => {
it("sketch", async () => {
const { container, getByRole } = renderColorPicker({ type: "sketch" });
Expand Down
11 changes: 11 additions & 0 deletions packages/pluggableWidgets/color-picker-web/src/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import "@testing-library/jest-dom";

const originalConsoleError = console.error.bind(console);
console.error = (...args: unknown[]): void => {
// react-color@2.19.3 uses defaultProps on function components, deprecated in React 18.3.
// These warnings are from the library, not our widget code.
if (typeof args[0] === "string" && args[0].includes("Support for defaultProps will be removed")) {
return;
}
originalConsoleError(...args);
};
Loading