diff --git a/common/src/app/common/types/plugins.cljc b/common/src/app/common/types/plugins.cljc index b582da35831..97c78edbd8c 100644 --- a/common/src/app/common/types/plugins.cljc +++ b/common/src/app/common/types/plugins.cljc @@ -6,6 +6,7 @@ (ns app.common.types.plugins (:require + [app.common.schema :as sm] [app.common.schema.generators :as sg])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -37,6 +38,8 @@ [:icon {:optional true} :string] [:permissions [:set :string]]]) +(sm/register! ::registry-entry schema:registry-entry) + (def schema:plugin-registry [:map [:ids [:vector :string]] diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index fc2eed736fd..2ccace505f0 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -52,7 +52,11 @@ :typography "typography"}) (def dtcg-token-type->token-type - (set/map-invert token-type->dtcg-token-type)) + (-> (set/map-invert token-type->dtcg-token-type) + ;; Backwards compability + (assoc "fontWeight" :font-weight + "fontSize" :font-size + "fontFamily" :font-family))) (def token-types (into #{} (keys token-type->dtcg-token-type))) @@ -441,3 +445,8 @@ (when (font-weight-values weight) (cond-> {:weight weight} italic? (assoc :style "italic"))))) + +(defn typography-composite-token-reference? + "Predicate if a typography composite token is a reference value - a string pointing to another reference token." + [token-value] + (string? token-value)) diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 7e098e5cab9..72bd4da34d8 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -1501,6 +1501,29 @@ Will return a value that matches this schema: (and (not (contains? decoded-json "$metadata")) (not (contains? decoded-json "$themes")))) +(defn- transform-font-family-value-dtcg->internal + "Transform font family token value from DTCG format to internal format. + - If value is a string, split it into an array of font families + - If value is already an array, keep it as is + - Otherwise return as is" + [value] + (cond + (string? value) (cto/split-font-family value) + ;; Keep array / collection + (sequential? value) value + :else value)) + +(defn- transform-typography-value-keys-dtcg->internal + "Transform typography token value keys from DTCG format to internal format." + [value] + (if (map? value) + (-> value + (set/rename-keys cto/dtcg-token-type->token-type) + (select-keys cto/typography-keys) + ;; Transform font-family values within typography composite tokens + (d/update-when :font-family transform-font-family-value-dtcg->internal)) + value)) + (defn- flatten-nested-tokens-json "Convert a tokens tree in the decoded json fragment into a flat map, being the keys the token paths after joining the keys with '.'." @@ -1518,16 +1541,12 @@ Will return a value that matches this schema: (assoc tokens child-path (make-token :name child-path :type token-type - :value (cond-> (get v "$value") - ;; Split string of font-families - (and (= :font-family token-type) - (string? (get v "$value"))) - cto/split-font-family - - ;; Keep array of font-families - (and (= :font-family token-type) - (sequential? (get v "$value"))) - identity) + :value + (let [token-value (get v "$value")] + (case token-type + :font-family (transform-font-family-value-dtcg->internal token-value) + :typography (transform-typography-value-keys-dtcg->internal token-value) + token-value)) :description (get v "$description"))) ;; Discard unknown type tokens tokens))))) @@ -1680,8 +1699,31 @@ Will return a value that matches this schema: :else (parse-multi-set-dtcg-json decoded-json)))) +(defn- transform-typography-value-keys-internal->dtcg + "Transform typography token value keys from internal format to DTCG format." + [value] + (if (map? value) + (->> value + (reduce-kv (fn [acc internal-key v] + (let [dtcg-key (case internal-key + :font-weight "fontWeight" + :font-size "fontSize" + :letter-spacing "letterSpacing" + :font-family "fontFamilies" + :text-case "textCase" + :text-decoration "textDecoration" + nil)] + (if dtcg-key + (assoc acc dtcg-key v) + acc))) + {})) + value)) + (defn- token->dtcg-token [token] - (cond-> {"$value" (:value token) + (cond-> {"$value" (cond-> (:value token) + ;; Transform typography token values + (= :typography (:type token)) + transform-typography-value-keys-internal->dtcg) "$type" (cto/token-type->dtcg-token-type (:type token))} (:description token) (assoc "$description" (:description token)))) diff --git a/common/test/common_tests/types/data/tokens-font-family-example.json b/common/test/common_tests/types/data/tokens-font-family-example.json new file mode 100644 index 00000000000..b4d23dec2cc --- /dev/null +++ b/common/test/common_tests/types/data/tokens-font-family-example.json @@ -0,0 +1,26 @@ +{ + "fonts": { + "string-font-family": { + "$value": "Arial, Helvetica, sans-serif", + "$type": "fontFamilies", + "$description": "A font family defined as a string" + }, + "array-font-family": { + "$value": ["Inter", "system-ui", "sans-serif"], + "$type": "fontFamilies", + "$description": "A font family defined as an array" + }, + "single-font-family": { + "$value": "Georgia", + "$type": "fontFamilies" + }, + "complex-font-family": { + "$value": "Times New Roman, serif", + "$type": "fontFamilies" + }, + "font-with-spaces": { + "$value": "Source Sans Pro, Arial, sans-serif", + "$type": "fontFamilies" + } + } +} \ No newline at end of file diff --git a/common/test/common_tests/types/data/tokens-typography-example.json b/common/test/common_tests/types/data/tokens-typography-example.json new file mode 100644 index 00000000000..614c7cf1e4c --- /dev/null +++ b/common/test/common_tests/types/data/tokens-typography-example.json @@ -0,0 +1,52 @@ +{ + "test": { + "typo": { + "$value": { + "fontWeight": "100", + "fontSize": "16px", + "letterSpacing": "0.1em" + }, + "$type": "typography" + }, + "typo2": { + "$value": "{typo}", + "$type": "typography" + }, + "font-weight": { + "$value": "200", + "$type": "fontWeights" + }, + "typo-to-single": { + "$value": "{font-weight}", + "$type": "typography" + }, + "test-empty": { + "$value": {}, + "$type": "typography" + }, + "font-size": { + "$value": "18px", + "$type": "fontSizes" + }, + "typo-complex": { + "$value": { + "fontWeight": "bold", + "fontSize": "24px", + "letterSpacing": "0.05em", + "fontFamilies": ["Arial", "sans-serif"], + "textCase": "uppercase" + }, + "$type": "typography", + "$description": "A complex typography token" + }, + "typo-with-string-font-family": { + "$value": { + "fontWeight": "600", + "fontSize": "20px", + "fontFamilies": "Roboto, Helvetica, sans-serif" + }, + "$type": "typography", + "$description": "Typography token with string font family" + } + } +} \ No newline at end of file diff --git a/common/test/common_tests/types/tokens_lib_test.cljc b/common/test/common_tests/types/tokens_lib_test.cljc index cd7fab3a655..1ebe4f0f22f 100644 --- a/common/test/common_tests/types/tokens_lib_test.cljc +++ b/common/test/common_tests/types/tokens_lib_test.cljc @@ -1580,3 +1580,254 @@ "$type" "color" "$description" ""}}}}}] (t/is (= expected result))))) + +#?(:clj + (t/deftest parse-typography-tokens + (let [json (-> (slurp "test/common_tests/types/data/tokens-typography-example.json") + (json/decode {:key-fn identity})) + lib (ctob/parse-decoded-json json "typography-test") + set (ctob/get-set lib "typography-test")] + + (t/testing "typography token with composite value" + (let [token (ctob/get-token-by-name lib "typography-test" "test.typo")] + (t/is (some? token)) + (t/is (= (:type token) :typography)) + (t/is (= (:value token) {:font-weight "100" + :font-size "16px" + :letter-spacing "0.1em"})) + (t/is (= (:description token) "")))) + + (t/testing "typography token with string reference" + (let [token (ctob/get-token-by-name lib "typography-test" "test.typo2")] + (t/is (some? token)) + (t/is (= (:type token) :typography)) + (t/is (= (:value token) "{typo}")) + (t/is (= (:description token) "")))) + + (t/testing "typography token referencing single token" + (let [token (ctob/get-token-by-name lib "typography-test" "test.typo-to-single")] + (t/is (some? token)) + (t/is (= (:type token) :typography)) + (t/is (= (:value token) "{font-weight}")) + (t/is (= (:description token) "")))) + + (t/testing "typography token with empty value" + (let [token (ctob/get-token-by-name lib "typography-test" "test.test-empty")] + (t/is (some? token)) + (t/is (= (:type token) :typography)) + (t/is (= (:value token) {})) + (t/is (= (:description token) "")))) + + (t/testing "typography token with complex value and description" + (let [token (ctob/get-token-by-name lib "typography-test" "test.typo-complex")] + (t/is (some? token)) + (t/is (= (:type token) :typography)) + (t/is (= (:value token) {:font-weight "bold" + :font-size "24px" + :letter-spacing "0.05em" + :font-family ["Arial", "sans-serif"] + :text-case "uppercase"})) + (t/is (= (:description token) "A complex typography token")))) + + (t/testing "individual font tokens still work" + (let [font-weight-token (ctob/get-token-by-name lib "typography-test" "test.font-weight") + font-size-token (ctob/get-token-by-name lib "typography-test" "test.font-size")] + (t/is (some? font-weight-token)) + (t/is (= (:type font-weight-token) :font-weight)) + (t/is (= (:value font-weight-token) "200")) + + (t/is (some? font-size-token)) + (t/is (= (:type font-size-token) :font-size)) + (t/is (= (:value font-size-token) "18px")))) + + (t/testing "typography token with string font family gets transformed to array" + (let [token (ctob/get-token-by-name lib "typography-test" "test.typo-with-string-font-family")] + (t/is (some? token)) + (t/is (= (:type token) :typography)) + (t/is (= (:value token) {:font-weight "600" + :font-size "20px" + :font-family ["Roboto" "Helvetica" "sans-serif"]})) + (t/is (= (:description token) "Typography token with string font family"))))))) + +#?(:clj + (t/deftest export-typography-tokens + (let [tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set + :name "typography-set" + :tokens {"typo.composite" + (ctob/make-token + {:name "typo.composite" + :type :typography + :value {:font-weight "bold" + :font-size "16px" + :letter-spacing "0.1em"} + :description "A composite typography token"}) + "typo.reference" + (ctob/make-token + {:name "typo.reference" + :type :typography + :value "{other-token}"}) + "typo.empty" + (ctob/make-token + {:name "typo.empty" + :type :typography + :value {}})}))) + result (ctob/export-dtcg-json tokens-lib) + typography-set (get result "typography-set")] + + (t/testing "composite typography token export" + (let [composite-token (get-in typography-set ["typo" "composite"])] + (t/is (= (get composite-token "$type") "typography")) + (t/is (= (get composite-token "$value") {"fontWeight" "bold" + "fontSize" "16px" + "letterSpacing" "0.1em"})) + (t/is (= (get composite-token "$description") "A composite typography token")))) + + (t/testing "reference typography token export" + (let [reference-token (get-in typography-set ["typo" "reference"])] + (t/is (= (get reference-token "$type") "typography")) + (t/is (= (get reference-token "$value") "{other-token}")) + (t/is (= (get reference-token "$description") "")))) + + (t/testing "empty typography token export" + (let [empty-token (get-in typography-set ["typo" "empty"])] + (t/is (= (get empty-token "$type") "typography")) + (t/is (= (get empty-token "$value") {})) + (t/is (= (get empty-token "$description") ""))))))) + +#?(:clj + (t/deftest typography-token-round-trip + (let [original-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set + :name "test-set" + :tokens {"typo.test" + (ctob/make-token + {:name "typo.test" + :type :typography + :value {:font-weight "700" + :font-size "20px" + :letter-spacing "0.05em" + :font-family ["Helvetica", "sans-serif"]} + :description "Round trip test"}) + "typo.ref" + (ctob/make-token + {:name "typo.ref" + :type :typography + :value "{typo.test}"})}))) + ;; Export to JSON format + exported (ctob/export-dtcg-json original-lib) + ;; Import back + imported-lib (ctob/parse-decoded-json exported "")] + + (t/testing "round trip preserves typography tokens" + (let [original-token (ctob/get-token-by-name original-lib "test-set" "typo.test") + imported-token (ctob/get-token-by-name imported-lib "test-set" "typo.test")] + (t/is (some? imported-token)) + (t/is (= (:type imported-token) (:type original-token))) + (t/is (= (:value imported-token) (:value original-token))) + (t/is (= (:description imported-token) (:description original-token)))) + + (let [original-ref (ctob/get-token-by-name original-lib "test-set" "typo.ref") + imported-ref (ctob/get-token-by-name imported-lib "test-set" "typo.ref")] + (t/is (some? imported-ref)) + (t/is (= (:type imported-ref) (:type original-ref))) + (t/is (= (:value imported-ref) (:value original-ref)))))))) + +#?(:clj + (t/deftest parse-font-family-tokens + (let [json (-> (slurp "test/common_tests/types/data/tokens-font-family-example.json") + (json/decode {:key-fn identity})) + lib (ctob/parse-decoded-json json "font-family-test")] + + (t/testing "string font family token gets split into array" + (let [token (ctob/get-token-by-name lib "font-family-test" "fonts.string-font-family")] + (t/is (some? token)) + (t/is (= (:type token) :font-family)) + (t/is (= (:value token) ["Arial" "Helvetica" "sans-serif"])) + (t/is (= (:description token) "A font family defined as a string")))) + + (t/testing "array font family token stays as array" + (let [token (ctob/get-token-by-name lib "font-family-test" "fonts.array-font-family")] + (t/is (some? token)) + (t/is (= (:type token) :font-family)) + (t/is (= (:value token) ["Inter" "system-ui" "sans-serif"])) + (t/is (= (:description token) "A font family defined as an array")))) + + (t/testing "single font family string gets converted to array" + (let [token (ctob/get-token-by-name lib "font-family-test" "fonts.single-font-family")] + (t/is (some? token)) + (t/is (= (:type token) :font-family)) + (t/is (= (:value token) ["Georgia"])) + (t/is (= (:description token) "")))) + + (t/testing "complex font names with spaces handled correctly" + (let [token (ctob/get-token-by-name lib "font-family-test" "fonts.font-with-spaces")] + (t/is (some? token)) + (t/is (= (:type token) :font-family)) + (t/is (= (:value token) ["Source Sans Pro" "Arial" "sans-serif"]))))))) + +#?(:clj + (t/deftest export-font-family-tokens + (let [tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set + :name "font-family-set" + :tokens {"fonts.array-family" + (ctob/make-token + {:name "fonts.array-family" + :type :font-family + :value ["Roboto" "sans-serif"] + :description "An array font family token"}) + "fonts.single-family" + (ctob/make-token + {:name "fonts.single-family" + :type :font-family + :value ["Georgia"]})}))) + result (ctob/export-dtcg-json tokens-lib) + font-family-set (get result "font-family-set")] + + (t/testing "array font family token export" + (let [array-token (get-in font-family-set ["fonts" "array-family"])] + (t/is (= (get array-token "$type") "fontFamilies")) + (t/is (= (get array-token "$value") ["Roboto" "sans-serif"])) + (t/is (= (get array-token "$description") "An array font family token")))) + + (t/testing "single font family token export" + (let [single-token (get-in font-family-set ["fonts" "single-family"])] + (t/is (= (get single-token "$type") "fontFamilies")) + (t/is (= (get single-token "$value") ["Georgia"])) + (t/is (= (get single-token "$description") ""))))))) + +#?(:clj + (t/deftest font-family-token-round-trip + (let [original-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set + :name "test-set" + :tokens {"fonts.test-array" + (ctob/make-token + {:name "fonts.test-array" + :type :font-family + :value ["Arial" "Helvetica" "sans-serif"] + :description "Round trip test"}) + "fonts.test-single" + (ctob/make-token + {:name "fonts.test-single" + :type :font-family + :value ["Times New Roman"]})}))) + ;; Export to JSON format + exported (ctob/export-dtcg-json original-lib) + ;; Import back + imported-lib (ctob/parse-decoded-json exported "")] + + (t/testing "round trip preserves font family tokens" + (let [original-token (ctob/get-token-by-name original-lib "test-set" "fonts.test-array") + imported-token (ctob/get-token-by-name imported-lib "test-set" "fonts.test-array")] + (t/is (some? imported-token)) + (t/is (= (:type imported-token) (:type original-token))) + (t/is (= (:value imported-token) (:value original-token))) + (t/is (= (:description imported-token) (:description original-token)))) + + (let [original-single (ctob/get-token-by-name original-lib "test-set" "fonts.test-single") + imported-single (ctob/get-token-by-name imported-lib "test-set" "fonts.test-single")] + (t/is (some? imported-single)) + (t/is (= (:type imported-single) (:type original-single))) + (t/is (= (:value imported-single) (:value original-single)))))))) diff --git a/frontend/playwright/ui/specs/tokens.spec.js b/frontend/playwright/ui/specs/tokens.spec.js index e1d707268f4..08f3b3f5a9f 100644 --- a/frontend/playwright/ui/specs/tokens.spec.js +++ b/frontend/playwright/ui/specs/tokens.spec.js @@ -972,10 +972,141 @@ test.describe("Tokens: Themes modal", () => { await fontSizeField.fill(""); await expect(saveButton).toBeEnabled(); + // Fill in values for all fields and verify they persist when switching tabs + await fontSizeField.fill("16"); + + const fontFamilyField = tokensUpdateCreateModal + .getByLabel(/Font Family/i) + .first(); + const fontWeightField = + tokensUpdateCreateModal.getByLabel(/Font Weight/i); + const letterSpacingField = + tokensUpdateCreateModal.getByLabel(/Letter Spacing/i); + const textCaseField = tokensUpdateCreateModal.getByLabel(/Text Case/i); + const textDecorationField = + tokensUpdateCreateModal.getByLabel(/Text Decoration/i); + + // Capture all values before switching tabs + const originalValues = { + fontSize: await fontSizeField.inputValue(), + fontFamily: await fontFamilyField.inputValue(), + fontWeight: await fontWeightField.inputValue(), + letterSpacing: await letterSpacingField.inputValue(), + textCase: await textCaseField.inputValue(), + textDecoration: await textDecorationField.inputValue(), + }; + + // Switch to reference tab and back to composite tab + const referenceTabButton = tokensUpdateCreateModal.getByRole("button", { + name: "Reference", + }); + await referenceTabButton.click(); + const compositeTabButton = tokensUpdateCreateModal.getByRole("button", { + name: "Composite", + }); + await compositeTabButton.click(); + + // Verify all values are preserved after switching tabs + await expect(fontSizeField).toHaveValue(originalValues.fontSize); + await expect(fontFamilyField).toHaveValue(originalValues.fontFamily); + await expect(fontWeightField).toHaveValue(originalValues.fontWeight); + await expect(letterSpacingField).toHaveValue( + originalValues.letterSpacing, + ); + await expect(textCaseField).toHaveValue(originalValues.textCase); + await expect(textDecorationField).toHaveValue( + originalValues.textDecoration, + ); + await saveButton.click(); // Modal should close, token should be visible (with new name) in sidebar await expect(tokensUpdateCreateModal).not.toBeVisible(); }); + + test("User cant submit empty typography token or reference", async ({ + page, + }) => { + const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } = + await setupTypographyTokensFile(page); + + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + await tokensTabPanel + .getByRole("button", { name: "Add Token: Typography" }) + .click(); + + await expect(tokensUpdateCreateModal).toBeVisible(); + + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + await nameField.fill("typography.empty"); + + const valueField = tokensUpdateCreateModal.getByLabel("Font Size"); + + // Insert a value and then delete it + await valueField.fill("1"); + await valueField.fill(""); + + // Submit button should be disabled when field is empty + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + await expect(submitButton).toBeDisabled(); + + // Switch to reference tab, should not be submittable either + const referenceTabButton = tokensUpdateCreateModal.getByRole("button", { + name: "Reference", + }); + await referenceTabButton.click(); + await expect(submitButton).toBeDisabled(); + }); + + test("User adds typography token with reference", async ({ page }) => { + const { tokensUpdateCreateModal, tokenThemesSetsSidebar, tokensSidebar } = + await setupTypographyTokensFile(page); + + const newTokenTitle = "NewReference"; + + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + await tokensTabPanel + .getByRole("button", { name: "Add Token: Typography" }) + .click(); + + await expect(tokensUpdateCreateModal).toBeVisible(); + + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + await nameField.fill(newTokenTitle); + + const referenceTabButton = tokensUpdateCreateModal.getByRole("button", { + name: "Reference", + }); + referenceTabButton.click(); + + const referenceField = tokensUpdateCreateModal.getByLabel("Reference"); + await referenceField.fill("{Full}"); + + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + + const resolvedValue = + await tokensUpdateCreateModal.getByText("Resolved value:"); + await expect(resolvedValue).toBeVisible(); + await expect(resolvedValue).toContainText("Font Family: 42dot Sans"); + await expect(resolvedValue).toContainText("Font Size: 100"); + await expect(resolvedValue).toContainText("Font Weight: 300"); + await expect(resolvedValue).toContainText("Letter Spacing: 2"); + await expect(resolvedValue).toContainText("Text Case: uppercase"); + await expect(resolvedValue).toContainText("Text Decoration: underline"); + + await expect(submitButton).toBeEnabled(); + await submitButton.click(); + + await expect(tokensUpdateCreateModal).not.toBeVisible(); + + const newToken = tokensSidebar.getByRole("button", { + name: newTokenTitle, + }); + await expect(newToken).toBeVisible(); + }); }); }); diff --git a/frontend/src/app/main/data/workspace/tokens/errors.cljs b/frontend/src/app/main/data/workspace/tokens/errors.cljs index 75e16499112..4173481916f 100644 --- a/frontend/src/app/main/data/workspace/tokens/errors.cljs +++ b/frontend/src/app/main/data/workspace/tokens/errors.cljs @@ -88,6 +88,10 @@ {:error/code :error.style-dictionary/invalid-token-value-font-weight :error/fn #(tr "workspace.tokens.invalid-font-weight-token-value" %)} + :error.style-dictionary/invalid-token-value-typography + {:error/code :error.style-dictionary/invalid-token-value-typography + :error/fn #(tr "workspace.tokens.invalid-token-value-typography" %)} + :error/unknown {:error/code :error/unknown :error/fn #(tr "labels.unknown-error")}}) diff --git a/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss b/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss index c80cf0e1a52..f4ac4b7f774 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss @@ -12,6 +12,7 @@ @include use-typography("body-small"); color: var(--hint-color); + white-space: pre; } .type-hint { diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs index c31b2bde826..7d475f619ba 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/form.cljs @@ -207,15 +207,27 @@ (assoc err :typography-key k))) token-values))) +(defn invalidate-empty-typography-token [token] + (when (empty? (:value token)) + (wte/get-error-code :error.token/empty-input))) + (defn validate-typography-token - [props] - (-> props - (update :token-value - (fn [v] - (-> (or v {}) - (d/update-when :font-family #(if (string? %) (ctt/split-font-family %) %))))) - (assoc :validators [invalidate-typography-token-self-reference]) - (default-validate-token))) + [{:keys [token-value] :as props}] + (cond + ;; Entering form without a value - show no error just resolve nil + (nil? token-value) (rx/of nil) + ;; Validate refrence string + (ctt/typography-composite-token-reference? token-value) (default-validate-token props) + ;; Validate composite token + :else + (-> props + (update :token-value + (fn [v] + (-> (or v {}) + (d/update-when :font-family #(if (string? %) (ctt/split-font-family %) %))))) + (assoc :validators [invalidate-empty-typography-token + invalidate-typography-token-self-reference]) + (default-validate-token)))) (defn use-debonced-resolve-callback "Resolves a token values using `StyleDictionary`. @@ -365,6 +377,11 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va token-resolve-result* (mf/use-state (get resolved-tokens (cft/token-identifier token))) token-resolve-result (deref token-resolve-result*) + clear-resolve-value + (mf/use-fn + (fn [] + (reset! token-resolve-result* nil))) + set-resolve-value (mf/use-fn (mf/deps on-value-resolve) @@ -399,7 +416,6 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va (mf/use-fn (mf/deps on-update-value-debounced) (fn [next-value] - (dom/set-value! (mf/ref-val value-input-ref) next-value) (mf/set-ref-val! value-ref next-value) (on-update-value-debounced next-value))) @@ -576,7 +592,8 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va :on-update-value on-update-value :on-external-update-value on-external-update-value :custom-input-token-value-props custom-input-token-value-props - :token-resolve-result token-resolve-result}] + :token-resolve-result token-resolve-result + :clear-resolve-value clear-resolve-value}] [:> input-tokens-value* {:placeholder placeholder :label label @@ -716,6 +733,7 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va color-value (-> (tinycolor/valid-color hex-value) (tinycolor/set-alpha (or alpha 1)) (tinycolor/->string format))] + (dom/set-value! (mf/ref-val input-ref) color-value) (on-external-update-value color-value))))] [:* @@ -897,7 +915,7 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va {:label "Text Decoration" :placeholder (tr "workspace.tokens.text-decoration-value-enter")})) -(mf/defc typography-inputs* +(mf/defc typography-value-inputs* [{:keys [default-value on-blur on-update-value token-resolve-result]}] (let [typography-inputs (mf/use-memo typography-inputs) errors-by-key (sd/collect-typography-errors token-resolve-result)] @@ -928,12 +946,12 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va (-> (obj/set! e "tokenType" k) (on-update-value))))] - [:div {:class (stl/css :input-row)} + [:div {:key (str k) + :class (stl/css :input-row)} (case k :font-family [:> font-picker* - {:key (str k) - :label label + {:label label :placeholder placeholder :input-ref input-ref :default-value (when value (ctt/join-font-family value)) @@ -942,24 +960,84 @@ custom-input-token-value-props: Custom props passed to the custom-input-token-va :on-external-update-value on-external-update-value :token-resolve-result (when (seq token-resolve-result) token-resolve-result)}] [:> input-tokens-value* - {:key (str k) - :label label + {:label label :placeholder placeholder :default-value value :on-blur on-blur :on-change on-change :token-resolve-result (when (seq token-resolve-result) token-resolve-result)}])]))])) +(mf/defc typography-reference-input* + [{:keys [default-value on-blur on-update-value token-resolve-result]}] + [:> input-tokens-value* + {:label "Reference" + :placeholder "Reference" + :default-value (when (ctt/typography-composite-token-reference? default-value) default-value) + :on-blur on-blur + :on-change on-update-value + :token-resolve-result (when (or + (:errors token-resolve-result) + (string? (:value token-resolve-result))) + token-resolve-result)}]) + +(mf/defc typography-inputs* + [{:keys [default-value on-update-value on-external-update-value on-value-resolve clear-resolve-value] :rest props}] + (let [;; Active Tab State + active-tab* (mf/use-state (if (ctt/typography-composite-token-reference? default-value) :reference :composite)) + active-tab (deref active-tab*) + reference-tab-active? (= :reference active-tab) + ;; Backup value ref + ;; Used to restore the previously entered value when switching tabs + ;; Uses ref to not trigger state updates during update + backup-state-ref (mf/use-var + (if reference-tab-active? + {:reference default-value} + {:composite default-value})) + default-value (get @backup-state-ref active-tab) + + on-toggle-tab + (mf/use-fn + (mf/deps active-tab on-external-update-value on-value-resolve clear-resolve-value) + (fn [] + (let [next-tab (if (= active-tab :composite) :reference :composite)] + ;; Clear the resolved value so it wont show up before the next-tab value has resolved + (clear-resolve-value) + ;; Restore the internal value from backup + (on-external-update-value (get @backup-state-ref next-tab)) + (reset! active-tab* next-tab)))) + + ;; Store token value in the backup-state-ref + on-update-reference-value + (mf/use-fn + (mf/deps on-update-value active-tab) + (fn [e] + (if reference-tab-active? + (swap! backup-state-ref assoc :reference (dom/get-target-val e)) + (swap! backup-state-ref assoc-in [:composite (obj/get e "tokenType")] (dom/get-target-val e))) + (on-update-value e))) + + input-props (mf/spread-props props {:default-value default-value + :on-update-value on-update-reference-value})] + [:div {:class (stl/css :nested-input-row)} + [:button {:on-click on-toggle-tab :type "button"} + (if reference-tab-active? "Composite" "Reference")] + (if reference-tab-active? + [:> typography-reference-input* input-props] + [:> typography-value-inputs* input-props])])) + (mf/defc typography-form* [{:keys [token] :rest props}] (let [on-get-token-value (mf/use-callback (fn [e prev-value] (let [token-type (obj/get e "tokenType") - input-value (dom/get-target-val e)] - (if (empty? input-value) - (dissoc prev-value token-type) - (assoc prev-value token-type input-value)))))] + input-value (dom/get-target-val e) + reference-value-input? (not token-type)] + (cond + reference-value-input? input-value + + (empty? input-value) (dissoc prev-value token-type) + :else (assoc prev-value token-type input-value)))))] [:> form* (mf/spread-props props {:token token :custom-input-token-value typography-inputs* diff --git a/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs b/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs index def747e6f85..ce3023e14dc 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/create/input_tokens_value.cljs @@ -14,6 +14,7 @@ [app.main.ui.ds.controls.utilities.input-field :refer [input-field*]] [app.main.ui.ds.controls.utilities.label :refer [label*]] [app.main.ui.ds.foundations.assets.icon :refer [icon-list]] + [app.main.ui.workspace.tokens.management.tooltip :as wtmt] [app.util.i18n :refer [tr]] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -41,7 +42,7 @@ (str/join "\n")) errors (->> (wte/humanize-errors errors) (str/join "\n")) - :else (tr "workspace.tokens.resolved-value" (or resolved-value result))) + :else (tr "workspace.tokens.resolved-value" (wtmt/format-token-value (or resolved-value result)))) type (cond empty-message? "hint" errors "error" 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 372d19d5483..08b84579cdc 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 @@ -6,7 +6,6 @@ (ns app.main.ui.workspace.tokens.management.token-pill (:require-macros - [app.common.data.macros :as dm] [app.main.style :as stl]) (:require [app.common.data :as d] @@ -19,153 +18,13 @@ [app.main.ui.components.color-bullet :refer [color-bullet]] [app.main.ui.ds.foundations.assets.icon :refer [icon*]] [app.main.ui.ds.foundations.utilities.token.token-status :refer [token-status-icon*]] + [app.main.ui.workspace.tokens.management.tooltip :as wtmt] [app.util.dom :as dom] - [app.util.i18n :refer [tr]] [clojure.set :as set] [cuerdas.core :as str] [rumext.v2 :as mf])) ;; Translation dictionaries -(def ^:private attribute-dictionary - {:rotation "Rotation" - :opacity "Opacity" - :stroke-width "Stroke Width" - - ;; Spacing - :p1 "Top" :p2 "Right" :p3 "Bottom" :p4 "Left" - :column-gap "Column Gap" :row-gap "Row Gap" - - ;; Sizing - :width "Width" - :height "Height" - :layout-item-min-w "Min Width" - :layout-item-min-h "Min Height" - :layout-item-max-w "Max Width" - :layout-item-max-h "Max Height" - - ;; Border Radius - :r1 "Top Left" :r2 "Top Right" :r4 "Bottom Left" :r3 "Bottom Right" - - ;; Dimensions - :x "X" :y "Y" - - ;; Color - :fill "Fill" - :stroke-color "Stroke Color"}) - -(def ^:private dimensions-dictionary - {:stroke-width :stroke-width - :p1 :spacing - :p2 :spacing - :p3 :spacing - :p4 :spacing - :column-gap :spacing - :row-gap :spacing - :width :sizing - :height :sizing - :layout-item-min-w :sizing - :layout-item-min-h :sizing - :layout-item-max-w :sizing - :layout-item-max-h :sizing - :r1 :border-radius - :r2 :border-radius - :r4 :border-radius - :r3 :border-radius - :x :x - :y :y}) - -(def ^:private category-dictionary - {:stroke-width "Stroke Width" - :spacing "Spacing" - :sizing "Sizing" - :border-radius "Border Radius" - :x "X" - :y "Y" - :font-size "Font Size" - :font-family "Font Family" - :font-weight "Font Weight" - :letter-spacing "Letter Spacing" - :text-case "Text Case" - :text-decoration "Text Decoration"}) - -;; Helper functions - -(defn partially-applied-attr - "Translates partially applied attributes based on the dictionary." - [app-token-keys is-applied {:keys [attributes all-attributes]}] - (let [filtered-keys (if all-attributes - (filter #(contains? all-attributes %) app-token-keys) - (filter #(contains? attributes %) app-token-keys))] - (when is-applied - (str/join ", " (map attribute-dictionary filtered-keys))))) - -(defn translate-and-format - "Translates and formats grouped values by category." - [grouped-values] - (str/join "\n" - (map (fn [[category values]] - (if (#{:x :y} category) - (dm/str "- " (category-dictionary category)) - (dm/str "- " (category-dictionary category) ": " - (str/join ", " (map attribute-dictionary values)) "."))) - grouped-values))) - -(defn format-token-value [token-value] - (cond - (map? token-value) - (->> (map (fn [[k v]] (str "- " (category-dictionary k) ": " (format-token-value v))) token-value) - (str/join "\n") - (str "\n")) - - (sequential? token-value) - (str/join "," token-value) - - :else - (str token-value))) - -(defn- generate-tooltip - "Generates a tooltip for a given token" - [is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set] - (let [{:keys [name type resolved-value value]} token - resolved-value-theme (:resolved-value theme-token) - resolved-value (or resolved-value-theme resolved-value) - {:keys [title] :as token-props} (dwta/get-token-properties theme-token) - applied-tokens (:applied-tokens shape) - app-token-vals (set (vals applied-tokens)) - app-token-keys (keys applied-tokens) - is-applied? (contains? app-token-vals name) - - - applied-to (if half-applied - (partially-applied-attr app-token-keys is-applied? token-props) - (tr "labels.all")) - grouped-values (group-by dimensions-dictionary app-token-keys) - - base-title (dm/str "Token: " name "\n" - (tr "workspace.tokens.original-value" (format-token-value value)) "\n" - (tr "workspace.tokens.resolved-value" (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 - ref-not-in-active-set - (tr "workspace.tokens.ref-not-valid") - - no-valid-value - (tr "workspace.tokens.value-not-valid") - - ;; 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")) - - ;; Otherwise only show the base title - :else base-title))) ;; FIXME: the token thould already have precalculated references, so ;; we don't need to perform this regex operation on each rerender @@ -290,8 +149,8 @@ (fn [event] (let [node (dom/get-current-target event) 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)] + title (wtmt/generate-tooltip is-viewer? (first selected-shapes) theme-token token + half-applied? no-valid-value ref-not-in-active-set)] (dom/set-attribute! node "title" title))))] [:button {:class (stl/css-case diff --git a/frontend/src/app/main/ui/workspace/tokens/management/tooltip.cljs b/frontend/src/app/main/ui/workspace/tokens/management/tooltip.cljs new file mode 100644 index 00000000000..4b3d96484a3 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/tooltip.cljs @@ -0,0 +1,148 @@ +(ns app.main.ui.workspace.tokens.management.tooltip + (:require-macros + [app.common.data.macros :as dm]) + (:require + [app.main.data.workspace.tokens.application :as dwta] + [app.util.i18n :refer [tr]] + [cuerdas.core :as str])) + +(def ^:private attribute-dictionary + {:rotation "Rotation" + :opacity "Opacity" + :stroke-width "Stroke Width" + + ;; Spacing + :p1 "Top" :p2 "Right" :p3 "Bottom" :p4 "Left" + :column-gap "Column Gap" :row-gap "Row Gap" + + ;; Sizing + :width "Width" + :height "Height" + :layout-item-min-w "Min Width" + :layout-item-min-h "Min Height" + :layout-item-max-w "Max Width" + :layout-item-max-h "Max Height" + + ;; Border Radius + :r1 "Top Left" :r2 "Top Right" :r4 "Bottom Left" :r3 "Bottom Right" + + ;; Dimensions + :x "X" :y "Y" + + ;; Color + :fill "Fill" + :stroke-color "Stroke Color"}) + +(def ^:private dimensions-dictionary + {:stroke-width :stroke-width + :p1 :spacing + :p2 :spacing + :p3 :spacing + :p4 :spacing + :column-gap :spacing + :row-gap :spacing + :width :sizing + :height :sizing + :layout-item-min-w :sizing + :layout-item-min-h :sizing + :layout-item-max-w :sizing + :layout-item-max-h :sizing + :r1 :border-radius + :r2 :border-radius + :r4 :border-radius + :r3 :border-radius + :x :x + :y :y}) + +(def ^:private category-dictionary + {:stroke-width "Stroke Width" + :spacing "Spacing" + :sizing "Sizing" + :border-radius "Border Radius" + :x "X" + :y "Y" + :font-size "Font Size" + :font-family "Font Family" + :font-weight "Font Weight" + :letter-spacing "Letter Spacing" + :text-case "Text Case" + :text-decoration "Text Decoration"}) + +(defn- partially-applied-attr + "Translates partially applied attributes based on the dictionary." + [app-token-keys is-applied {:keys [attributes all-attributes]}] + (let [filtered-keys (if all-attributes + (filter #(contains? all-attributes %) app-token-keys) + (filter #(contains? attributes %) app-token-keys))] + (when is-applied + (str/join ", " (map attribute-dictionary filtered-keys))))) + +(defn- translate-and-format + "Translates and formats grouped values by category." + [grouped-values] + (str/join "\n" + (map (fn [[category values]] + (if (#{:x :y} category) + (dm/str "- " (category-dictionary category)) + (dm/str "- " (category-dictionary category) ": " + (str/join ", " (map attribute-dictionary values)) "."))) + grouped-values))) + +(defn format-token-value + "Converts token value of any shape to a string." + [token-value] + (cond + (map? token-value) + (->> (map (fn [[k v]] (str "- " (category-dictionary k) ": " (format-token-value v))) token-value) + (str/join "\n") + (str "\n")) + + (sequential? token-value) + (str/join ", " token-value) + + :else + (str token-value))) + +(defn generate-tooltip + "Generates a tooltip for a given token" + [is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set] + (let [{:keys [name type resolved-value value]} token + resolved-value-theme (:resolved-value theme-token) + resolved-value (or resolved-value-theme resolved-value) + {:keys [title] :as token-props} (dwta/get-token-properties theme-token) + applied-tokens (:applied-tokens shape) + app-token-vals (set (vals applied-tokens)) + app-token-keys (keys applied-tokens) + is-applied? (contains? app-token-vals name) + + + applied-to (if half-applied + (partially-applied-attr app-token-keys is-applied? token-props) + (tr "labels.all")) + grouped-values (group-by dimensions-dictionary app-token-keys) + + base-title (dm/str "Token: " name "\n" + (tr "workspace.tokens.original-value" (format-token-value value)) "\n" + (tr "workspace.tokens.resolved-value" (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 + ref-not-in-active-set + (tr "workspace.tokens.ref-not-valid") + + no-valid-value + (tr "workspace.tokens.value-not-valid") + + ;; 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")) + + ;; Otherwise only show the base title + :else base-title))) diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index cc65f108838..a51fb2970f2 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -11,10 +11,12 @@ [app.common.files.changes-builder :as cb] [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] + [app.common.json :as json] [app.common.schema :as sm] [app.common.types.color :as ctc] [app.common.types.shape :as cts] [app.common.types.text :as txt] + [app.common.types.tokens-lib :as ctob] [app.common.uuid :as uuid] [app.main.data.changes :as ch] [app.main.data.common :as dcm] @@ -25,6 +27,7 @@ [app.main.data.workspace.groups :as dwg] [app.main.data.workspace.media :as dwm] [app.main.data.workspace.selection :as dws] + [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.fonts :refer [fetch-font-css]] [app.main.router :as rt] [app.main.store :as st] @@ -544,4 +547,22 @@ :else (let [ids (into #{} (map #(obj/get % "$id")) shapes)] - (st/emit! (dw/convert-selected-to-path ids))))))) + (st/emit! (dw/convert-selected-to-path ids))))) + + :getTokens + (fn [] + (let [file-data (dsh/lookup-file-data @st/state) + tokens-lib (get file-data :tokens-lib)] + (some-> tokens-lib + (ctob/export-dtcg-json) + (json/encode :key-fn identity :indent 2)))) + + :setTokens + (fn [json-string] + (js/console.log "json-string" json-string) + (try + (let [decoded-json (json/decode json-string {:key-fn identity}) + tokens-lib (ctob/parse-decoded-json decoded-json "____")] + (st/emit! (dwtl/import-tokens-lib tokens-lib))) + (catch :default e + (js/console.error "Error importing json" json-string e)))))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 29e128469f9..faaea32268c 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -7523,6 +7523,10 @@ msgstr "" "Invalid font weight value: use numeric values (100-950) or standard names " "(thin, light, regular, bold, etc.) optionally followed by 'Italic'" +#: src/app/main/data/workspace/tokens/errors.cljs:91 +msgid "workspace.tokens.invalid-token-value-typography" +msgstr "Invalid typography value: this should be a reference value to another composite typography token." + #: src/app/main/data/workspace/tokens/errors.cljs:23 msgid "workspace.tokens.invalid-json" msgstr "Import Error: Invalid token data in JSON."