♻️ Add DS tooltip to token pills" (#11900)

* 🐛 Fix can-edit permission wiring for token pills

* ✨ Use DS tooltip component in token pill

* 🎉 Add playwright test

* 🐛 Fix tests
This commit is contained in:
Eva Marco 2026-09-28 08:54:12 +02:00 committed by GitHub
parent 7e3f779d8c
commit 20c818661d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 161 additions and 85 deletions

View File

@ -1924,11 +1924,11 @@ test("User disables the current set but token still have resolved values shown i
name: "#ff0000 color.primary",
});
await expect(colorTokenPill).toHaveCount(1);
await colorTokenPill.hover(); // Force title attribute to be attached to the button
await expect(colorTokenPill).toHaveAttribute(
"title",
/Resolved value: #ff0000/,
);
await colorTokenPill.hover();
const tooltip = page.getByRole("tooltip");
await expect(tooltip).toBeVisible();
await expect(tooltip).toContainText("Resolved value: #ff0000");
});
test.describe("User can't create groups that clash with token names", () => {

View File

@ -337,3 +337,38 @@ test("User can see an error on token pill and token modal form when token has an
});
await expect(submitButton).toBeEnabled();
});
test("User sees a formatted tooltip when hovering a token pill", async ({
page,
}) => {
const { tokensSidebar } = await setupTokensFileRender(page);
await unfoldTokenType(tokensSidebar, "color");
const colorToken = tokensSidebar.getByRole("button", {
name: "colors.blue.100",
});
await expect(colorToken).toBeVisible();
// No tooltip should be rendered before hovering the pill
await expect(page.getByRole("tooltip")).not.toBeVisible();
await colorToken.hover();
const tooltip = page.getByRole("tooltip");
await expect(tooltip).toBeVisible();
// The token name is shown next to a "Name:" label, and is bold
await expect(tooltip).toContainText("Name:");
const tokenNameEl = tooltip.getByText("colors.blue.100", { exact: true });
await expect(tokenNameEl).toBeVisible();
await expect(tokenNameEl).toHaveCSS("font-weight", "700");
// The original and resolved values are shown on their own lines
await expect(tooltip).toContainText("Original value:");
await expect(tooltip).toContainText("Resolved value:");
// Moving away hides the tooltip again
await page.mouse.move(0, 0);
await expect(page.getByRole("tooltip")).not.toBeVisible();
});

View File

@ -160,6 +160,7 @@ $arrow-side: 12px;
grid-area: content;
block-size: fit-content;
overflow-wrap: anywhere;
white-space: pre-line;
}
.tooltip-trigger {

View File

@ -95,6 +95,9 @@
can-edit-tokens?
(mf/use-ctx ctx/can-edit-tokens?)
can-edit-file?
(mf/use-ctx ctx/can-edit?)
is-selected-inside-layout (d/nilv is-selected-inside-layout false)
tokens
@ -195,4 +198,6 @@
:tokens-lib tokens-lib
:on-token-pill-click on-token-pill-click
:on-pill-context-menu on-pill-context-menu
:on-node-context-menu on-node-context-menu}])]))
:on-node-context-menu on-node-context-menu
:can-edit can-edit-file?
:can-edit-tokens can-edit-tokens?}])]))

View File

