💄 Group tokens by name path (#7775)
Some checks failed
_STAGING / build-bundle (push) Has been cancelled
_STAGING / build-docker (push) Has been cancelled
_NITRATE MODULE / build-bundle (push) Has been cancelled
_NITRATE MODULE / build-docker (push) Has been cancelled
_DEVELOP / build-bundle (push) Has been cancelled
_DEVELOP / build-docker (push) Has been cancelled
Commit Message Check / Check Commit Message (push) Has been cancelled
CI / Linter (push) Has been cancelled
CI / Common Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / Render WASM Tests (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Library Tests (push) Has been cancelled
CI / Build Integration Bundle (push) Has been cancelled
CI / Integration Tests 1/4 (push) Has been cancelled
CI / Integration Tests 2/4 (push) Has been cancelled
CI / Integration Tests 3/4 (push) Has been cancelled
CI / Integration Tests 4/4 (push) Has been cancelled

* 💄 Group tokens by name path
This commit is contained in:
Xaviju
2025-12-10 12:34:19 +01:00
committed by GitHub
parent 007b3f11f9
commit 0956b66281
10 changed files with 555 additions and 168 deletions

View File

@@ -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)))

View File

@@ -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();

View File

@@ -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]]))

View File

@@ -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;
}

View File

@@ -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}])]))

View File

@@ -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}])]))

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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}])])]))

View File

@@ -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;
}
}