From 0956b66281bba6303e2c0acb85bb2aa6be4d9d1a Mon Sep 17 00:00:00 2001 From: Xaviju Date: Wed, 10 Dec 2025 12:34:19 +0100 Subject: [PATCH] :lipstick: Group tokens by name path (#7775) * :lipstick: Group tokens by name path --- common/src/app/common/path_names.cljc | 91 ++++++++++ frontend/playwright/ui/specs/tokens.spec.js | 157 ++++++++++-------- .../app/main/ui/ds/layers/layer_button.cljs | 49 ++++++ .../app/main/ui/ds/layers/layer_button.scss | 56 +++++++ .../main/ui/workspace/tokens/management.cljs | 74 +++++---- .../ui/workspace/tokens/management/group.cljs | 133 +++++++++------ .../ui/workspace/tokens/management/group.scss | 11 -- .../tokens/management/token_pill.cljs | 3 +- .../tokens/management/token_tree.cljs | 110 ++++++++++++ .../tokens/management/token_tree.scss | 39 +++++ 10 files changed, 555 insertions(+), 168 deletions(-) create mode 100644 frontend/src/app/main/ui/ds/layers/layer_button.cljs create mode 100644 frontend/src/app/main/ui/ds/layers/layer_button.scss delete mode 100644 frontend/src/app/main/ui/workspace/tokens/management/group.scss create mode 100644 frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs create mode 100644 frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss diff --git a/common/src/app/common/path_names.cljc b/common/src/app/common/path_names.cljc index 6fdf1d1525..74774c0044 100644 --- a/common/src/app/common/path_names.cljc +++ b/common/src/app/common/path_names.cljc @@ -132,3 +132,94 @@ Some naming conventions: (if-let [last-period (str/last-index-of s ".")] [(subs s 0 (inc last-period)) (subs s (inc last-period))] [s ""])) + +;; Tree building functions -------------------------------------------------- + +"Build tree structure from flat list of paths" + +"`build-tree-root` is the main function to build the tree." + +"Receives a list of segments with 'name' properties representing paths, + and a separator string." +"E.g segments = [{... :name 'one/two/three'} {... :name 'one/two/four'} {... :name 'one/five'}]" + +"Transforms into a tree structure like: + [{:name 'one' + :path 'one' + :depth 0 + :leaf nil + :children-fn (fn [] [{:name 'two' + :path 'one.two' + :depth 1 + :leaf nil + :children-fn (fn [] [{... :name 'three'} {... :name 'four'}])} + {:name 'five' + :path 'one.five' + :depth 1 + :leaf {... :name 'five'} + ...}])}]" + +(defn- sort-by-children + "Sorts segments so that those with children come first." + [segments separator] + (sort-by (fn [segment] + (let [path (split-path (:name segment) :separator separator) + path-length (count path)] + (if (= path-length 1) + 1 + 0))) + segments)) + +(defn- group-by-first-segment + "Groups segments by their first path segment and update segment name." + [segments separator] + (reduce (fn [acc segment] + (let [[first-segment & remaining-segments] (split-path (:name segment) :separator separator) + rest-path (when (seq remaining-segments) (join-path remaining-segments :separator separator :with-spaces? false))] + (update acc first-segment (fnil conj []) + (if rest-path + (assoc segment :name rest-path) + segment)))) + {} + segments)) + +(defn- sort-and-group-segments + "Sorts elements and groups them by their first path segment." + [segments separator] + (let [sorted (sort-by-children segments separator) + grouped (group-by-first-segment sorted separator)] + grouped)) + +(defn- build-tree-node + "Builds a single tree node with lazy children." + [segment-name remaining-segments separator parent-path depth] + (let [current-path (if parent-path + (str parent-path "." segment-name) + segment-name) + + is-leaf? (and (seq remaining-segments) + (every? (fn [segment] + (let [remaining-segment-name (first (split-path (:name segment) :separator separator))] + (= segment-name remaining-segment-name))) + remaining-segments)) + + leaf-segment (when is-leaf? (first remaining-segments)) + node {:name segment-name + :path current-path + :depth depth + :leaf leaf-segment + :children-fn (when-not is-leaf? + (fn [] + (let [grouped-elements (sort-and-group-segments remaining-segments separator)] + (mapv (fn [[child-segment-name remaining-child-segments]] + (build-tree-node child-segment-name remaining-child-segments separator current-path (inc depth))) + grouped-elements))))}] + node)) + +(defn build-tree-root + "Builds the root level of the tree." + [segments separator] + (let [grouped-elements (sort-and-group-segments segments separator)] + (mapv (fn [[segment-name remaining-segments]] + (build-tree-node segment-name remaining-segments separator nil 0)) + grouped-elements))) diff --git a/frontend/playwright/ui/specs/tokens.spec.js b/frontend/playwright/ui/specs/tokens.spec.js index f8502b5ecb..3ad2dd2b67 100644 --- a/frontend/playwright/ui/specs/tokens.spec.js +++ b/frontend/playwright/ui/specs/tokens.spec.js @@ -40,6 +40,7 @@ const setupEmptyTokensFile = async (page, options = {}) => { tokensUpdateCreateModal: workspacePage.tokensUpdateCreateModal, tokenThemesSetsSidebar: workspacePage.tokenThemesSetsSidebar, tokenSetItems: workspacePage.tokenSetItems, + tokensSidebar: workspacePage.tokensSidebar, tokenSetGroupItems: workspacePage.tokenSetGroupItems, tokenContextMenuForSet: workspacePage.tokenContextMenuForSet, }; @@ -110,15 +111,12 @@ const checkInputFieldWithError = async ( ).toBeVisible(); }; -const checkInputFieldWithoutError = async ( - tokenThemeUpdateCreateModal, - inputLocator, -) => { +const checkInputFieldWithoutError = async (inputLocator) => { expect(await inputLocator.getAttribute("aria-invalid")).toBeNull(); expect(await inputLocator.getAttribute("aria-describedby")).toBeNull(); }; -async function testTokenCreationFlow( +const testTokenCreationFlow = async ( page, { tokenLabel, @@ -132,7 +130,7 @@ async function testTokenCreationFlow( resolvedValueText, secondResolvedValueText, }, -) { +) => { const invalidValueError = "Invalid token value"; const emptyNameError = "Name should be at least 1 character"; const selfReferenceError = "Token has self reference"; @@ -242,7 +240,45 @@ async function testTokenCreationFlow( await expect( tokensTabPanel.getByRole("button", { name: "my-token-2" }), ).toBeEnabled(); -} +}; + +const unfoldTokenTree = async (tokensTabPanel, type, tokenName) => { + const tokenSegments = tokenName.split("."); + const tokenFolderTree = tokenSegments.slice(0, -1); + const tokenLeafName = tokenSegments.pop(); + + const typeParentWrapper = tokensTabPanel.getByTestId(`section-${type}`); + const typeSectionButton = typeParentWrapper + .getByRole("button", { + name: type, + }) + .first(); + + const isSectionExpanded = + await typeSectionButton.getAttribute("aria-expanded"); + + if (isSectionExpanded === "false") { + await typeSectionButton.click(); + } + + for (const segment of tokenFolderTree) { + const segmentButton = typeParentWrapper + .getByRole("listitem") + .getByRole("button", { name: segment }) + .first(); + + const isExpanded = await segmentButton.getAttribute("aria-expanded"); + if (isExpanded === "false") { + await segmentButton.click(); + } + } + + await expect( + typeParentWrapper.getByRole("button", { + name: tokenLeafName, + }), + ).toBeEnabled(); +}; test.describe("Tokens: Tokens Tab", () => { test("Clicking tokens tab button opens tokens sidebar tab", async ({ @@ -398,15 +434,12 @@ test.describe("Tokens: Tokens Tab", () => { const emptyNameError = "Name should be at least 1 character"; const selfReferenceError = "Token has self reference"; const missingReferenceError = "Missing token references"; - const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = + const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } = await setupEmptyTokensFile(page); - const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); - const addTokenButton = tokensTabPanel.getByRole("button", { - name: `Add Token: Color`, - }); - - await addTokenButton.click(); + await tokensSidebar + .getByRole("button", { name: "Add Token: Color" }) + .click(); await expect(tokensUpdateCreateModal).toBeVisible(); // Placeholder checks @@ -471,38 +504,34 @@ test.describe("Tokens: Tokens Tab", () => { await expect(submitButton).toBeEnabled(); await submitButton.click(); - await expect( - tokensTabPanel.getByRole("button", { - name: "color.primary", - }), - ).toBeEnabled(); + await unfoldTokenTree(tokensSidebar, "color", "color.primary"); // Create token referencing the previous one with keyboard - await tokensTabPanel + await tokensSidebar .getByRole("button", { name: "Add Token: Color" }) .click(); await expect(tokensUpdateCreateModal).toBeVisible(); await nameField.click(); - await nameField.fill("color.secondary"); + await nameField.fill("secondary"); await nameField.press("Tab"); await valueField.click(); await valueField.fill("{color.primary}"); await expect(submitButton).toBeEnabled(); - await nameField.press("Enter"); + await submitButton.press("Enter"); await expect( - tokensTabPanel.getByRole("button", { - name: "color.secondary", + tokensSidebar.getByRole("button", { + name: "secondary", }), ).toBeEnabled(); // Tokens tab panel should have two tokens with the color red / #ff0000 await expect( - tokensTabPanel.getByRole("button", { name: "#ff0000" }), + tokensSidebar.getByRole("button", { name: "#ff0000" }), ).toHaveCount(2); // Global set has been auto created and is active @@ -518,7 +547,7 @@ test.describe("Tokens: Tokens Tab", () => { ).toHaveAttribute("aria-checked", "true"); // Check color picker - await tokensTabPanel + await tokensSidebar .getByRole("button", { name: "Add Token: Color" }) .click(); await expect(tokensUpdateCreateModal).toBeVisible(); @@ -1079,7 +1108,7 @@ test.describe("Tokens: Tokens Tab", () => { const emptyNameError = "Name should be at least 1 character"; const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = - await setupEmptyTokensFile(page, {flags: ["enable-token-shadow"]}); + await setupEmptyTokensFile(page, { flags: ["enable-token-shadow"] }); // Open modal const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); @@ -1507,24 +1536,15 @@ test.describe("Tokens: Tokens Tab", () => { test("User edits token and auto created set show up in the sidebar", async ({ page, }) => { - const { - workspacePage, - tokensUpdateCreateModal, - tokenThemesSetsSidebar, - tokensSidebar, - tokenContextMenuForToken, - } = await setupTokensFile(page); + const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFile(page); await expect(tokensSidebar).toBeVisible(); - const tokensColorGroup = tokensSidebar.getByRole("button", { - name: "Color 92", - }); - await expect(tokensColorGroup).toBeVisible(); - await tokensColorGroup.click(); + await unfoldTokenTree(tokensSidebar, "color", "colors.blue.100"); const colorToken = tokensSidebar.getByRole("button", { - name: "colors.blue.100", + name: "100", }); await expect(colorToken).toBeVisible(); await colorToken.click({ button: "right" }); @@ -1541,8 +1561,10 @@ test.describe("Tokens: Tokens Tab", () => { await expect(tokensUpdateCreateModal).not.toBeVisible(); + await unfoldTokenTree(tokensSidebar, "color", "colors.blue.100.changed"); + const colorTokenChanged = tokensSidebar.getByRole("button", { - name: "colors.blue.100.changed", + name: "changed", }); await expect(colorTokenChanged).toBeVisible(); }); @@ -1633,11 +1655,10 @@ test.describe("Tokens: Tokens Tab", () => { }); test("User creates grouped color token", async ({ page }) => { - const { workspacePage, tokensUpdateCreateModal, tokenThemesSetsSidebar } = + const { workspacePage, tokensUpdateCreateModal, tokensSidebar } = await setupEmptyTokensFile(page); - const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); - await tokensTabPanel + await tokensSidebar .getByRole("button", { name: "Add Token: Color" }) .click(); @@ -1649,7 +1670,7 @@ test.describe("Tokens: Tokens Tab", () => { const valueField = tokensUpdateCreateModal.getByLabel("Value"); await nameField.click(); - await nameField.fill("color.dark.primary"); + await nameField.fill("dark.primary"); await valueField.click(); await valueField.fill("red"); @@ -1660,7 +1681,9 @@ test.describe("Tokens: Tokens Tab", () => { await expect(submitButton).toBeEnabled(); await submitButton.click(); - await expect(tokensTabPanel.getByLabel("color.dark.primary")).toBeEnabled(); + await unfoldTokenTree(tokensSidebar, "color", "dark.primary"); + + await expect(tokensSidebar.getByLabel("primary")).toBeEnabled(); }); test("User cant create regular token with value missing", async ({ @@ -1676,7 +1699,6 @@ test.describe("Tokens: Tokens Tab", () => { await expect(tokensUpdateCreateModal).toBeVisible(); const nameField = tokensUpdateCreateModal.getByLabel("Name"); - const valueField = tokensUpdateCreateModal.getByLabel("Value"); const submitButton = tokensUpdateCreateModal.getByRole("button", { name: "Save", }); @@ -1686,7 +1708,7 @@ test.describe("Tokens: Tokens Tab", () => { // Fill in name but leave value empty await nameField.click(); - await nameField.fill("color.primary"); + await nameField.fill("primary"); // Submit button should remain disabled when value is empty await expect(submitButton).toBeDisabled(); @@ -1704,7 +1726,6 @@ test.describe("Tokens: Tokens Tab", () => { .click(); await expect(tokensUpdateCreateModal).toBeVisible(); - const nameField = tokensUpdateCreateModal.getByLabel("Name"); const valueField = tokensUpdateCreateModal.getByLabel("Value"); await valueField.click(); @@ -1754,15 +1775,10 @@ test.describe("Tokens: Tokens Tab", () => { await expect(tokensSidebar).toBeVisible(); - const tokensColorGroup = tokensSidebar.getByRole("button", { - name: "Color 92", - }); - - await expect(tokensColorGroup).toBeVisible(); - await tokensColorGroup.click(); + unfoldTokenTree(tokensSidebar, "color", "colors.blue.100"); const colorToken = tokensSidebar.getByRole("button", { - name: "colors.blue.100", + name: "100", }); await colorToken.click({ button: "right" }); @@ -1782,15 +1798,10 @@ test.describe("Tokens: Tokens Tab", () => { await expect(tokensSidebar).toBeVisible(); - const tokensColorGroup = tokensSidebar.getByRole("button", { - name: "Color 92", - }); - await expect(tokensColorGroup).toBeVisible(); - - await tokensColorGroup.click(); + unfoldTokenTree(tokensSidebar, "color", "colors.blue.100"); const colorToken = tokensSidebar.getByRole("button", { - name: "colors.blue.100", + name: "100", }); await expect(colorToken).toBeVisible(); await colorToken.click({ button: "right" }); @@ -1803,8 +1814,7 @@ test.describe("Tokens: Tokens Tab", () => { }); test("User fold/unfold color tokens", async ({ page }) => { - const { tokensSidebar, tokenContextMenuForToken } = - await setupTokensFile(page); + const { tokensSidebar } = await setupTokensFile(page); await expect(tokensSidebar).toBeVisible(); @@ -1814,8 +1824,10 @@ test.describe("Tokens: Tokens Tab", () => { await expect(tokensColorGroup).toBeVisible(); await tokensColorGroup.click(); + unfoldTokenTree(tokensSidebar, "color", "colors.blue.100"); + const colorToken = tokensSidebar.getByRole("button", { - name: "colors.blue.100", + name: "100", }); await expect(colorToken).toBeVisible(); await tokensColorGroup.click(); @@ -2218,13 +2230,10 @@ test.describe("Tokens: Apply token", () => { const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); await tokensTabButton.click(); - await tokensSidebar - .getByRole("button") - .filter({ hasText: "Color" }) - .click(); + unfoldTokenTree(tokensSidebar, "color", "colors.black"); await tokensSidebar - .getByRole("button", { name: "colors.black" }) + .getByRole("button", { name: "black" }) .click({ button: "right" }); await tokenContextMenuForToken.getByText("Fill").click(); @@ -2462,7 +2471,7 @@ test.describe("Tokens: Apply token", () => { await expect(tokensUpdateCreateModal).toBeVisible(); const nameField = tokensUpdateCreateModal.getByLabel("Name"); - await nameField.fill("shadow.primary"); + await nameField.fill("primary"); // User adds first shadow with a color from the color ramp const firstShadowFields = tokensUpdateCreateModal.getByTestId( @@ -2709,9 +2718,11 @@ test.describe("Tokens: Apply token", () => { await submitButton.click(); await expect(tokensUpdateCreateModal).not.toBeVisible(); + unfoldTokenTree(tokensSidebar, "shadow", "primary"); + // Verify token appears in sidebar const shadowToken = tokensSidebar.getByRole("button", { - name: "shadow.primary", + name: "primary", }); await expect(shadowToken).toBeEnabled(); diff --git a/frontend/src/app/main/ui/ds/layers/layer_button.cljs b/frontend/src/app/main/ui/ds/layers/layer_button.cljs new file mode 100644 index 0000000000..759952c30a --- /dev/null +++ b/frontend/src/app/main/ui/ds/layers/layer_button.cljs @@ -0,0 +1,49 @@ +;; 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.layers.layer-button + (:require-macros + [app.main.style :as stl]) + (:require + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] + [rumext.v2 :as mf])) + +(def ^:private schema:layer-button + [:map + [:label :string] + [:description {:optional true} [:maybe :string]] + [:class {:optional true} :string] + [:expandable {:optional true} :boolean] + [:expanded {:optional true} :boolean] + [:icon {:optional true} :string] + [:on-toggle-expand fn?]]) + +(mf/defc layer-button* + {::mf/schema schema:layer-button} + [{:keys [label description class is-expandable expanded icon on-toggle-expand children] :rest props}] + (let [button-props (mf/spread-props props + {:class [class (stl/css-case :layer-button true + :layer-button--expandable is-expandable + :layer-button--expanded expanded)] + :type "button" + :on-click on-toggle-expand})] + [:div {:class (stl/css :layer-button-wrapper)} + [:> "button" button-props + [:div {:class (stl/css :layer-button-content)} + (when is-expandable + (if expanded + [:> icon* {:icon-id i/arrow-down :class (stl/css :folder-node-icon)}] + [:> icon* {:icon-id i/arrow-right :class (stl/css :folder-node-icon)}])) + (when icon + [:> icon* {:icon-id icon :class (stl/css :layer-button-icon)}]) + [:span {:class (stl/css :layer-button-name)} + label] + (when description + [:span {:class (stl/css :layer-button-description)} + description]) + [:span {:class (stl/css :layer-button-quantity)}]]] + [:div {:class (stl/css :layer-button-actions)} + children]])) diff --git a/frontend/src/app/main/ui/ds/layers/layer_button.scss b/frontend/src/app/main/ui/ds/layers/layer_button.scss new file mode 100644 index 0000000000..56e59e8acf --- /dev/null +++ b/frontend/src/app/main/ui/ds/layers/layer_button.scss @@ -0,0 +1,56 @@ +// 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 "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/typography.scss" as *; +@use "ds/colors.scss" as *; + +.layer-button-wrapper { + --layer-button-block-size: #{$sz-32}; + --layer-button-background: var(--color-background-primary); + --layer-button-text: var(--color-foreground-secondary); + + display: flex; + justify-content: space-between; + + block-size: var(--layer-button-block-size); + + background: var(--layer-button-background); + color: var(--layer-button-text); +} + +.layer-button { + @include use-typography("body-small"); + + appearance: none; + + flex: 1; + display: flex; + align-items: center; + + border: none; + background: none; + color: inherit; +} + +.layer-button--expanded { + & .layer-button-name { + color: var(--color-foreground-primary); + } +} + +.layer-button-content { + display: flex; + align-items: center; + gap: var(--sp-xs); +} + +.layer-button-description { + padding: var(--sp-xs); + background-color: var(--color-background-tertiary); + border-radius: $br-6; +} diff --git a/frontend/src/app/main/ui/workspace/tokens/management.cljs b/frontend/src/app/main/ui/workspace/tokens/management.cljs index 98bbb080b5..846c112bdb 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management.cljs @@ -44,6 +44,39 @@ [(seq (array/sort! empty)) (seq (array/sort! filled))])))) +(mf/defc selected-set-info* + {::mf/private true} + [{:keys [tokens-lib selected-token-set-id]}] + (let [selected-token-set + (mf/with-memo [tokens-lib] + (when selected-token-set-id + (some-> tokens-lib (ctob/get-set selected-token-set-id)))) + + active-token-sets-names + (mf/with-memo [tokens-lib] + (some-> tokens-lib (ctob/get-active-themes-set-names))) + + token-set-active? + (mf/use-fn + (mf/deps active-token-sets-names) + (fn [name] + (contains? active-token-sets-names name)))] + [:div {:class (stl/css :sets-header-container)} + [:> text* {:as "span" + :typography "headline-small" + :class (stl/css :sets-header)} + (tr "workspace.tokens.tokens-section-title" (ctob/get-name selected-token-set))] + [:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")} + ;; NOTE: when no set in tokens-lib, the selected-token-set-id + ;; will be `nil`, so for properly hide the inactive message we + ;; check that at least `selected-token-set-id` has a value + (when (and (some? selected-token-set-id) + (not (token-set-active? (ctob/get-name selected-token-set)))) + [:* + [:> icon* {:class (stl/css :sets-header-status-icon) :icon-id i/eye-off}] + [:> text* {:as "span" :typography "body-small" :class (stl/css :sets-header-status-text)} + (tr "workspace.tokens.inactive-set")]])]])) + (mf/defc tokens-section* {::mf/private true} [{:keys [tokens-lib active-tokens resolved-active-tokens]}] @@ -65,9 +98,7 @@ selected-token-set-id (mf/deref refs/selected-token-set-id) - selected-token-set - (when selected-token-set-id - (some-> tokens-lib (ctob/get-set selected-token-set-id))) + ;; If we have not selected any set explicitly we just ;; select the first one from the list of sets @@ -92,15 +123,9 @@ tokens)] (ctob/group-by-type tokens))) - active-token-sets-names - (mf/with-memo [tokens-lib] - (some-> tokens-lib (ctob/get-active-themes-set-names))) - token-set-active? - (mf/use-fn - (mf/deps active-token-sets-names) - (fn [name] - (contains? active-token-sets-names name))) + + [empty-group filled-group] (mf/with-memo [tokens-by-type] @@ -118,34 +143,27 @@ [:* [:& token-context-menu] - [:div {:class (stl/css :sets-header-container)} - [:> text* {:as "span" :typography "headline-small" :class (stl/css :sets-header)} (tr "workspace.tokens.tokens-section-title" (ctob/get-name selected-token-set))] - [:div {:class (stl/css :sets-header-status) :title (tr "workspace.tokens.inactive-set-description")} - ;; NOTE: when no set in tokens-lib, the selected-token-set-id - ;; will be `nil`, so for properly hide the inactive message we - ;; check that at least `selected-token-set-id` has a value - (when (and (some? selected-token-set-id) - (not (token-set-active? (ctob/get-name selected-token-set)))) - [:* - [:> icon* {:class (stl/css :sets-header-status-icon) :icon-id i/eye-off}] - [:> text* {:as "span" :typography "body-small" :class (stl/css :sets-header-status-text)} - (tr "workspace.tokens.inactive-set")]])]] + + [:& selected-set-info* {:tokens-lib tokens-lib + :selected-token-set-id selected-token-set-id}] (for [type filled-group] (let [tokens (get tokens-by-type type)] [:> token-group* {:key (name type) - :is-open (get open-status type false) + :tokens tokens + :is-expanded (get open-status type false) :type type :selected-ids selected :selected-shapes selected-shapes :is-selected-inside-layout is-selected-inside-layout :active-theme-tokens resolved-active-tokens - :tokens tokens}])) + :tokens-lib tokens-lib + :selected-token-set-id selected-token-set-id}])) (for [type empty-group] [:> token-group* {:key (name type) + :tokens [] :type type :selected-shapes selected-shapes - :is-selected-inside-layout :is-selected-inside-layout - :active-theme-tokens resolved-active-tokens - :tokens []}])])) + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens resolved-active-tokens}])])) 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 8dd73d5fce..0d038a2324 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs @@ -8,6 +8,9 @@ (ns app.main.ui.workspace.tokens.management.group (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.types.tokens-lib :as ctob] [app.main.data.modal :as modal] [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.tokens.library-edit :as dwtl] @@ -16,51 +19,70 @@ [app.main.ui.context :as ctx] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] - [app.main.ui.workspace.sidebar.assets.common :as cmm] - [app.main.ui.workspace.tokens.management.token-pill :refer [token-pill*]] + [app.main.ui.ds.layers.layer-button :refer [layer-button*]] + [app.main.ui.workspace.tokens.management.token-tree :refer [token-tree*]] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [rumext.v2 :as mf])) + (defn token-section-icon [type] (case type - :border-radius "corner-radius" - :color "drop" - :boolean "boolean-difference" - :font-family "text-font-family" - :font-size "text-font-size" - :letter-spacing "text-letterspacing" - :text-case "text-mixed" - :text-decoration "text-underlined" - :font-weight "text-font-weight" - :typography "text-typography" - :opacity "percentage" - :number "number" - :rotation "rotation" - :spacing "padding-extended" - :string "text-mixed" - :stroke-width "stroke-size" - :dimensions "expand" - :sizing "expand" - :shadow "drop-shadow" + :border-radius i/corner-radius + :color i/drop + :boolean i/boolean-difference + :font-family i/text-font-family + :font-size i/text-font-size + :letter-spacing i/text-letterspacing + :text-case i/text-mixed + :text-decoration i/text-underlined + :font-weight i/text-font-weight + :typography i/text-typography + :opacity i/percentage + :number i/number + :rotation i/rotation + :spacing i/padding-extended + :string i/text-mixed + :stroke-width i/stroke-size + :dimensions i/expand + :sizing i/expand + :shadow i/drop-shadow "add")) +(def ^:private schema:token-group + [:map + [:type :keyword] + [:tokens :any] + [:selected-shapes :any] + [:is-selected-inside-layout {:optional true} [:maybe :boolean]] + [:active-theme-tokens {:optional true} :any] + [:selected-token-set-id {:optional true} :any] + [:tokens-lib {:optional true} :any] + [:on-token-pill-click {:optional true} fn?] + [:on-context-menu {:optional true} fn?]]) + (mf/defc token-group* - {::mf/private true} - [{:keys [type tokens selected-shapes is-selected-inside-layout active-theme-tokens is-open selected-ids]}] + {::mf/schema schema:token-group} + [{:keys [type tokens selected-shapes is-selected-inside-layout active-theme-tokens selected-token-set-id tokens-lib is-expanded selected-ids]}] (let [{:keys [modal title]} (get dwta/token-properties type) editing-ref (mf/deref refs/workspace-editor-state) not-editing? (empty? editing-ref) + is-expanded (d/nilv is-expanded false) + can-edit? (mf/use-ctx ctx/can-edit?) + is-selected-inside-layout (d/nilv is-selected-inside-layout false) + tokens (mf/with-memo [tokens] (vec (sort-by :name tokens))) + expandable? (d/nilv (seq tokens) false) + on-context-menu (mf/use-fn (fn [event token] @@ -73,8 +95,8 @@ on-toggle-open-click (mf/use-fn - (mf/deps is-open type) - #(st/emit! (dwtl/set-token-type-section-open type (not is-open)))) + (mf/deps is-expanded type) + #(st/emit! (dwtl/set-token-type-section-open type (not is-expanded)))) on-popover-open-click (mf/use-fn @@ -96,33 +118,36 @@ (mf/use-fn (mf/deps not-editing? selected-ids) (fn [event token] - (dom/stop-propagation event) - (when (and not-editing? (seq selected-shapes) (not= (:type token) :number)) - (st/emit! (dwta/toggle-token {:token token - :shape-ids selected-ids})))))] + (let [token (ctob/get-token tokens-lib selected-token-set-id (:id token))] + (dom/stop-propagation event) + (when (and not-editing? (seq selected-shapes) (not= (:type token) :number)) + (st/emit! (dwta/toggle-token {:token token + :shape-ids selected-ids}))))))] - [:div {:on-click on-toggle-open-click :class (stl/css :token-section-wrapper)} - [:> cmm/asset-section* {:icon (token-section-icon type) - :title title - :section :tokens - :assets-count (count tokens) - :is-open is-open} - [:> cmm/asset-section-block* {:role :title-button} - (when can-edit? - [:> icon-button* {:on-click on-popover-open-click - :variant "ghost" - :icon i/add - :id (str "add-token-button-" title) - :aria-label (tr "workspace.tokens.add-token" title)}])] - (when is-open - [:> cmm/asset-section-block* {:role :content} - [:div {:class (stl/css :token-pills-wrapper)} - (for [token tokens] - [:> token-pill* - {:key (:name token) - :token token - :selected-shapes selected-shapes - :is-selected-inside-layout is-selected-inside-layout - :active-theme-tokens active-theme-tokens - :on-click on-token-pill-click - :on-context-menu on-context-menu}])]])]])) + [:div {:class (stl/css :token-section-wrapper) + :data-testid (dm/str "section-" (name type))} + [:> layer-button* {:label title + :expanded is-expanded + :description (when expandable? (dm/str (count tokens))) + :is-expandable expandable? + :aria-expanded is-expanded + :aria-controls (dm/str "token-tree-" (name type)) + :on-toggle-expand on-toggle-open-click + :icon (token-section-icon type)} + (when can-edit? + [:> icon-button* {:id (str "add-token-button-" title) + :icon "add" + :aria-label (tr "workspace.tokens.add-token" title) + :variant "ghost" + :on-click on-popover-open-click + :class (stl/css :token-section-icon)}])] + (when is-expanded + [:> token-tree* {:tokens tokens + :id (dm/str "token-tree-" (name type)) + :tokens-lib tokens-lib + :selected-shapes selected-shapes + :active-theme-tokens active-theme-tokens + :selected-token-set-id selected-token-set-id + :is-selected-inside-layout is-selected-inside-layout + :on-token-pill-click on-token-pill-click + :on-context-menu on-context-menu}])])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/group.scss b/frontend/src/app/main/ui/workspace/tokens/management/group.scss deleted file mode 100644 index e46bfb846f..0000000000 --- a/frontend/src/app/main/ui/workspace/tokens/management/group.scss +++ /dev/null @@ -1,11 +0,0 @@ -// 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 - -.token-pills-wrapper { - display: flex; - gap: var(--sp-xs); - flex-wrap: wrap; -} 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 dd001e187c..bfb1a1f0a3 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 @@ -307,10 +307,9 @@ :class (stl/css :token-pill-icon)}]) (if contains-path? - (let [[first-part last-part] (cpn/split-by-last-period name)] + (let [[_ last-part] (cpn/split-by-last-period name)] [:span {:class (stl/css :divided-name-wrapper) :aria-label name} - [:span {:class (stl/css :first-name-wrapper)} first-part] [:span {:class (stl/css :last-name-wrapper)} last-part]]) [:span {:class (stl/css :name-wrapper) :aria-label name} 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 new file mode 100644 index 0000000000..5a31dbcd54 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs @@ -0,0 +1,110 @@ +;; 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.workspace.tokens.management.token-tree + (:require-macros [app.main.style :as stl]) + (:require + [app.common.path-names :as cpn] + [app.common.types.tokens-lib :as ctob] + [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])) + +(def ^:private schema:folder-node + [:map + [:node :any] + [:selected-shapes :any] + [:is-selected-inside-layout {:optional true} :boolean] + [:active-theme-tokens {:optional true} :any] + [:selected-token-set-id {:optional true} :any] + [:tokens-lib {:optional true} :any] + [:on-token-pill-click {:optional true} fn?] + [:on-context-menu {:optional true} fn?]]) + +(mf/defc folder-node* + {::mf/schema schema:folder-node} + [{:keys [node selected-shapes is-selected-inside-layout active-theme-tokens selected-token-set-id tokens-lib on-token-pill-click on-context-menu]}] + (let [expanded* (mf/use-state false) + expanded (deref expanded*) + swap-folder-expanded #(swap! expanded* not)] + [:li {:class (stl/css :folder-node)} + [:> layer-button* {:label (:name node) + :expanded expanded + :aria-expanded expanded + :aria-controls (str "folder-children-" (:path node)) + :is-expandable (not (:leaf node)) + :on-toggle-expand swap-folder-expanded}] + (when expanded + (let [children-fn (:children-fn node)] + [:div {:class (stl/css :folder-children-wrapper) + :id (str "folder-children-" (:path node))} + (when children-fn + (let [children (children-fn)] + (for [child children] + (if (not (:leaf child)) + [:ul {:class (stl/css :node-parent)} + [:> folder-node* {:key (:path child) + :node child + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :on-token-pill-click on-token-pill-click + :on-context-menu on-context-menu + :tokens-lib tokens-lib + :selected-token-set-id selected-token-set-id}]] + (let [id (:id (:leaf child)) + token (ctob/get-token tokens-lib selected-token-set-id id)] + [:> token-pill* + {:key id + :token token + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :on-click on-token-pill-click + :on-context-menu on-context-menu}])))))]))])) + +(def ^:private schema:token-tree + [:map + [:tokens :any] + [:selected-shapes :any] + [:is-selected-inside-layout {:optional true} :boolean] + [:active-theme-tokens {:optional true} :any] + [:selected-token-set-id {:optional true} :any] + [:tokens-lib {:optional true} :any] + [:on-token-pill-click {:optional true} fn?] + [:on-context-menu {:optional true} fn?]]) + +(mf/defc token-tree* + {::mf/schema schema:token-tree} + [{:keys [tokens selected-shapes is-selected-inside-layout active-theme-tokens tokens-lib selected-token-set-id on-token-pill-click on-context-menu]}] + (let [separator "." + tree (mf/use-memo + (mf/deps tokens) + (fn [] + (cpn/build-tree-root tokens separator)))] + [:div {:class (stl/css :token-tree-wrapper)} + (for [node tree] + [:ul {:class (stl/css :node-parent) + :key (:path node) + :style {:--node-depth (inc (:depth node))}} + (if (:leaf node) + (let [token (ctob/get-token tokens-lib selected-token-set-id (get-in node [:leaf :id]))] + [:> token-pill* + {:token token + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :on-click on-token-pill-click + :on-context-menu on-context-menu}]) + ;; Render segment folder + [:> folder-node* {:node node + :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout + :active-theme-tokens active-theme-tokens + :on-token-pill-click on-token-pill-click + :on-context-menu on-context-menu + :tokens-lib tokens-lib + :selected-token-set-id selected-token-set-id}])])])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss new file mode 100644 index 0000000000..3320379d04 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss @@ -0,0 +1,39 @@ +// 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 "ds/_borders.scss" as *; + +.token-tree-wrapper { + padding-block-end: var(--sp-s); +} + +.node-parent { + --node-spacing: var(--sp-l); + --node-depth: 0; + + margin-block-end: 0; + padding-inline-start: calc(var(--node-spacing) * var(--node-depth)); +} + +.folder-children-wrapper:has(> button) { + margin-inline-start: var(--sp-s); + padding-inline-start: var(--sp-s); + border-inline-start: $b-2 solid var(--color-background-quaternary); + display: flex; + flex-wrap: wrap; + column-gap: var(--sp-xs); + + & .node-parent { + flex: 1 0 100%; + + &:last-of-type { + margin-block-end: var(--sp-s); + } + } + & .token-pill { + flex: 0 0 auto; + } +}