@ -18,9 +18,9 @@
[app.main.data.workspace.tokens.color :as dwtc]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.data.workspace.tokens.format :as dwtf]
[app.main.refs :as refs]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.utilities.token.token-status :refer [token-status-icon*]]
[app.main.ui.ds.tooltip :refer [tooltip*]]
[app.main.ui.ds.utilities.swatch :refer [swatch*]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
@ -127,11 +127,14 @@
(tr "labels.all"))
grouped-values (group-by dimensions-dictionary app-token-keys)
base-title (dm/str "Token: " name "\n"
(tr "workspace.tokens.original-value" (dwtf/format-token-value value)) "\n"
(tr "workspace.tokens.resolved-value" (dwtf/format-token-value resolved-value))
(when (= (:type token) :number)
(dm/str "\n" (tr "workspace.tokens.more-options"))))]
name-line
(mf/html [:* (dm/str (tr "workspace.tokens.token-name") ": ")
[:span {:class (stl/css :token-pill-tooltip-name)} name]])
rest-of-title (dm/str (tr "workspace.tokens.original-value" (dwtf/format-token-value value)) "\n"
(tr "workspace.tokens.resolved-value" (dwtf/format-token-value resolved-value))
(when (= (:type token) :number)
(dm/str "\n" (tr "workspace.tokens.more-options"))))]
(cond
;; If there are errors, show the appropriate message
@ -146,15 +149,21 @@
;; If the token is applied and the user is a is-viewer, show the details
(and is-applied? is-viewer)
(->> [base-title
(tr "workspace.tokens.applied-to")
(if (= :dimensions type)
(translate-and-format grouped-values)
(str "- " title ": " applied-to))]
(str/join "\n"))
(mf/html
[:*
name-line
[:div rest-of-title]
[:div (tr "workspace.tokens.applied-to")]
[:div (if (= :dimensions type)
(translate-and-format grouped-values)
(str "- " title ": " applied-to))]])
;; Otherwise only show the base title
:else base-title)))
:else
(mf/html
[:*
name-line
[:div rest-of-title]]))))
;; FIXME: the token thould already have precalculated references, so
;; we don't need to perform this regex operation on each rerender
@ -188,11 +197,17 @@
(mf/defc token-pill*
{::mf/wrap [mf/memo]}
[{:keys [on-click token on-context-menu selected-shapes is-selected-inside-layout active-theme-tokens]}]
[{:keys [on-click token on-context-menu selected-shapes is-selected-inside-layout active-theme-tokens can-edit]}]
(let [{:keys [name value type]} token
resolved-token (get active-theme-tokens (:name token))
errors (:errors resolved-token)
pill-ref (mf/use-ref nil)
pill-id (mf/use-id)
tooltip-content* (mf/use-state "")
tooltip-content (deref tooltip-content*)
has-selected? (pos? (count selected-shapes))
is-reference? (cfo/is-reference? token)
contains-path? (str/includes? name ".")
@ -219,10 +234,8 @@
(not half-applied?)
(not (attributes-match-selection? selected-shapes attributes {:selected-inside-layout? is-selected-inside-layout})))
;; FIXME: move to context or props
can-edit? (:can-edit (deref refs/permissions))
is-viewer? (not can-edit?)
is-viewer? (not can-edit)
ref-not-in-active-set
(if (contains? cf/flags :tokenscript)
@ -268,77 +281,89 @@
on-context-menu
(mf/use-fn
(mf/deps can-edit? on-context-menu token)
(mf/deps can-edit on-context-menu token)
(fn [e]
(dom/stop-propagation e)
(when can-edit?
(when can-edit
(on-context-menu e token))))
on-click
(mf/use-fn
(mf/deps errors? on-click)
(mf/deps errors? on-click can-edit)
(fn [event]
(dom/stop-propagation event)
(when (and can-edit? (not (seq errors)) on-click)
(when (and can-edit (not (seq errors)) on-click)
(on-click event))))
on-hover
(mf/use-fn
(mf/deps selected-shapes is-viewer? active-theme-tokens token half-applied? no-valid-value ref-not-in-active-set name-collision errors)
(fn [event]
(let [node (dom/get-current-target event)
theme-token (get active-theme-tokens name)
(fn [_]
(let [theme-token (get active-theme-tokens name)
title (generate-tooltip is-viewer? (first selected-shapes) theme-token token
half-applied? no-valid-value ref-not-in-active-set name-collision errors)]
(dom/set-attribute! node "title" title))))]
(reset! tooltip-content* title))))
[:button {:class (stl/css-case
:token-pill true
:token-pill-no-icon (and (not status-icon?) (not errors?))
:token-pill-default can-edit?
:token-pill-disabled disabled?
:token-pill-applied (and can-edit? applied?)
:token-pill-invalid (and can-edit? errors?)
:token-pill-invalid-applied (and applied? errors? can-edit?)
;; `tooltip-content` may hold rich (hiccup) content, so it's wrapped
;; in a function to satisfy the tooltip's `:content` schema; this
;; wrapper is cheap, the actual (expensive) computation still only
;; happens once, inside `on-hover`.
tooltip-content-fn
(mf/use-fn
(mf/deps tooltip-content)
(fn [] tooltip-content))]
:token-pill-viewer is-viewer?
:token-pill-applied-viewer (and is-viewer?
applied?)
:token-pill-invalid-viewer (and is-viewer?
errors?)
:token-pill-invalid-applied-viewer (and is-viewer?
applied?))
:id (str "token-pill-" (:id token))
:type "button"
:on-focus on-hover
[:> tooltip* {:content tooltip-content-fn
:trigger-ref pill-ref
:id pill-id}
[:button {:ref pill-ref
:class (stl/css-case
:token-pill true
:token-pill-no-icon (and (not status-icon?) (not errors?))
:token-pill-default can-edit
:token-pill-disabled disabled?
:token-pill-applied (and can-edit applied?)
:token-pill-invalid (and can-edit errors?)
:token-pill-invalid-applied (and applied? errors? can-edit)
:on-click on-click
:on-mouse-enter on-hover
:token-pill-viewer is-viewer?
:token-pill-applied-viewer (and is-viewer?
applied?)
:token-pill-invalid-viewer (and is-viewer?
errors?)
:token-pill-invalid-applied-viewer (and is-viewer?
applied?))
:id (str "token-pill-" (:id token))
:type "button"
:on-focus on-hover
:on-context-menu on-context-menu}
(cond
errors?
[:> icon*
{:icon-id i/broken-link
:class (stl/css :token-pill-icon)
:aria-label (if name-collision
(wte/resolve-error-message (first errors))
(tr "workspace.tokens.missing-reference"))}]
:on-click on-click
:on-mouse-enter on-hover
color
[:> swatch* {:background color
:size "small"}]
:on-context-menu on-context-menu}
(cond
errors?
[:> icon*
{:icon-id i/broken-link
:class (stl/css :token-pill-icon)
:aria-label (if name-collision
(wte/resolve-error-message (first errors))
(tr "workspace.tokens.missing-reference"))}]
status-icon?
[:> token-status-icon*
{:icon-id token-status-id
:class (stl/css :token-pill-icon)}])
color
[:> swatch* {:background color
:size "small"}]
(if contains-path?
(let [[_ last-part] (cpn/split-by-last-period name)]
[:span {:class (stl/css :divided-name-wrapper)
:aria-label name}
[:span {:class (stl/css :last-name-wrapper)} last-part]])
[:span {:class (stl/css :name-wrapper)
:aria-label name}
name])]))
status-icon?
[:> token-status-icon*
{:icon-id token-status-id
:class (stl/css :token-pill-icon)}])
(if contains-path?
(let [[_ last-part] (cpn/split-by-last-period name)]
[:span {:class (stl/css :divided-name-wrapper)
:aria-label name}
[:span {:class (stl/css :last-name-wrapper)} last-part]])
[:span {:class (stl/css :name-wrapper)
:aria-label name}
name])]]))

