🎉 Add color list to colorpicker (#9953)

* 🎉 Add color list to colorpicker

* 🎉 Improve performance

* 🎉 Add accessibility roles

* 🎉 Add test

* 🎉 Add empty state
This commit is contained in:
Eva Marco 2026-06-04 08:47:00 +02:00 committed by GitHub
parent 892869b039
commit c3f107e830
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 515 additions and 72 deletions

View File

@ -85,9 +85,7 @@ test("Create a LINEAR gradient", async ({ page }) => {
.last();
await inputOpacity2.fill("40");
await expect(
workspacePage.page.getByText("Linear gradient")
).toBeVisible();
await expect(workspacePage.page.getByText("Linear gradient")).toBeVisible();
});
test("Create a RADIAL gradient", async ({ page }) => {
@ -161,9 +159,7 @@ test("Create a RADIAL gradient", async ({ page }) => {
.last();
await inputOpacity2.fill("100");
await expect(
workspacePage.page.getByText("Radial gradient")
).toBeVisible();
await expect(workspacePage.page.getByText("Radial gradient")).toBeVisible();
});
test("Gradient stops limit", async ({ page }) => {
@ -244,3 +240,82 @@ test("Bug 10089 - Cannot change alpha", async ({ page }) => {
await expect(alpha).toHaveValue("50");
});
test("Color picker color list", async ({ page }) => {
const workspacePage = new WasmWorkspacePage(page);
await workspacePage.setupEmptyFile();
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-not-empty.json",
);
await workspacePage.mockRPC(
"update-file?id=*",
"workspace/update-file-create-rect.json",
);
await workspacePage.goToWorkspace({
fileId: "6191cd35-bb1f-81f7-8004-7cc63d087374",
pageId: "6191cd35-bb1f-81f7-8004-7cc63d087375",
});
await page.getByRole("tab", { name: "Assets" }).click();
await page.getByRole("button", { name: "Add color" }).click();
const rampSelector = page.getByTestId("value-saturation-selector");
await expect(rampSelector).toBeVisible();
await rampSelector.click({ position: { x: 50, y: 50 } });
await page.getByRole("button", { name: "Save color style" }).click();
await page
.getByTestId("left-sidebar")
.locator('input[type="text"]')
.fill("first color");
await workspacePage.page.keyboard.press("Enter");
await page.getByRole("button", { name: "Add color" }).click();
await rampSelector.click({ position: { x: 40, y: 40 } });
await page.getByRole("button", { name: "Save color style" }).click();
await page
.getByTestId("left-sidebar")
.locator('input[type="text"]')
.fill("second color");
await workspacePage.page.keyboard.press("Enter");
await page.getByRole("button", { name: "Add color" }).click();
await rampSelector.click({ position: { x: 60, y: 60 } });
await page.getByRole("button", { name: "Save color style" }).click();
await page
.getByTestId("left-sidebar")
.locator('input[type="text"]')
.fill("third color");
await workspacePage.page.keyboard.press("Enter");
await page.getByRole("tab", { name: "Layers" }).click();
await workspacePage.clickLeafLayer("Rectangle");
const swatch = workspacePage.page.getByRole("button", { name: "#B1B2B5" });
await swatch.click();
const colorpicker = workspacePage.page.getByTestId("colorpicker");
await expect(colorpicker).toBeVisible();
const colorItems = colorpicker.getByRole("listitem");
const colorButtons = colorItems.getByRole("button");
await expect(colorButtons).toHaveCount(3);
const toggleButton = colorpicker.getByRole("button", { name: "List view" });
await toggleButton.click();
await expect(
colorpicker.getByRole("listitem", { name: "#708191" }),
).toBeVisible();
await colorpicker
.getByRole("combobox")
.filter({ hasText: "Recent colors" })
.click();
await colorpicker.getByRole("option", { name: "File library" }).click();
await expect(
colorpicker.getByRole("listitem", { name: "First color" }),
).toBeVisible();
});

View File

@ -16,7 +16,7 @@
--options-dropdown-border-color: var(--color-background-quaternary);
position: absolute;
inset-block-start: $sz-36;
inset-block: var(--dropdown-start, $sz-36) var(--dropdown-end, auto);
inline-size: var(--dropdown-width, 100%);
transform: translateX(var(--dropdown-translate-distance, 0));
background-color: var(--options-dropdown-bg-color);

View File

@ -11,6 +11,7 @@
(:require
[app.common.data.macros :as dm]
[app.common.json :as json]
[app.common.math :as mth]
[app.common.schema :as sm]
[app.common.types.color :as ct]
[app.config :as cfg]
@ -20,7 +21,7 @@
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- color-title
(defn color-title
[color-item]
(let [{:keys [name path]} (meta color-item)
@ -31,10 +32,16 @@
gradient (:gradient color-item)
image (:image color-item)
color (:color color-item)]
color (:color color-item)
opacity (:opacity color-item)
opacity-text (when (< (:opacity color-item) 1)
(str (mth/round (* (:opacity color-item) 100)) "%"))]
(if (some? name)
(cond
(and (some? opacity) (some? color) (< (:opacity color-item) 1))
(str/ffmt "% (% - %)" path-and-name color opacity-text)
(some? color)
(str/ffmt "% (%)" path-and-name color)
@ -48,6 +55,9 @@
path-and-name)
(cond
(and (some? opacity) (some? color) (< (:opacity color-item) 1))
(str/ffmt "% (%)" color opacity-text)
(some? color)
color

View File

@ -14,9 +14,11 @@
.colorpicker-tooltip {
@extend %modal-background;
--colorpicker-width: #{$sz-284};
left: calc(10 * px2rem(140));
padding: var(--sp-m);
width: $sz-284;
width: var(--colorpicker-width);
overflow: auto;
display: flex;
flex-direction: column;

View File

@ -9,6 +9,8 @@
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.math :as mth]
[app.common.path-names :as cpn]
[app.common.types.color :as ctc]
[app.common.uuid :as uuid]
[app.main.data.event :as ev]
@ -16,26 +18,196 @@
[app.main.data.workspace.colors :as mdc]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.color-bullet :as cb]
[app.main.ui.components.select :refer [select]]
[app.main.ui.context :as ctx]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.product.empty-state :refer [empty-state*]]
[app.main.ui.ds.tooltip :refer [tooltip*]]
[app.main.ui.ds.utilities.swatch :refer [swatch*] :as su]
[app.main.ui.hooks :as h]
[app.main.ui.hooks.resize :as r]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.workspace.sidebar.assets.groups :as grp]
[app.util.color :as uc]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[rumext.v2 :as mf]))
;; ---------------------------------------------------------------------------
;; Private helpers
;; ---------------------------------------------------------------------------
(defn- convert-grouped-colors
"Walk the nested group tree produced by `grp/group-assets` and replace
every raw library color (at the leaf `\"\"` vectors) with its converted
form via `ctc/library-color->color`. Done once inside the effect so the
render path never needs to call `library-color->color`."
[groups resolved-file-id]
(reduce-kv
(fn [acc group-key value]
(assoc acc group-key
(if (= group-key "")
;; leaf vector — convert each raw color
(mapv #(ctc/library-color->color % resolved-file-id) value)
;; nested sub-tree — recurse, preserving sorted-map type
(convert-grouped-colors value resolved-file-id))))
(empty groups)
groups))
;; ---------------------------------------------------------------------------
;; Color row
;; ---------------------------------------------------------------------------
(mf/defc color-row-colorpicker*
"Single color row for the list view. Memoized so it only re-renders when
`color-item` or `on-click` actually changes."
{::mf/memo true
::mf/private true}
[{:keys [color-item on-click]}]
(let [gradient (:gradient color-item)
image (:image color-item)
color (:color color-item)
{:keys [name]} (meta color-item)
element-id (mf/use-id)
element-ref (mf/use-ref nil)
handle-click
(mf/use-fn
(mf/deps on-click color-item)
(fn [_] (on-click color-item)))
opacity-text (str " (" (mth/round (* (:opacity color-item) 100)) "%)")
gradient-text (str " (" (uc/gradient-type->string (:type gradient)) ")")]
[:> tooltip* {:content (su/color-title color-item)
:trigger-ref element-ref
:id element-id}
[:li {:aria-labelledby element-id
:ref element-ref}
[:button {:class (stl/css :color-row-colorpicker)
:on-key-down (fn [e]
(when (or (= (.-key e) "Enter") (= (.-key e) " "))
(.preventDefault e)
(handle-click e)))
:on-click handle-click}
[:> swatch* {:background color-item
:show-tooltip false
:size "medium"}]
(cond
gradient
(if name
[:span {:class (stl/css :color-row-colorpicker-label)}
(str name)
[:span {:class (stl/css :color-row-colorpicker-gradient-type)}
gradient-text]]
[:span {:class (stl/css :color-row-colorpicker-label)}
(tr "media.gradient")
[:span {:class (stl/css :color-row-colorpicker-gradient-type)}
gradient-text]])
image
[:span (tr "media.image")]
color
(if name
[:span {:class (stl/css :color-row-colorpicker-label)}
name
(when (and (number? (:opacity color-item)) (< (:opacity color-item) 1))
[:span {:class (stl/css :color-row-colorpicker-opacity)}
opacity-text])]
[:span {:class (stl/css :color-row-colorpicker-label)}
color
(when (and (number? (:opacity color-item)) (< (:opacity color-item) 1))
[:span {:class (stl/css :color-row-colorpicker-opacity)}
opacity-text])])
:else
[:span (tr "labels.other")])]]]))
;; ---------------------------------------------------------------------------
;; Grouped color list
;; ---------------------------------------------------------------------------
(mf/defc color-group-list*
"Renders library colors organised by group/path in list view.
`groups` nested map produced by `grp/group-assets`, with colors
already converted via `convert-grouped-colors`
`prefix` accumulated group label (empty string at root)
`resolved-file-id` UUID of the library the colors belong to
`on-color-click` called with the converted color map on click
`open-groups` set of group paths that are currently collapsed
`on-toggle-group` (fn [path]) to toggle a group open/closed"
{::mf/memo true
::mf/private true}
[{:keys [groups prefix resolved-file-id on-color-click open-groups on-toggle-group]}]
(let [direct-colors (get groups "")
subgroups (dissoc groups "")
is-root? (empty? prefix)
collapsed? (and (not is-root?) (contains? open-groups prefix))
handle-toggle-group (mf/use-fn
(mf/deps prefix on-toggle-group)
(fn [_]
(on-toggle-group prefix)))]
[:*
(when (not is-root?)
[:div {:class (stl/css :color-group-header)
:role "button"
:tab-index 0
:aria-expanded (not collapsed?)
:aria-label prefix
:on-key-down (fn [e]
(when (or (= (.-key e) "Enter") (= (.-key e) " "))
(.preventDefault e)
(handle-toggle-group e)))
:on-click handle-toggle-group}
[:> i/icon* {:icon-id (if collapsed? i/arrow-right i/arrow-down)
:size "s"
:class (stl/css :color-group-arrow)}]
[:span {:class (stl/css :color-group-name)} prefix]])
(when-not collapsed?
[:*
(for [color direct-colors]
[:> color-row-colorpicker*
{:key (dm/str (:ref-id color))
:color-item color
:on-click on-color-click}])
(for [[group-name sub-tree] subgroups]
[:> color-group-list*
{:key group-name
:groups sub-tree
:prefix (cpn/merge-path-item-with-dot prefix group-name)
:resolved-file-id resolved-file-id
:on-color-click on-color-click
:open-groups open-groups
:on-toggle-group on-toggle-group}])])]))
;; ---------------------------------------------------------------------------
;; Libraries panel
;; ---------------------------------------------------------------------------
(mf/defc libraries*
[{:keys [state on-select-color on-add-library-color disable-gradient disable-opacity disable-image]}]
(let [selected* (h/use-shared-state mdc/colorpicker-selected-broadcast-key :recent)
selected (deref selected*)
view-mode* (mf/use-state :grid)
view-mode (deref view-mode*)
file-id (mf/use-ctx ctx/current-file-id)
current-colors* (mf/use-state [])
current-colors (deref current-colors*)
grouped-colors* (mf/use-state {})
grouped-colors (deref grouped-colors*)
open-groups* (mf/use-state #{})
open-groups (deref open-groups*)
libraries (mf/deref refs/libraries)
recent-colors (mf/deref refs/recent-colors)
recent-colors (mf/with-memo [recent-colors]
@ -43,15 +215,15 @@
library-options
(mf/with-memo []
[{:value "recent" :label (tr "workspace.libraries.colors.recent-colors")}
{:value "file" :label (tr "workspace.libraries.colors.file-library")}])
[{:value "recent" :label (tr "workspace.libraries.colors.recent-colors") :id "recent"}
{:value "file" :label (tr "workspace.libraries.colors.file-library") :id "file"}])
options
(mf/with-memo [library-options libraries file-id]
(into library-options
(comp
(map val)
(map (fn [lib] {:value (d/name (:id lib)) :label (:name lib)})))
(map (fn [lib] {:value (d/name (:id lib)) :label (:name lib) :id (d/name (:id lib))})))
(dissoc libraries file-id)))
on-library-change
@ -81,63 +253,149 @@
(-> (mdc/show-palette selected)
(vary-meta assoc ::ev/origin "workspace-colorpicker")))))
toggle-view-mode
(mf/use-fn
(mf/deps view-mode)
(fn []
(let [new-mode (if (= view-mode :grid) :list :grid)]
(reset! view-mode* new-mode))))
on-color-click
(mf/use-fn
(mf/deps state selected on-select-color)
(fn [event]
(fn [color]
(when-not (= :recent selected)
(st/emit! (ev/event
{::ev/name "use-library-color"
::ev/origin "colorpicker"
:external-library (not= :file selected)})))
(on-select-color state event)))]
(on-select-color state color)))
;; Load library colors when the select is changed
on-toggle-group
(mf/use-fn
(fn [path]
(swap! open-groups*
(fn [s]
(if (contains? s path)
(disj s path)
(conj s path))))))]
;; Load library colors when the selected library (or filter options) change.
;;
;; flat current-colors* -- used for the grid view and the recent list view.
;; grouped grouped-colors* -- used for the library grouped list view.
;;
;; Library colors are fully converted with `library-color->color` here so
;; the render path never needs to do it. `flat-colors` is materialised as
;; an eager vector so realisation does not leak into render time.
;; open-groups* is reset to #{} (all groups expanded) on every library switch.
(mf/with-effect [selected recent-colors libraries file-id valid-color?]
(let [file-id (if (= selected :file)
file-id
selected)
(let [resolved-file-id (if (= selected :file) file-id selected)]
(reset! open-groups* #{})
(if (= selected :recent)
(let [colors (into []
(comp
(filter valid-color?)
(map-indexed (fn [index color]
(let [color (if (map? color) color {:color color})]
(vary-meta color assoc ::id (dm/str index)))))
(take-while some?))
(sort ctc/sort-colors (reverse recent-colors)))]
(reset! current-colors* colors)
(reset! grouped-colors* {}))
colors (if (= selected :recent)
;; NOTE: The `map?` check is to keep backwards
;; compatibility We transform from string to map
(->> (reverse recent-colors)
(filter valid-color?)
(map-indexed (fn [index color]
(let [color (if (map? color) color {:color color})]
(vary-meta color assoc ::id (dm/str index)))))
(sort ctc/sort-colors))
(->> (dm/get-in libraries [file-id :data :colors])
(vals)
(filter valid-color?)
(sort-by :name)
(map #(ctc/library-color->color % file-id))
(map-indexed (fn [index color]
(vary-meta color assoc ::id (dm/str index))))))]
(let [raw-colors (->> (dm/get-in libraries [resolved-file-id :data :colors])
(vals)
(filter valid-color?)
(sort-by :name))
(reset! current-colors* colors)))
;; Eager vector for the grid view -- index-based ::id for keying.
flat-colors (into []
(map-indexed (fn [index color]
(-> (ctc/library-color->color color resolved-file-id)
(vary-meta assoc ::id (dm/str index)))))
raw-colors)
;; Group tree with colors already converted -- no conversions at render time.
grouped (some-> (grp/group-assets raw-colors false)
(convert-grouped-colors resolved-file-id))]
(reset! current-colors* flat-colors)
(reset! grouped-colors* (or grouped {}))))))
[:div {:class (stl/css :libraries)}
[:div {:class (stl/css :select-wrapper)}
[:& select
{:class (stl/css :shadow-type-select)
:data-direction "up"
:default-value (or (d/name selected) "recent")
:options options
:on-change on-library-change}]]
[:div {:class (stl/css :selected-colors)}
[:> select* {:on-change on-library-change
:options options
:class (stl/css :library-select)
:default-selected (or (d/name selected) "recent")}]
[:> icon-button*
{:variant "ghost"
:aria-label (tr "workspace.libraries.colors.show-color-palette")
:on-click toggle-palette
:icon i/swatches}]
[:> icon-button*
{:variant "ghost"
:aria-label (if (= :grid view-mode)
(tr "workspace.assets.list-view")
(tr "workspace.assets.grid-view"))
:on-click toggle-view-mode
:icon (if (= :grid view-mode)
i/view-as-list
i/view-as-icons)}]
(when (= selected :file)
[:button {:class (stl/css :add-color-btn)
:on-click on-add-library-color}
deprecated-icon/add])
[:> icon-button*
{:variant "ghost"
:aria-label (tr "workspace.libraries.colors.add-library-color")
:on-click on-add-library-color
:icon i/add}])]
[:button {:class (stl/css :palette-btn)
:on-click toggle-palette}
deprecated-icon/swatches]
(if (= view-mode :grid)
;; Grid view
(if (seq current-colors)
[:ul {:class (stl/css :selected-colors)
:aria-label (tr "workspace.assets.colors")}
(for [color current-colors]
[:li {:key (-> color meta ::id)}
[:> swatch* {:background color
:on-click on-color-click
:size "medium"}]])]
[:> empty-state* {:icon "swatches"
:class (stl/css :empty-state)
:text (if (= selected :recent)
(tr "workspace.libraries.colors.empty-recent-colors")
(tr "workspace.libraries.colors.empty-palette"))}])
(for [color current-colors]
[:> cb/color-bullet*
{:key (-> color meta ::id)
:color color
:on-click on-color-click}])]]))
;; List view
(if (= selected :recent)
;; Recent colors -- flat list or empty state
(if (seq current-colors)
[:ul {:class (stl/css :selected-colors-list)
:aria-label (tr "workspace.assets.colors")}
(for [color current-colors]
[:> color-row-colorpicker*
{:key (-> color meta ::id)
:color-item color
:on-click on-color-click}])]
[:> empty-state* {:icon "swatches"
:class (stl/css :empty-state)
:text (tr "workspace.libraries.colors.empty-recent-colors")}])
;; Library colors -- grouped list view or empty state
(if (seq grouped-colors)
(let [resolved-file-id (if (= selected :file) file-id selected)]
[:ul {:class (stl/css :selected-colors-list)
:aria-label (tr "workspace.assets.colors")}
[:> color-group-list*
{:groups grouped-colors
:prefix ""
:resolved-file-id resolved-file-id
:on-color-click on-color-click
:open-groups open-groups
:on-toggle-group on-toggle-group}]])
[:> empty-state* {:icon "swatches"
:class (stl/css :empty-state)
:text (tr "workspace.libraries.colors.empty-palette")}])))]))

