From 085e40b14f41022cbecf03d08a7d19e997ad8064 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 5 Jun 2026 15:28:51 +0200 Subject: [PATCH 1/4] fix(color-picker): re-dispatch mouseup in capture phase to prevent stuck drag in popups --- .../color-picker-web/CHANGELOG.md | 4 ++ .../src/components/ColorPicker.tsx | 18 +++++++ .../components/__tests__/ColorPicker.spec.tsx | 53 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/packages/pluggableWidgets/color-picker-web/CHANGELOG.md b/packages/pluggableWidgets/color-picker-web/CHANGELOG.md index 7ffc47289e..7c9c58e75e 100644 --- a/packages/pluggableWidgets/color-picker-web/CHANGELOG.md +++ b/packages/pluggableWidgets/color-picker-web/CHANGELOG.md @@ -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 diff --git a/packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx b/packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx index 6c88656f7a..e7899ea869 100644 --- a/packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx +++ b/packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx @@ -174,6 +174,24 @@ export const ColorPicker = (props: ColorPickerProps): ReactElement => { validateColor(color); } }, [color]); + + 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); + return () => document.removeEventListener("mouseup", releaseDrag, true); + }, [hidden]); + return (
{ 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" }); From 8fbd680e8049659b84e9071be9d5815de7d0951e Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 5 Jun 2026 15:28:56 +0200 Subject: [PATCH 2/4] test(color-picker): suppress react-color defaultProps deprecation warnings in test output --- .../pluggableWidgets/color-picker-web/jest.config.js | 6 ++++++ .../pluggableWidgets/color-picker-web/package.json | 2 +- .../color-picker-web/src/jest.setup.ts | 11 +++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 packages/pluggableWidgets/color-picker-web/jest.config.js create mode 100644 packages/pluggableWidgets/color-picker-web/src/jest.setup.ts diff --git a/packages/pluggableWidgets/color-picker-web/jest.config.js b/packages/pluggableWidgets/color-picker-web/jest.config.js new file mode 100644 index 0000000000..d7fa03cf56 --- /dev/null +++ b/packages/pluggableWidgets/color-picker-web/jest.config.js @@ -0,0 +1,6 @@ +const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js"); + +module.exports = { + ...base, + setupFilesAfterEnv: [...base.setupFilesAfterEnv, "/jest.setup.ts"] +}; diff --git a/packages/pluggableWidgets/color-picker-web/package.json b/packages/pluggableWidgets/color-picker-web/package.json index 0d8fe2bbe9..571b5e8bb1 100644 --- a/packages/pluggableWidgets/color-picker-web/package.json +++ b/packages/pluggableWidgets/color-picker-web/package.json @@ -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" }, diff --git a/packages/pluggableWidgets/color-picker-web/src/jest.setup.ts b/packages/pluggableWidgets/color-picker-web/src/jest.setup.ts new file mode 100644 index 0000000000..9f6fb3f556 --- /dev/null +++ b/packages/pluggableWidgets/color-picker-web/src/jest.setup.ts @@ -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); +}; From 2645cb03c5b7bcddaf0f35842590ce6dc0e88739 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 5 Jun 2026 15:28:51 +0200 Subject: [PATCH 3/4] fix(color-picker): re-dispatch mouseup in capture phase to prevent stuck drag in popups --- .../color-picker-web/CHANGELOG.md | 4 ++ .../src/components/ColorPicker.tsx | 18 +++++++ .../components/__tests__/ColorPicker.spec.tsx | 53 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/packages/pluggableWidgets/color-picker-web/CHANGELOG.md b/packages/pluggableWidgets/color-picker-web/CHANGELOG.md index 7ffc47289e..7c9c58e75e 100644 --- a/packages/pluggableWidgets/color-picker-web/CHANGELOG.md +++ b/packages/pluggableWidgets/color-picker-web/CHANGELOG.md @@ -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 diff --git a/packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx b/packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx index 6c88656f7a..e7899ea869 100644 --- a/packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx +++ b/packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx @@ -174,6 +174,24 @@ export const ColorPicker = (props: ColorPickerProps): ReactElement => { validateColor(color); } }, [color]); + + 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); + return () => document.removeEventListener("mouseup", releaseDrag, true); + }, [hidden]); + return (
{ 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" }); From 70a4fce38809f091318512c5fd52f5ba7960efe5 Mon Sep 17 00:00:00 2001 From: Rahman Date: Fri, 5 Jun 2026 15:28:56 +0200 Subject: [PATCH 4/4] test(color-picker): suppress react-color defaultProps deprecation warnings in test output --- .../pluggableWidgets/color-picker-web/jest.config.js | 6 ++++++ .../pluggableWidgets/color-picker-web/package.json | 2 +- .../color-picker-web/src/jest.setup.ts | 11 +++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 packages/pluggableWidgets/color-picker-web/jest.config.js create mode 100644 packages/pluggableWidgets/color-picker-web/src/jest.setup.ts diff --git a/packages/pluggableWidgets/color-picker-web/jest.config.js b/packages/pluggableWidgets/color-picker-web/jest.config.js new file mode 100644 index 0000000000..d7fa03cf56 --- /dev/null +++ b/packages/pluggableWidgets/color-picker-web/jest.config.js @@ -0,0 +1,6 @@ +const base = require("@mendix/pluggable-widgets-tools/test-config/jest.config.js"); + +module.exports = { + ...base, + setupFilesAfterEnv: [...base.setupFilesAfterEnv, "/jest.setup.ts"] +}; diff --git a/packages/pluggableWidgets/color-picker-web/package.json b/packages/pluggableWidgets/color-picker-web/package.json index 0d8fe2bbe9..571b5e8bb1 100644 --- a/packages/pluggableWidgets/color-picker-web/package.json +++ b/packages/pluggableWidgets/color-picker-web/package.json @@ -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" }, diff --git a/packages/pluggableWidgets/color-picker-web/src/jest.setup.ts b/packages/pluggableWidgets/color-picker-web/src/jest.setup.ts new file mode 100644 index 0000000000..9f6fb3f556 --- /dev/null +++ b/packages/pluggableWidgets/color-picker-web/src/jest.setup.ts @@ -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); +};