diff --git a/frontend/playwright/ui/specs/tokens/crud.spec.js b/frontend/playwright/ui/specs/tokens/crud.spec.js index 3e90aca0e3..6fb6a0cf42 100644 --- a/frontend/playwright/ui/specs/tokens/crud.spec.js +++ b/frontend/playwright/ui/specs/tokens/crud.spec.js @@ -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", () => { diff --git a/frontend/playwright/ui/specs/tokens/tree.spec.js b/frontend/playwright/ui/specs/tokens/tree.spec.js index 919e61fdd6..5b2782a4a6 100644 --- a/frontend/playwright/ui/specs/tokens/tree.spec.js +++ b/frontend/playwright/ui/specs/tokens/tree.spec.js @@ -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(); +}); diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss index 7a4daaabe7..be307c0645 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss @@ -160,6 +160,7 @@ $arrow-side: 12px; grid-area: content; block-size: fit-content; overflow-wrap: anywhere; + white-space: pre-line; } .tooltip-trigger { diff --git a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs index a658001be1..b1c6b0342d 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs @@ -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?}])])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs index 2c19792332..cdb8d89284 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.cljs @@ -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])]])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss index 4580062688..7698fc4da5 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss @@ -192,3 +192,7 @@ --token-pill-accent: var(--color-foreground-error); } } + +.token-pill-tooltip-name { + font-weight: 700; +} diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs index 7930f784fc..3a5973209a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs @@ -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}]]))]))