View File

@ -4,42 +4,116 @@
//
// Copyright (c) KALEIDOS INC Sucursal en España SL
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/typography.scss" as t;
@use "ds/_sizes.scss" as *;
@use "ds/colors.scss" as *;
@use "ds/mixins.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/borders.scss" as *;
.libraries {
margin-top: deprecated.$s-8;
width: 100%;
margin-block-start: var(--sp-s);
inline-size: 100%;
}
.selected-colors {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: deprecated.$s-4;
justify-content: space-between;
gap: var(--sp-xs);
overflow: auto;
margin-top: deprecated.$s-8;
max-height: deprecated.$s-168;
margin-block-start: var(--sp-s);
max-block-size: px2rem(168);
padding-inline-start: var(--sp-xxs);
}
.add-color-btn,
.palette-btn {
@extend %button-secondary;
.selected-colors-list {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
overflow: auto;
margin-block-start: var(--sp-s);
max-block-size: px2rem(168);
}
height: deprecated.$s-24;
width: deprecated.$s-24;
border-radius: deprecated.$br-circle;
padding: 0;
.color-row-colorpicker-label {
@include t.use-typography("body-small");
@include text-ellipsis;
svg {
@extend %button-icon;
inline-size: calc(var(--colorpicker-width) - #{px2rem(67)});
color: var(--color-foreground-primary);
text-align: left;
}
.color-row-colorpicker-gradient-type,
.color-row-colorpicker-opacity {
color: var(--color-foreground-secondary);
}
.color-row-colorpicker {
border: none;
background: none;
cursor: pointer;
display: grid;
grid-template-columns: auto 1fr;
gap: var(--sp-xs);
min-block-size: $sz-32;
inline-size: calc(var(--colorpicker-width) - #{px2rem(35)});
align-items: center;
justify-content: start;
border-radius: $br-8;
padding-block: 0;
padding-inline: var(--sp-xs);
&:hover {
background: var(--color-background-quaternary);
}
}
.color-group-header {
display: flex;
align-items: center;
min-block-size: $sz-32;
gap: var(--sp-xs);
padding-block: var(--sp-xs);
padding-inline: var(--sp-xs);
cursor: pointer;
border-radius: $br-8;
user-select: none;
}
.color-group-arrow {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--color-foreground-secondary);
}
.color-group-name {
@include t.use-typography("body-small");
@include text-ellipsis;
cursor: pointer;
color: var(--color-foreground-secondary);
}
.selected-colors::after {
content: "";
flex: auto;
}
.select-wrapper {
--dropdown-start: auto;
--dropdown-end: #{$sz-36};
--dropdown-width: var(--seven-columns-width);
display: flex;
align-items: center;
overflow: initial;
gap: var(--sp-xs);
}
.empty-state {
margin-block: px2rem(40) px2rem(48);
}

View File

@ -6975,6 +6975,18 @@ msgstr "HSV"
msgid "workspace.libraries.colors.recent-colors"
msgstr "Recent colors"
#: src/app/main/ui/workspace/colorpicker/libraries.cljs
msgid "workspace.libraries.colors.add-library-color"
msgstr "Add color to library"
#: src/app/main/ui/workspace/colorpicker/libraries.cljs
msgid "workspace.libraries.colors.show-color-palette"
msgstr "Show color palette"
#: src/app/main/ui/workspace/colorpicker/libraries.cljs
msgid "workspace.libraries.colors.empty-recent-colors"
msgstr "There are no recent colors yet"
#: src/app/main/ui/workspace/colorpicker.cljs
#, unused
msgid "workspace.libraries.colors.rgb-complementary"

View File

@ -6807,6 +6807,18 @@ msgstr "HSV"
msgid "workspace.libraries.colors.recent-colors"
msgstr "Colores recientes"
#: src/app/main/ui/workspace/colorpicker/libraries.cljs
msgid "workspace.libraries.colors.add-library-color"
msgstr "Añadir color a la biblioteca del archivo"
#: src/app/main/ui/workspace/colorpicker/libraries.cljs
msgid "workspace.libraries.colors.show-color-palette"
msgstr "Mostrar paleta de colores"
#: src/app/main/ui/workspace/colorpicker/libraries.cljs
msgid "workspace.libraries.colors.empty-recent-colors"
msgstr "Aún no hay colores recientes"
#: src/app/main/ui/workspace/colorpicker.cljs
#, unused
msgid "workspace.libraries.colors.rgb-complementary"