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/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/components/ColorPicker.tsx b/packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx index 6c88656f7a..54573f069a 100644 --- a/packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx +++ b/packages/pluggableWidgets/color-picker-web/src/components/ColorPicker.tsx @@ -83,12 +83,16 @@ export const ColorPicker = (props: ColorPickerProps): ReactElement => { [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 => { @@ -174,6 +178,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" }); 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); +};