diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs
index 89f8a49613..7cb60c1ca6 100644
--- a/frontend/src/app/main/ui/ds.cljs
+++ b/frontend/src/app/main/ui/ds.cljs
@@ -21,6 +21,7 @@
[app.main.ui.ds.product.empty-placeholder :refer [empty-placeholder*]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.ds.storybook :as sb]
+ [app.main.ui.ds.utilities.swatch :refer [swatch*]]
[app.util.i18n :as i18n]))
@@ -40,6 +41,7 @@
:Text text*
:TabSwitcher tab-switcher*
:Toast toast*
+ :Swatch swatch*
;; meta / misc
:meta #js {:icons (clj->js (sort icon-list))
:svgs (clj->js (sort raw-svg-list))
diff --git a/frontend/src/app/main/ui/ds/_sizes.scss b/frontend/src/app/main/ui/ds/_sizes.scss
index 1011d12851..5301f6b44d 100644
--- a/frontend/src/app/main/ui/ds/_sizes.scss
+++ b/frontend/src/app/main/ui/ds/_sizes.scss
@@ -8,6 +8,7 @@
// TODO: create actual tokens once we have them from design
$sz-16: px2rem(16);
+$sz-24: px2rem(24);
$sz-32: px2rem(32);
$sz-36: px2rem(36);
$sz-160: px2rem(160);
diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.cljs b/frontend/src/app/main/ui/ds/utilities/swatch.cljs
new file mode 100644
index 0000000000..bfce203c86
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/utilities/swatch.cljs
@@ -0,0 +1,103 @@
+
+;; This Source Code Form is subject to the terms of the Mozilla Public
+;; License, v. 2.0. If a copy of the MPL was not distributed with this
+;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
+;;
+;; Copyright (c) KALEIDOS INC
+
+(ns app.main.ui.ds.utilities.swatch
+ (:require-macros
+ [app.main.style :as stl])
+ (:require
+ [app.common.data.macros :as dm]
+ [cuerdas.core :as str]
+ [rumext.v2 :as mf]))
+
+(def ^:private schema:swatch
+ [:map
+ [:background :string]
+ [:class {:optional true} :string]
+ [:format {:optional true} [:enum "square" "rounded"]]
+ [:size {:optional true} [:enum "small" "medium"]]
+ [:active {:optional true} :boolean]
+ [:on-click {:optional true} fn?]])
+
+(def hex-regex #"^#(?:[0-9a-fA-F]{3}){1,2}$")
+(def rgb-regex #"^rgb\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3})\)$")
+(def hsl-regex #"^hsl\((\d{1,3}),\s*(\d{1,3})%,\s*(\d{1,3})%\)$")
+(def hsla-regex #"^hsla\((\d{1,3}),\s*(\d{1,3})%,\s*(\d{1,3})%,\s*(0|1|0?\.\d+)\)$")
+(def rgba-regex #"^rgba\((\d{1,3}),\s*(\d{1,3}),\s*(\d{1,3}),\s*(0|1|0?\.\d+)\)$")
+
+(defn- gradient? [background]
+ (or
+ (str/starts-with? background "linear-gradient")
+ (str/starts-with? background "radial-gradient")))
+
+(defn- color-solid? [background]
+ (boolean
+ (or (re-matches hex-regex background)
+ (or (re-matches hsl-regex background)
+ (re-matches rgb-regex background)))))
+
+(defn- color-opacity? [background]
+ (boolean
+ (or (re-matches hsla-regex background)
+ (re-matches rgba-regex background))))
+
+(defn- extract-color-and-opacity [background]
+ (cond
+ (re-matches rgba-regex background)
+ (let [[_ r g b a] (re-matches rgba-regex background)]
+ {:color (dm/str "rgb(" r ", " g ", " b ")")
+ :opacity (js/parseFloat a)})
+
+ (re-matches hsla-regex background)
+ (let [[_ h s l a] (re-matches hsla-regex background)]
+ {:color (dm/str "hsl(" h ", " s "%, " l "%)")
+ :opacity (js/parseFloat a)})
+
+ :else
+ {:color background
+ :opacity 1.0}))
+
+(mf/defc swatch*
+ {::mf/props :obj
+ ::mf/schema schema:swatch}
+ [{:keys [background on-click format size active class]
+ :rest props}]
+ (let [element-type (if on-click "button" "div")
+ button-type (if on-click "button" nil)
+ format (or format "square")
+ size (or size "small")
+ active (or active false)
+ {:keys [color opacity]} (extract-color-and-opacity background)
+ class (dm/str class " " (stl/css-case
+ :swatch true
+ :small (= size "small")
+ :medium (= size "medium")
+ :square (= format "square")
+ :active (= active true)
+ :interactive (= element-type "button")
+ :rounded (= format "rounded")))
+ props (mf/spread-props props {:class class :on-click on-click :type button-type})]
+
+ [:> element-type props
+ (cond
+ (color-solid? background)
+ [:span {:class (stl/css :swatch-solid)
+ :style {:background background}}]
+
+ (color-opacity? background)
+ [:span {:class (stl/css :swatch-opacity)}
+ [:span {:class (stl/css :swatch-solid-side)
+ :style {:background color}}]
+ [:span {:class (stl/css :swatch-opacity-side)
+ :style {:background color :opacity opacity}}]]
+
+ (gradient? background)
+ [:span {:class (stl/css :swatch-gradient)
+ :style {:background-image (str background ", repeating-conic-gradient(lightgray 0% 25%, white 0% 50%)")}}]
+
+ :else
+ [:span {:class (stl/css :swatch-image)
+ :style {:background-image (str "url('" background "'), repeating-conic-gradient(lightgray 0% 25%, white 0% 50%)")}}])]))
diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.mdx b/frontend/src/app/main/ui/ds/utilities/swatch.mdx
new file mode 100644
index 0000000000..a091a4e325
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/utilities/swatch.mdx
@@ -0,0 +1,67 @@
+import { Canvas, Meta } from "@storybook/blocks";
+import * as SwatchStories from "./swatch.stories";
+
+
+
+# Swatch
+
+Swatches are elements that display a color, gradient or image. They can sometimes trigger an action.
+
+## Variants
+
+**Color** (`"color"`), displays a solid color. It can take a hexadecimal, an rgb or an rgba.
+
+
+
+**WithOpacity** (`"color"`), displays a solid color on one side and the same color with its opacity applied on the other side. It can take a hexadecimal, an rgb or an rgba.
+
+
+
+**Gradient** (`"gradient"`), displays a gradient. A gradient should be a `linear-gradient` or a `conic-gradient`.
+
+
+
+**Image** (`"image"`) the swatch could display any image.
+
+
+
+**Active** (`"active"`) displays the swatch as active while an interface related action is happening.
+
+
+
+**Size** (`"size"`) shows a bigger or smaller swatch. Accepts `small` and `medium` (_default_) sizes.
+
+
+
+**Format** (`"format"`) displays a square or rounded swatch. Accepts `square` (_default_) and `rounded` sizes.
+
+
+
+## Technical Notes
+
+### Background
+
+The `swatch*` component accepts a `background` prop, which must be:
+
+- An hexadecimal (e.g. `#996633`)
+- An RGB (e.g. `rgb(125, 125, 0)`)
+- An RGBA (e.g. `rgba(125, 125, 0, 0.3)`)
+- A linear gradient (e.g. `linear-gradient(to right, blue, pink)`)
+- A conic gradient (e.g. `conic-gradient(red, orange, yellow, green, blue)`)
+- An image (e.g. `url(https://placecats.com/100/100)`)
+
+### onClick
+
+> Note: If the swatch is interactive, an `aria-label` is required. More on the `Accessibility` section.
+
+The swatch button accepts an onClick prop that expect a function on the parent context.
+It should be useful for launching other tools as a color picker.
+It runs when the user clics on the swatch, or presses enter or space while focusing it.
+
+### Accessibility
+
+If the swatch is interactive, an `aria-label` is required.
+
+```clj
+[:> swatch* {:on-click launch-colorpicker :aria-label "Lorem ipsum"}]
+```
diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.scss b/frontend/src/app/main/ui/ds/utilities/swatch.scss
new file mode 100644
index 0000000000..938bfc7322
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/utilities/swatch.scss
@@ -0,0 +1,104 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+@use "../_borders.scss" as *;
+@use "../_sizes.scss" as *;
+@use "../colors.scss" as *;
+
+.swatch {
+ --border-color: var(--color-accent-primary-muted);
+ --border-radius: #{$br-4};
+ --border-color-active: var(--color-foreground-primary);
+ --border-color-active-inset: var(--color-background-primary);
+
+ --checkerboard-background: repeating-conic-gradient(lightgray 0% 25%, white 0% 50%);
+ --checkerboard-size: 0.5rem 0.5rem;
+
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ overflow: hidden;
+
+ &:focus {
+ --border-color: var(--color-accent-primary);
+ }
+}
+
+.small {
+ inline-size: $sz-16;
+ block-size: $sz-16;
+}
+
+.medium {
+ --checkerboard-size: 1rem 1rem;
+
+ inline-size: $sz-24;
+ block-size: $sz-24;
+}
+
+.rounded {
+ --border-radius: #{$br-circle};
+}
+
+.active {
+ --border-color: var(--border-color-active);
+
+ position: relative;
+
+ &::before {
+ content: "";
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-start: 0;
+ inline-size: 100%;
+ block-size: 100%;
+ border-radius: 3px;
+ box-shadow: 0 0 0 1px var(--border-color-active-inset) inset;
+ }
+}
+
+.interactive {
+ cursor: pointer;
+ appearance: none;
+ margin: 0;
+ padding: 0;
+ background: none;
+
+ &:hover {
+ border: 2px solid var(--border-color);
+ }
+}
+
+.swatch-image,
+.swatch-gradient,
+.swatch-opacity,
+.swatch-solid {
+ block-size: 100%;
+ display: block;
+}
+
+.swatch-gradient {
+ background-size: cover, var(--checkerboard-size);
+ background-position: center, center;
+ background-repeat: no-repeat, repeat;
+}
+
+.swatch-image {
+ background-size: cover, var(--checkerboard-size);
+ background-position: center, center;
+ background-repeat: no-repeat, repeat;
+}
+
+.swatch-opacity {
+ background: var(--checkerboard-background);
+ background-size: var(--checkerboard-size);
+ display: flex;
+}
+
+.swatch-solid-side,
+.swatch-opacity-side {
+ flex: 1;
+ display: block;
+}
diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.stories.jsx b/frontend/src/app/main/ui/ds/utilities/swatch.stories.jsx
new file mode 100644
index 0000000000..165b7c5993
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/utilities/swatch.stories.jsx
@@ -0,0 +1,86 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) KALEIDOS INC
+
+import * as React from "react";
+import Components from "@target/components";
+import { action } from "@storybook/addon-actions";
+
+const { Swatch } = Components;
+
+export default {
+ title: "Foundations/Utilities/Swatch",
+ component: Swatch,
+ argTypes: {
+ background: {
+ control: { type: "text" },
+ },
+ format: {
+ control: "select",
+ options: ["square", "rounded"],
+ },
+ size: {
+ control: "select",
+ options: ["small", "medium"],
+ },
+ active: {
+ control: { type: "boolean" },
+ },
+ },
+ args: {
+ background: "#663399",
+ format: "square",
+ size: "medium",
+ active: false,
+ },
+ render: ({ ...args }) => ,
+};
+
+export const Default = {};
+
+export const WithOpacity = {
+ args: {
+ background: "rgba(255, 0, 0, 0.5)",
+ },
+};
+
+export const LinearGradient = {
+ args: {
+ background: "linear-gradient(to right, transparent, mistyrose)",
+ },
+};
+
+export const Image = {
+ args: {
+ background: "images/form/never-used.png",
+ size: "medium",
+ },
+};
+
+export const Rounded = {
+ args: {
+ format: "rounded",
+ },
+};
+
+export const Small = {
+ args: {
+ size: "small",
+ },
+};
+
+export const Active = {
+ args: {
+ active: true,
+ background: "#CC00CC",
+ },
+};
+
+export const Clickable = {
+ args: {
+ onClick: action("on-click"),
+ "aria-label": "Click swatch",
+ },
+};