View File

@ -192,3 +192,7 @@
--token-pill-accent: var(--color-foreground-error);
}
}
.token-pill-tooltip-name {
font-weight: 700;
}

View File

@ -12,7 +12,6 @@
[app.common.types.tokens-lib :as ctob]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.store :as st]
[app.main.ui.context :as ctx]
[app.main.ui.ds.layers.layer-button :refer [layer-button*]]
[app.main.ui.workspace.tokens.management.token-pill :refer [token-pill*]]
[rumext.v2 :as mf]))
@ -29,7 +28,8 @@
[:tokens-lib {:optional true} :any]
[:on-token-pill-click {:optional true} fn?]
[:on-pill-context-menu {:optional true} fn?]
[:on-node-context-menu {:optional true} fn?]])
[:on-node-context-menu {:optional true} fn?]
[:can-edit {:optional true} :boolean]])
(mf/defc folder-node*
{::mf/schema schema:folder-node}
@ -43,7 +43,8 @@
tokens-lib
on-token-pill-click
on-pill-context-menu
on-node-context-menu]}]
on-node-context-menu
can-edit]}]
(let [full-path (str (name type) "." (:path node))
is-folder-expanded (not (contains? (set (or folded-token-paths [])) full-path))
children (:children node)
@ -100,6 +101,7 @@
:on-pill-context-menu on-pill-context-menu
:on-node-context-menu on-node-context-menu
:tokens-lib tokens-lib
:can-edit can-edit
:selected-token-set-id selected-token-set-id}]]
(let [id (:id (:leaf child))
token (ctob/get-token tokens-lib selected-token-set-id id)]
@ -110,6 +112,7 @@
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-click on-token-pill-click
:can-edit can-edit
:on-context-menu on-pill-context-menu}]))))])]))
(def ^:private schema:token-tree
@ -124,7 +127,9 @@
[:selected-token-set-id {:optional true} :any]
[:on-token-pill-click {:optional true} fn?]
[:on-pill-context-menu {:optional true} fn?]
[:on-node-context-menu {:optional true} fn?]])
[:on-node-context-menu {:optional true} fn?]
[:can-edit {:optional true} :boolean]
[:can-edit-tokens {:optional true} :boolean]])
(mf/defc token-tree*
{::mf/schema schema:token-tree}
@ -138,19 +143,18 @@
selected-token-set-id
on-token-pill-click
on-pill-context-menu
on-node-context-menu]}]
on-node-context-menu
can-edit
can-edit-tokens]}]
(let [separator "."
raw-tree
(mf/with-memo [tokens]
(cpn/build-tree-root tokens separator))
can-edit-file?
(mf/use-ctx ctx/can-edit?)
on-node-context-menu (mf/use-fn
(mf/deps can-edit-file? on-node-context-menu)
(mf/deps can-edit-tokens on-node-context-menu)
(fn [event node]
(when can-edit-file?
(when can-edit-tokens
(on-node-context-menu event node))))
ordered-nodes (mf/with-memo [raw-tree]
@ -175,6 +179,7 @@
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens active-theme-tokens
:on-click on-token-pill-click
:can-edit can-edit
:on-context-menu on-pill-context-menu}])
;; Render segment folder
[:ul {:class (stl/css :node-parent)
@ -189,4 +194,5 @@
:on-node-context-menu on-node-context-menu
:on-pill-context-menu on-pill-context-menu
:tokens-lib tokens-lib
:can-edit can-edit
:selected-token-set-id selected-token-set-id}]]))]))