diff --git a/addons/web/static/src/core/colorlist/colorlist.js b/addons/web/static/src/core/colorlist/colorlist.js
new file mode 100644
index 00000000000..28ffecd3032
--- /dev/null
+++ b/addons/web/static/src/core/colorlist/colorlist.js
@@ -0,0 +1,54 @@
+/** @odoo-module **/
+
+import { _lt } from "@web/core/l10n/translation";
+
+const { Component, useRef, useState, useExternalListener } = owl;
+
+export class ColorList extends Component {
+ setup() {
+ this.colorlistRef = useRef("colorlist");
+ this.state = useState({ isExpanded: false });
+ useExternalListener(window, "click", this.onOutsideClick);
+ }
+ get colors() {
+ return this.constructor.COLORS;
+ }
+ onColorSelected(id) {
+ this.props.onColorSelected(id);
+ this.state.isExpanded = false;
+ }
+ onOutsideClick(ev) {
+ if (this.colorlistRef.el.contains(ev.target)) {
+ return;
+ }
+ this.state.isExpanded = false;
+ }
+ onToggle(focus = true) {
+ this.state.isExpanded = !this.state.isExpanded;
+ if (focus) {
+ this.colorlistRef.el.firstElementChild.focus();
+ }
+ }
+}
+
+ColorList.COLORS = [
+ _lt("No color"),
+ _lt("Red"),
+ _lt("Orange"),
+ _lt("Yellow"),
+ _lt("Light blue"),
+ _lt("Dark purple"),
+ _lt("Salmon pink"),
+ _lt("Medium blue"),
+ _lt("Dark blue"),
+ _lt("Fuchsia"),
+ _lt("Green"),
+ _lt("Purple"),
+];
+ColorList.template = "web.ColorList";
+ColorList.props = {
+ colors: Array,
+ isExpanded: { type: Boolean, optional: true },
+ onColorSelected: Function,
+ togglerColor: { type: Number, optional: true },
+};
diff --git a/addons/web/static/src/core/colorlist/colorlist.scss b/addons/web/static/src/core/colorlist/colorlist.scss
new file mode 100644
index 00000000000..865c4e858fe
--- /dev/null
+++ b/addons/web/static/src/core/colorlist/colorlist.scss
@@ -0,0 +1,39 @@
+.o_colorlist {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ max-width: 100%;
+ gap: 10px;
+
+ button {
+ padding: 0;
+ border: 1px solid white;
+ box-shadow: 0 0 0 1px #c9ccd2;
+ width: 22px;
+ height: 17px;
+ }
+}
+
+// Set all the colors but the "no-color" one
+@for $size from 2 through length($o-colors) {
+ .o_colorlist_item_color_#{$size - 1} {
+ background-color: nth($o-colors, $size);
+ }
+}
+
+// Set the "no-color", a red bar on white background
+.o_colorlist_item_color_0 {
+ position: relative;
+ &::before {
+ content: "";
+ @include o-position-absolute(-2px, $left: 10px);
+ display: block;
+ width: 1px;
+ height: 19px;
+ transform: rotate(45deg);
+ background-color: red;
+ }
+ &::after {
+ background-color: white;
+ }
+}
diff --git a/addons/web/static/src/core/colorlist/colorlist.xml b/addons/web/static/src/core/colorlist/colorlist.xml
new file mode 100644
index 00000000000..33a7abcbe99
--- /dev/null
+++ b/addons/web/static/src/core/colorlist/colorlist.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/tests/core/colorlist_tests.js b/addons/web/static/tests/core/colorlist_tests.js
new file mode 100644
index 00000000000..3d8d2ae85aa
--- /dev/null
+++ b/addons/web/static/tests/core/colorlist_tests.js
@@ -0,0 +1,107 @@
+/** @odoo-module **/
+
+import { ColorList } from "@web/core/colorlist/colorlist";
+import { registry } from "@web/core/registry";
+import { uiService } from "@web/core/ui/ui_service";
+import { makeTestEnv } from "../helpers/mock_env";
+import { click, getFixture, mount } from "../helpers/utils";
+
+const { Component, xml } = owl;
+const serviceRegistry = registry.category("services");
+
+let target;
+
+/**
+ * @param {typeof ColorList} Picker
+ * @param {Object} props
+ * @returns {Promise}
+ */
+async function mountComponent(Picker, props) {
+ serviceRegistry.add("ui", uiService);
+ target = getFixture();
+
+ class Parent extends Component {}
+ Parent.template = xml/* xml */ `
+
+
+ `;
+
+ const env = await makeTestEnv();
+ if (!props.onColorSelected) {
+ props.onColorSelected = () => {};
+ }
+ const parent = await mount(Parent, target, { env, props: { Picker, props } });
+ return parent;
+}
+
+QUnit.module("Components", () => {
+ QUnit.module("ColorList");
+
+ QUnit.test("basic rendering", async function (assert) {
+ assert.expect(4);
+
+ await mountComponent(ColorList, {
+ colors: [0, 9],
+ });
+
+ assert.containsOnce(target, ".o_colorlist");
+ assert.containsN(target, "button", 2, "two buttons are available");
+ const secondBtn = target.querySelectorAll(".o_colorlist button")[1];
+ assert.strictEqual(
+ secondBtn.attributes.title.value,
+ "Fuchsia",
+ "second button color is Fuchsia"
+ );
+ assert.hasClass(
+ secondBtn,
+ "o_colorlist_item_color_9",
+ "second button has the corresponding class"
+ );
+ });
+
+ QUnit.test("toggler is available if togglerColor props is given", async function (assert) {
+ assert.expect(9);
+
+ const togglerColorId = 0;
+ await mountComponent(ColorList, {
+ colors: [4, 5, 6],
+ togglerColor: togglerColorId,
+ onColorSelected: (colorId) => assert.step("color #" + colorId + " is selected"),
+ });
+
+ assert.containsOnce(target, ".o_colorlist");
+ assert.containsOnce(
+ target,
+ "button.o_colorlist_toggler",
+ "only the toggler button is available"
+ );
+ assert.hasClass(
+ target.querySelector(".o_colorlist button"),
+ "o_colorlist_item_color_" + togglerColorId,
+ "toggler has the right class"
+ );
+
+ await click(target.querySelector(".o_colorlist button"));
+
+ assert.containsNone(
+ target,
+ "button.o_colorlist_toggler",
+ "toggler button is no longer visible"
+ );
+ assert.containsN(target, "button", 3, "three buttons are available");
+
+ await click(target.querySelector(".outsideDiv"));
+ assert.containsN(target, "button", 1, "only one button is available");
+ assert.containsOnce(
+ target,
+ "button.o_colorlist_toggler",
+ "colorlist has been closed and toggler is visible"
+ );
+
+ // reopen the colorlist and select a color
+ await click(target.querySelector(".o_colorlist_toggler"));
+ await click(target.querySelectorAll(".o_colorlist button")[2]);
+
+ assert.verifySteps(["color #6 is selected"]);
+ });
+});