diff --git a/src/components/index.js b/src/components/index.js
index f32815eb..55f708d7 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -83,6 +83,7 @@ export {TotalRow as MuiTotalRow, NotesRow as MuiNotesRow, FeeRow as MuiFeeRow, P
export {default as MuiFormikAsyncSelect} from './mui/formik-inputs/mui-formik-async-select'
export {default as MuiFormikCheckboxGroup} from './mui/formik-inputs/mui-formik-checkbox-group'
export {default as MuiFormikCheckbox} from './mui/formik-inputs/mui-formik-checkbox'
+export {default as MuiFormikColorInput} from './mui/formik-inputs/mui-formik-color-input'
export {default as MuiFormikDatepicker} from './mui/formik-inputs/mui-formik-datepicker'
export {default as MuiFormikDiscountField} from './mui/formik-inputs/mui-formik-discountfield'
export {default as MuiFormikDropdownCheckbox} from './mui/formik-inputs/mui-formik-dropdown-checkbox'
diff --git a/src/components/mui/__tests__/mui-formik-color-input.test.js b/src/components/mui/__tests__/mui-formik-color-input.test.js
new file mode 100644
index 00000000..889838c2
--- /dev/null
+++ b/src/components/mui/__tests__/mui-formik-color-input.test.js
@@ -0,0 +1,166 @@
+/**
+ * Copyright 2026 OpenStack Foundation
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * */
+
+jest.mock("i18n-react/dist/i18n-react", () => ({
+ __esModule: true,
+ default: { translate: (key) => key }
+}));
+
+import React from "react";
+import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
+import { Formik, Form } from "formik";
+import "@testing-library/jest-dom";
+import MuiFormikColorInput from "../formik-inputs/mui-formik-color-input";
+
+const DEBOUNCE_MS = 250;
+const PLACEHOLDER_KEY = "color_picker.placeholder";
+
+const renderWithFormik = (props, initialValues = { testField: "" }) =>
+ render(
+
+
+
+ );
+
+const renderWithSubmit = (initialValues = { testField: "" }, props = {}) => {
+ const onSubmit = jest.fn();
+ render(
+
+
+
+ );
+ return onSubmit;
+};
+
+// Restore real timers after each test to prevent leaking fake timers
+afterEach(() => jest.useRealTimers());
+
+const getColorInput = () => document.querySelector('input[type="color"]');
+
+describe("MuiFormikColorInput", () => {
+ test("renders a color input", () => {
+ renderWithFormik({});
+ expect(getColorInput()).toBeInTheDocument();
+ });
+
+ test("loads initial value: reflects value, shows clear button, hides placeholder", () => {
+ renderWithFormik({}, { testField: "#ff0000" });
+ expect(getColorInput()).toHaveValue("#ff0000");
+ expect(screen.getByRole("button")).toBeInTheDocument();
+ expect(screen.queryByText(PLACEHOLDER_KEY)).not.toBeInTheDocument();
+ });
+
+ test("empty initial state: shows placeholder and no clear button", () => {
+ renderWithFormik({});
+ expect(screen.getByText(PLACEHOLDER_KEY)).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ test("blur without selection: formik value stays empty and placeholder remains", async () => {
+ const onSubmit = renderWithSubmit({ testField: "" });
+ const input = getColorInput();
+
+ fireEvent.focus(input);
+ fireEvent.blur(input);
+
+ expect(screen.getByText(PLACEHOLDER_KEY)).toBeInTheDocument();
+ fireEvent.click(screen.getByText("Submit"));
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith({ testField: "" }, expect.anything());
+ });
+ });
+
+ // The useEffect resets hasValue to false whenever localValue changes (before the debounce
+ // commits the new color to field.value). The clear button only appears after the debounce fires.
+ test("select a color: shows clear button and hides placeholder after debounce", () => {
+ jest.useFakeTimers();
+ renderWithFormik({});
+
+ fireEvent.change(getColorInput(), { target: { value: "#33aaff" } });
+ act(() => jest.advanceTimersByTime(DEBOUNCE_MS));
+
+ expect(screen.getByRole("button")).toBeInTheDocument();
+ expect(screen.queryByText(PLACEHOLDER_KEY)).not.toBeInTheDocument();
+ });
+
+ test("select a color: updates formik value after debounce", async () => {
+ jest.useFakeTimers();
+ const onSubmit = renderWithSubmit({ testField: "" });
+
+ fireEvent.change(getColorInput(), { target: { value: "#33aaff" } });
+ act(() => jest.advanceTimersByTime(DEBOUNCE_MS));
+ jest.useRealTimers();
+
+ fireEvent.click(screen.getByText("Submit"));
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith({ testField: "#33aaff" }, expect.anything());
+ });
+ });
+
+ test("clear button: shows placeholder and hides clear button", () => {
+ renderWithFormik({}, { testField: "#ff0000" });
+
+ fireEvent.click(screen.getByRole("button"));
+
+ expect(screen.getByText(PLACEHOLDER_KEY)).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ test("clear button: resets formik value to empty", async () => {
+ const onSubmit = renderWithSubmit({ testField: "#ff0000" });
+
+ fireEvent.click(screen.getAllByRole("button")[0]);
+
+ fireEvent.click(screen.getByText("Submit"));
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith({ testField: "" }, expect.anything());
+ });
+ });
+
+ test("shows error message when field is touched and has an error", () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText("Color is required")).toBeInTheDocument();
+ });
+
+ test("does not show error when field is not yet touched", () => {
+ render(
+
+
+
+ );
+ expect(screen.queryByText("Color is required")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/mui/formik-inputs/mui-formik-async-select.js b/src/components/mui/formik-inputs/mui-formik-async-select.js
index 8f724fb6..109c1ddc 100644
--- a/src/components/mui/formik-inputs/mui-formik-async-select.js
+++ b/src/components/mui/formik-inputs/mui-formik-async-select.js
@@ -137,4 +137,4 @@ const MuiFormikAsyncAutocomplete = ({
);
};
-export default MuiFormikAsyncAutocomplete;
+export default MuiFormikAsyncAutocomplete;
\ No newline at end of file
diff --git a/src/components/mui/formik-inputs/mui-formik-color-input.js b/src/components/mui/formik-inputs/mui-formik-color-input.js
new file mode 100644
index 00000000..20404ad0
--- /dev/null
+++ b/src/components/mui/formik-inputs/mui-formik-color-input.js
@@ -0,0 +1,114 @@
+import React, { useEffect, useRef, useState } from "react";
+import T from "i18n-react";
+import PropTypes from "prop-types";
+import { Box, IconButton, InputAdornment, TextField } from "@mui/material";
+import ClearIcon from "@mui/icons-material/Clear";
+import { useField } from "formik";
+import { DEBOUNCE_WAIT_250 } from "../../../utils/constants";
+
+const MuiFormikColorInput = ({ name, placeholder = T.translate("color_picker.placeholder"), InputProps: restInputProps, ...rest }) => {
+ const [field, meta, helpers] = useField(name);
+ const [hasValue, setHasValue] = useState(Boolean(field.value));
+ const [localValue, setLocalValue] = useState(field.value || "#000000");
+ const debounceRef = useRef(null);
+
+ useEffect(() => {
+ setHasValue(Boolean(field.value));
+ if (field.value && field.value !== localValue) setLocalValue(field.value);
+ }, [field.value, localValue]);
+
+ useEffect(() => () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ }, []);
+
+ const handleChange = (e) => {
+ const value = e.target.value;
+ setLocalValue(value);
+ setHasValue(true);
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ helpers.setValue(value);
+ debounceRef.current = null;
+ }, DEBOUNCE_WAIT_250);
+ };
+
+ const handleBlur = (e) => {
+ field.onBlur(e);
+ helpers.setTouched(true);
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ debounceRef.current = null;
+ helpers.setValue(hasValue ? localValue : "");
+ }
+ };
+
+ const handleClear = (e) => {
+ e.stopPropagation();
+ if (debounceRef.current) {
+ clearTimeout(debounceRef.current);
+ debounceRef.current = null;
+ }
+ setHasValue(false);
+ helpers.setValue("");
+ helpers.setTouched(true);
+ };
+
+ return (
+
+
+
+
+
+
+ ),
+ } : {}), ...restInputProps
+ }}
+ sx={{
+ "& input[type='color']::-webkit-color-swatch-wrapper": { padding: "2px" },
+ }}
+ {...rest}
+ />
+ {!hasValue && (
+
+
+ {placeholder}
+
+
+ )}
+
+ );
+};
+
+MuiFormikColorInput.propTypes = {
+ name: PropTypes.string.isRequired,
+ placeholder: PropTypes.string
+};
+
+export default MuiFormikColorInput;
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 01f7bf27..96f2f2f8 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -138,5 +138,8 @@
"total": "Total",
"rate": "Rate",
"action": "Action"
+ },
+ "color_picker": {
+ "placeholder": "Select a color"
}
}
diff --git a/src/utils/constants.js b/src/utils/constants.js
index 85e190fc..3f140450 100644
--- a/src/utils/constants.js
+++ b/src/utils/constants.js
@@ -7,6 +7,7 @@ export const BPS = 100;
export const CODE_200 = 200;
+export const DEBOUNCE_WAIT_150 = 150;
export const DEBOUNCE_WAIT_250 = 250;
export const DEBOUNCE_WAIT = 500;
diff --git a/webpack.common.js b/webpack.common.js
index 2a9513fc..00192aea 100644
--- a/webpack.common.js
+++ b/webpack.common.js
@@ -108,6 +108,7 @@ module.exports = {
'components/mui/formik-inputs/async-select': './src/components/mui/formik-inputs/mui-formik-async-select.js',
'components/mui/formik-inputs/checkbox-group': './src/components/mui/formik-inputs/mui-formik-checkbox-group.js',
'components/mui/formik-inputs/checkbox': './src/components/mui/formik-inputs/mui-formik-checkbox.js',
+ 'components/mui/formik-inputs/color-input': './src/components/mui/formik-inputs/mui-formik-color-input.js',
'components/mui/formik-inputs/datepicker': './src/components/mui/formik-inputs/mui-formik-datepicker.js',
'components/mui/formik-inputs/discount-field': './src/components/mui/formik-inputs/mui-formik-discountfield.js',
'components/mui/formik-inputs/dropdown-checkbox': './src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js',