♻️ Replace token form files (#7896)
Some checks failed
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
_DEVELOP / build-bundle (push) Has been cancelled
_DEVELOP / build-docker (push) Has been cancelled
_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

* ♻️ Replace shadow form

* ♻️ Rename files and components

* ♻️ Replace offsetx and offsety names

* ♻️ Replace form file for new form component using new form system

* ♻️ Rename files and props
This commit is contained in:
Eva Marco
2025-12-05 17:04:07 +01:00
committed by GitHub
parent f13b3c8737
commit 81e0e4f222
53 changed files with 2171 additions and 3873 deletions

View File

@@ -1575,10 +1575,10 @@ Will return a value that matches this schema:
(if (map? shadow)
(let [legacy-shadow-type (get "type" shadow)]
(-> shadow
(set/rename-keys {"x" :offsetX
"offsetX" :offsetX
"y" :offsetY
"offsetY" :offsetY
(set/rename-keys {"x" :offset-x
"offsetX" :offset-x
"y" :offset-y
"offsetY" :offset-y
"blur" :blur
"spread" :spread
"color" :color
@@ -1589,7 +1589,7 @@ Will return a value that matches this schema:
(= "false" %) false
(= legacy-shadow-type "innerShadow") true
:else false))
(select-keys [:offsetX :offsetY :blur :spread :color :inset])))
(select-keys [:offset-x :offset-y :blur :spread :color :inset])))
shadow))]
(cond
;; Reference value - keep as string
@@ -1860,8 +1860,8 @@ Will return a value that matches this schema:
(mapv (fn [shadow]
(if (map? shadow)
(-> shadow
(set/rename-keys {:offsetX "offsetX"
:offsetY "offsetY"
(set/rename-keys {:offset-x "offsetX"
:offset-y "offsetY"
:blur "blur"
:spread "spread"
:color "color"

View File

@@ -1897,15 +1897,15 @@
(let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-single")]
(t/is (some? token))
(t/is (= :shadow (:type token)))
(t/is (= [{:offsetX "0", :offsetY "2px", :blur "4px", :spread "0", :color "#000", :inset false}]
(t/is (= [{:offset-x "0", :offset-y "2px", :blur "4px", :spread "0", :color "#000", :inset false}]
(:value token)))))
(t/testing "multiple shadow token"
(let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-multiple")]
(t/is (some? token))
(t/is (= :shadow (:type token)))
(t/is (= [{:offsetX "0", :offsetY "2px", :blur "4px", :spread "0", :color "#000", :inset true}
{:offsetX "0", :offsetY "8px", :blur "16px", :spread "0", :color "#000", :inset true}]
(t/is (= [{:offset-x "0", :offset-y "2px", :blur "4px", :spread "0", :color "#000", :inset true}
{:offset-x "0", :offset-y "8px", :blur "16px", :spread "0", :color "#000", :inset true}]
(:value token)))))
(t/testing "shadow token with reference"
@@ -1918,7 +1918,7 @@
(let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-with-type")]
(t/is (some? token))
(t/is (= :shadow (:type token)))
(t/is (= [{:offsetX "0", :offsetY "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset false}]
(t/is (= [{:offset-x "0", :offset-y "4px", :blur "8px", :spread "0", :color "rgba(0,0,0,0.2)", :inset false}]
(:value token)))))
(t/testing "shadow token with description"
@@ -1937,14 +1937,14 @@
(ctob/make-token
{:name "shadow.single"
:type :shadow
:value [{:offsetX "0" :offsetY "2px" :blur "4px" :spread "0" :color "#0000001A"}]
:value [{:offset-x "0" :offset-y "2px" :blur "4px" :spread "0" :color "#0000001A"}]
:description "A single shadow"})
"shadow.multiple"
(ctob/make-token
{:name "shadow.multiple"
:type :shadow
:value [{:offsetX "0" :offsetY "2px" :blur "4px" :spread "0" :color "#0000001A"}
{:offsetX "0" :offsetY "8px" :blur "16px" :spread "0" :color "#0000001A"}]})
:value [{:offset-x "0" :offset-y "2px" :blur "4px" :spread "0" :color "#0000001A"}
{:offset-x "0" :offset-y "8px" :blur "16px" :spread "0" :color "#0000001A"}]})
"shadow.ref"
(ctob/make-token
{:name "shadow.ref"
@@ -1991,7 +1991,7 @@
(ctob/make-token
{:name "shadow.test"
:type :shadow
:value [{:offsetX "1" :offsetY "1" :blur "1" :spread "1" :color "red" :inset true}]
:value [{:offset-x "1" :offset-y "1" :blur "1" :spread "1" :color "red" :inset true}]
:description "Round trip test"})
"shadow.ref"
(ctob/make-token

View File

@@ -5947,8 +5947,8 @@
"~:spread": "10",
"~:color": "rgb(160, 73, 73)",
"~:inset": true,
"~:offsetX": "10",
"~:offsetY": "10"
"~:offset-x": "10",
"~:offset-y": "10"
}
],
"~:description": "",

View File

@@ -1239,8 +1239,12 @@ test.describe("Tokens: Apply token", () => {
// Fill in the shadow values
const offsetXInput = firstShadowFields.getByLabel("X");
const offsetYInput = firstShadowFields.getByLabel("Y");
const blurInput = firstShadowFields.getByLabel("Blur");
const spreadInput = firstShadowFields.getByLabel("Spread");
const blurInput = firstShadowFields.getByRole("textbox", {
name: "Blur",
});
const spreadInput = firstShadowFields.getByRole("textbox", {
name: "Spread",
});
await offsetXInput.fill("2");
await offsetYInput.fill("2");
@@ -1261,9 +1265,10 @@ test.describe("Tokens: Apply token", () => {
await valueSaturationSelector.click({ position: { x: 50, y: 50 } });
// Verify that a color value was set
const colorInput = firstShadowFields.getByLabel("Color");
const firstColorValue = await colorInput.inputValue();
await expect(firstColorValue).toMatch(/^rgb(.*)$/);
const colorInput = firstShadowFields.getByRole("textbox", {
name: "Color",
});
await expect(colorInput).toHaveValue(/^rgb(.*)$/);
// Wait for validation to complete
await expect(
@@ -1281,11 +1286,15 @@ test.describe("Tokens: Apply token", () => {
const firstShadowFields = tokensUpdateCreateModal.getByTestId(
"shadow-input-fields-0",
);
const colorInput = firstShadowFields.getByLabel("Color");
const colorInput = firstShadowFields.getByRole("textbox", {
name: "Color",
});
const firstColorValue = await colorInput.inputValue();
// User adds a second shadow
const addButton = firstShadowFields.getByTestId("shadow-add-button-0");
const addButton = tokensUpdateCreateModal.getByRole("button", {
name: "Add Shadow",
});
await addButton.click();
const secondShadowFields = tokensUpdateCreateModal.getByTestId(
@@ -1294,8 +1303,7 @@ test.describe("Tokens: Apply token", () => {
await expect(secondShadowFields).toBeVisible();
// User adds a third shadow
const addButton2 = secondShadowFields.getByTestId("shadow-add-button-1");
await addButton2.click();
await addButton.click();
const thirdShadowFields = tokensUpdateCreateModal.getByTestId(
"shadow-input-fields-2",
@@ -1305,9 +1313,15 @@ test.describe("Tokens: Apply token", () => {
// User adds values for the third shadow
const thirdOffsetXInput = thirdShadowFields.getByLabel("X");
const thirdOffsetYInput = thirdShadowFields.getByLabel("Y");
const thirdBlurInput = thirdShadowFields.getByLabel("Blur");
const thirdSpreadInput = thirdShadowFields.getByLabel("Spread");
const thirdColorInput = thirdShadowFields.getByLabel("Color");
const thirdBlurInput = thirdShadowFields.getByRole("textbox", {
name: "Blur",
});
const thirdSpreadInput = thirdShadowFields.getByRole("textbox", {
name: "Spread",
});
const thirdColorInput = thirdShadowFields.getByRole("textbox", {
name: "Color",
});
await thirdOffsetXInput.fill("10");
await thirdOffsetYInput.fill("10");
@@ -1316,15 +1330,13 @@ test.describe("Tokens: Apply token", () => {
await thirdColorInput.fill("#FF0000");
// User removes the 2nd shadow
const removeButton2 = secondShadowFields.getByTestId(
"shadow-remove-button-1",
);
const removeButton2 = secondShadowFields.getByRole("button", {
name: "Remove Shadow",
});
await removeButton2.click();
// Verify second shadow is removed
await expect(
secondShadowFields.getByTestId("shadow-add-button-3"),
).not.toBeVisible();
// Verify that we have only two shadow fields
await expect(thirdShadowFields).not.toBeVisible();
// Verify that the first shadow kept its values
const firstOffsetXValue = await firstShadowFields
@@ -1334,13 +1346,13 @@ test.describe("Tokens: Apply token", () => {
.getByLabel("Y")
.inputValue();
const firstBlurValue = await firstShadowFields
.getByLabel("Blur")
.getByRole("textbox", { name: "Blur" })
.inputValue();
const firstSpreadValue = await firstShadowFields
.getByLabel("Spread")
.getByRole("textbox", { name: "Spread" })
.inputValue();
const firstColorValueAfter = await firstShadowFields
.getByLabel("Color")
.getByRole("textbox", { name: "Color" })
.inputValue();
await expect(firstOffsetXValue).toBe("2");
@@ -1349,7 +1361,7 @@ test.describe("Tokens: Apply token", () => {
await expect(firstSpreadValue).toBe("0");
await expect(firstColorValueAfter).toBe(firstColorValue);
// Verify that the third shadow (now second) kept its values
// Verify that the second kept its values (after shadow 3)
// After removing index 1, the third shadow becomes the second shadow at index 1
const newSecondShadowFields = tokensUpdateCreateModal.getByTestId(
"shadow-input-fields-1",
@@ -1363,13 +1375,13 @@ test.describe("Tokens: Apply token", () => {
.getByLabel("Y")
.inputValue();
const secondBlurValue = await newSecondShadowFields
.getByLabel("Blur")
.getByRole("textbox", { name: "Blur" })
.inputValue();
const secondSpreadValue = await newSecondShadowFields
.getByLabel("Spread")
.getByRole("textbox", { name: "Spread" })
.inputValue();
const secondColorValue = await newSecondShadowFields
.getByLabel("Color")
.getByRole("textbox", { name: "Color" })
.inputValue();
await expect(secondOffsetXValue).toBe("10");
@@ -1386,7 +1398,9 @@ test.describe("Tokens: Apply token", () => {
const newSecondShadowFields = tokensUpdateCreateModal.getByTestId(
"shadow-input-fields-1",
);
const colorInput = firstShadowFields.getByLabel("Color");
const colorInput = firstShadowFields.getByRole("textbox", {
name: "Color",
});
const firstColorValue = await colorInput.inputValue();
// Switch to reference tab
@@ -1414,13 +1428,13 @@ test.describe("Tokens: Apply token", () => {
.getByLabel("Y")
.inputValue();
const restoredFirstBlur = await firstShadowFields
.getByLabel("Blur")
.getByRole("textbox", { name: "Blur" })
.inputValue();
const restoredFirstSpread = await firstShadowFields
.getByLabel("Spread")
.getByRole("textbox", { name: "Spread" })
.inputValue();
const restoredFirstColor = await firstShadowFields
.getByLabel("Color")
.getByRole("textbox", { name: "Color" })
.inputValue();
await expect(restoredFirstOffsetX).toBe("2");
@@ -1437,13 +1451,13 @@ test.describe("Tokens: Apply token", () => {
.getByLabel("Y")
.inputValue();
const restoredSecondBlur = await newSecondShadowFields
.getByLabel("Blur")
.getByRole("textbox", { name: "Blur" })
.inputValue();
const restoredSecondSpread = await newSecondShadowFields
.getByLabel("Spread")
.getByRole("textbox", { name: "Spread" })
.inputValue();
const restoredSecondColor = await newSecondShadowFields
.getByLabel("Color")
.getByRole("textbox", { name: "Color" })
.inputValue();
await expect(restoredSecondOffsetX).toBe("10");

View File

@@ -59,9 +59,15 @@
"Parses `value` of a color `sd-token` into a map like `{:value 1 :unit \"px\"}`.
If the value is not parseable and/or has missing references returns a map with `:errors`."
[value]
(let [missing-references (seq (cto/find-token-value-references value))]
(if-let [tc (tinycolor/valid-color value)]
{:value value :unit (tinycolor/color-format tc)}
{:errors [(wte/error-with-value :error.token/invalid-color value)]}))
(cond
missing-references
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference missing-references)]
:references missing-references}
:else
{:errors [(wte/error-with-value :error.token/invalid-color value)]}))))
(defn- numeric-string? [s]
(and (string? s)
@@ -120,7 +126,7 @@
If the `value` is not parseable and/or has missing references returns a map with `:errors`.
If the `value` is parseable but is out of range returns a map with `warnings`."
[value]
(let [missing-references? (seq (cto/find-token-value-references value))
(let [missing-references? (seq (seq (cto/find-token-value-references value)))
parsed-value (cft/parse-token-value value)
out-of-scope (not (<= 0 (:value parsed-value) 1))
references (seq (cto/find-token-value-references value))]
@@ -368,8 +374,8 @@
(let [add-keyed-errors (fn [shadow-result k errors]
(update shadow-result :errors concat
(map #(assoc % :shadow-key k :shadow-index shadow-index) errors)))
parsers {:offsetX parse-sd-token-general-value
:offsetY parse-sd-token-general-value
parsers {:offset-x parse-sd-token-general-value
:offset-y parse-sd-token-general-value
:blur parse-sd-token-shadow-blur
:spread parse-sd-token-shadow-spread
:color parse-sd-token-color-value
@@ -389,9 +395,16 @@
(defn- parse-sd-token-shadow-value
"Parses shadow value and validates it."
[value]
(let [missing-references
(when (string? value)
(seq (cto/find-token-value-references value)))]
(cond
;; Reference value (string)
(string? value) {:value value}
missing-references
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference missing-references)]
:references missing-references}
(string? value)
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-shadow value)]}
;; Empty value
(nil? value) {:errors [(wte/get-error-code :error.token/empty-input)]}
@@ -417,7 +430,7 @@
(if (seq all-errors)
{:errors all-errors
:value all-values}
{:value all-values}))))
{:value all-values})))))
(defn collect-shadow-errors [token shadow-index]
(group-by :shadow-key

View File

@@ -153,11 +153,11 @@
(defn value->shadow
"Transform a token shadow value into penpot shadow data structure"
[value]
(mapv (fn [{:keys [offsetX offsetY blur spread color inset]}]
(mapv (fn [{:keys [offset-x offset-y blur spread color inset]}]
{:id (random-uuid)
:hidden false
:offset-x offsetX
:offset-y offsetY
:offset-x offset-x
:offset-y offset-y
:blur blur
:color (value->color color)
:spread spread

View File

@@ -108,6 +108,10 @@
{:error/code :error.style-dictionary/invalid-token-value-shadow-spread
:error/fn #(tr "workspace.tokens.shadow-spread-range")}
:error.style-dictionary/invalid-token-value-shadow
{:error/code :error.style-dictionary/invalid-token-value-shadow
:error/fn #(tr "workspace.tokens.invalid-token-value-shadow" %)}
:error/unknown
{:error/code :error/unknown
:error/fn #(tr "labels.unknown-error")}})

View File

@@ -16,8 +16,8 @@
:letter-spacing "Letter Spacing"
:text-case "Text Case"
:text-decoration "Text Decoration"
:offsetX "X"
:offsetY "Y"
:offset-x "X"
:offset-y "Y"
:blur "Blur"
:spread "Spread"
:color "Color"

View File

@@ -118,6 +118,7 @@
(mf/use-fn
(mf/deps disabled)
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(when-not disabled
(swap! is-open* not))))

View File

@@ -53,10 +53,15 @@
"true")
:aria-describedby (when has-hint
(str id "-hint"))
:aria-labelledby tooltip-id
:type (d/nilv type "text")
:id id
:max-length (d/nilv max-length max-input-length)})
props (if (and aria-label (not (some? icon)))
(mf/spread-props props
{:aria-label aria-label})
(mf/spread-props props
{:aria-labelledby tooltip-id}))
inside-class (stl/css-case :input-wrapper true
:has-hint has-hint
:hint-type-hint (= hint-type "hint")

View File

@@ -15,7 +15,7 @@
}
.swatch {
--border-color: var(--color-background-quaternary);
--border-color: var(--color-accent-primary-muted);
--border-radius: #{$br-4};
--border-color-active: var(--color-foreground-primary);
--border-color-active-inset: var(--color-background-primary);

View File

@@ -35,7 +35,7 @@
[app.main.ui.workspace.tokens.export.modal]
[app.main.ui.workspace.tokens.import]
[app.main.ui.workspace.tokens.import.modal]
[app.main.ui.workspace.tokens.management.create.modals]
[app.main.ui.workspace.tokens.management.forms.modals]
[app.main.ui.workspace.tokens.settings]
[app.main.ui.workspace.tokens.themes.create-modal]
[app.main.ui.workspace.viewport :refer [viewport*]]

View File

@@ -1,58 +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
@use "ds/typography.scss" as t;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
.form-wrapper {
width: $sz-384;
position: relative;
}
.token-rows {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.input-row {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.title-bar {
display: grid;
grid-template-columns: 1fr auto;
}
.form-modal-title {
@include t.use-typography("headline-medium");
color: var(--color-foreground-primary);
display: flex;
align-items: center;
}
.button-row {
display: grid;
grid-template-columns: auto auto;
justify-content: end;
gap: var(--sp-m);
padding-block-start: var(--sp-s);
}
.with-delete {
grid-template-columns: 1fr auto auto;
}
.warning-name-change-notification-wrapper {
margin-block-start: var(--sp-l);
}
.delete-btn {
justify-self: start;
}

View File

@@ -1,224 +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
(ns app.main.ui.workspace.tokens.management.create.color
(:require-macros [app.main.style :as stl])
(:require
[app.common.files.tokens :as cft]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.constants :refer [max-input-length]]
[app.main.data.modal :as modal]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.create.form-color-input-token :refer [form-color-input-token*]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as k]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- token-value-error-fn
[{:keys [value]}]
(when (or (str/empty? value)
(str/blank? value))
(tr "workspace.tokens.empty-input")))
(defn- make-schema
[tokens-tree]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (cft/token-name-path-exists? % tokens-tree))]]]
[:value [::sm/text {:error/fn token-value-error-fn}]]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(nil? (cto/token-value-self-reference? name value))))]]))
(mf/defc form*
[{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}]
(let [token
(mf/with-memo [token]
(or token {:type :color}))
token-type
(get token :type)
token-properties
(dwta/get-token-properties token)
token-title (str/lower (:title token-properties))
tokens
(mf/deref refs/workspace-active-theme-sets-tokens)
tokens
(mf/with-memo [tokens token]
;; Ensure that the resolved value uses the currently editing token
;; even if the name has been overriden by a token with the same name
;; in another set below.
(cond-> tokens
(and (:name token) (:value token))
(assoc (:name token) token)))
schema
(mf/with-memo [tokens-tree-in-selected-set]
(make-schema tokens-tree-in-selected-set))
initial
(mf/with-memo [token]
{:name (:name token "")
:value (:value token "")
:description (:description token "")})
form
(fm/use-form :schema schema
:initial initial)
warning-name-change?
(not= (get-in @form [:data :name])
(:name initial))
on-cancel
(mf/use-fn
(fn [e]
(dom/prevent-default e)
(modal/hide!)))
on-delete-token
(mf/use-fn
(mf/deps selected-token-set-id token)
(fn [e]
(dom/prevent-default e)
(modal/hide!)
(st/emit! (dwtl/delete-token selected-token-set-id (:id token)))))
handle-key-down-delete
(mf/use-fn
(mf/deps on-delete-token)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-delete-token e))))
handle-key-down-cancel
(mf/use-fn
(mf/deps on-cancel)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-cancel e))))
on-submit
(mf/use-fn
(mf/deps validate-token token tokens token-type)
(fn [form _event]
(let [name (get-in @form [:clean-data :name])
description (get-in @form [:clean-data :description])
value (get-in @form [:clean-data :value])]
(->> (validate-token {:token-value value
:token-name name
:token-description description
:prev-token token
:tokens tokens})
(rx/subs!
(fn [valid-token]
(st/emit!
(if is-create
(dwtl/create-token (ctob/make-token {:name name
:type token-type
:value (:value valid-token)
:description description}))
(dwtl/update-token (:id token)
{:name name
:value (:value valid-token)
:description description}))
(dwtp/propagate-workspace-tokens)
(modal/hide))))))))]
[:> fc/form* {:class (stl/css :form-wrapper)
:form form
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(tr "workspace.tokens.create-token" token-type)]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-name"
:name :name
:label (tr "workspace.tokens.token-name")
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:auto-focus true}]
(when (and warning-name-change? (= action "edit"))
[:div {:class (stl/css :warning-name-change-notification-wrapper)}
[:> context-notification*
{:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])]
[:div {:class (stl/css :input-row)}
[:> form-color-input-token*
{:placeholder (tr "workspace.tokens.token-value-enter")
:label (tr "workspace.tokens.token-value")
:name :value
:token token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-description"
:name :description
:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
:max-length max-input-length
:variant "comfortable"
:is-optional true}]]
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:on-key-down handle-key-down-delete
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:> button* {:on-click on-cancel
:on-key-down handle-key-down-cancel
:type "button"
:id "token-modal-cancel"
:variant "secondary"}
(tr "labels.cancel")]
[:> fc/form-submit* {:variant "primary"
:on-submit on-submit}
(tr "labels.save")]]]]))

View File

@@ -1,58 +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
@use "ds/typography.scss" as t;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
.form-wrapper {
width: $sz-384;
position: relative;
}
.token-rows {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.input-row {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.title-bar {
display: grid;
grid-template-columns: 1fr auto;
}
.form-modal-title {
@include t.use-typography("headline-medium");
color: var(--color-foreground-primary);
display: flex;
align-items: center;
}
.button-row {
display: grid;
grid-template-columns: auto auto;
justify-content: end;
gap: var(--sp-m);
padding-block-start: var(--sp-s);
}
.with-delete {
grid-template-columns: 1fr auto auto;
}
.warning-name-change-notification-wrapper {
margin-block-start: var(--sp-l);
}
.delete-btn {
justify-self: start;
}

View File

@@ -1,223 +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
(ns app.main.ui.workspace.tokens.management.create.dimensions
(:require-macros [app.main.style :as stl])
(:require
[app.common.files.tokens :as cft]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.constants :refer [max-input-length]]
[app.main.data.modal :as modal]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.create.form-input-token :refer [form-input-token*]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as k]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- token-value-error-fn
[{:keys [value]}]
(when (or (str/empty? value)
(str/blank? value))
(tr "workspace.tokens.empty-input")))
(defn- make-schema
[tokens-tree]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (cft/token-name-path-exists? % tokens-tree))]]]
[:value [::sm/text {:error/fn token-value-error-fn}]]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(nil? (cto/token-value-self-reference? name value))))]]))
(mf/defc form*
[{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}]
(let [token
(mf/with-memo [token]
(or token {:type :dimensions}))
token-type
(get token :type)
token-properties
(dwta/get-token-properties token)
token-title (str/lower (:title token-properties))
tokens
(mf/deref refs/workspace-active-theme-sets-tokens)
tokens
(mf/with-memo [tokens token]
;; Ensure that the resolved value uses the currently editing token
;; even if the name has been overriden by a token with the same name
;; in another set below.
(cond-> tokens
(and (:name token) (:value token))
(assoc (:name token) token)))
schema
(mf/with-memo [tokens-tree-in-selected-set]
(make-schema tokens-tree-in-selected-set))
initial
(mf/with-memo [token]
{:name (:name token "")
:value (:value token "")
:description (:description token "")})
form
(fm/use-form :schema schema
:initial initial)
warning-name-change?
(not= (get-in @form [:data :name])
(:name initial))
on-cancel
(mf/use-fn
(fn [e]
(dom/prevent-default e)
(modal/hide!)))
on-delete-token
(mf/use-fn
(mf/deps selected-token-set-id token)
(fn [e]
(dom/prevent-default e)
(modal/hide!)
(st/emit! (dwtl/delete-token selected-token-set-id (:id token)))))
handle-key-down-delete
(mf/use-fn
(mf/deps on-delete-token)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-delete-token e))))
handle-key-down-cancel
(mf/use-fn
(mf/deps on-cancel)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-cancel e))))
on-submit
(mf/use-fn
(mf/deps validate-token token tokens token-type)
(fn [form _event]
(let [name (get-in @form [:clean-data :name])
description (get-in @form [:clean-data :description])
value (get-in @form [:clean-data :value])]
(->> (validate-token {:token-value value
:token-name name
:token-description description
:prev-token token
:tokens tokens})
(rx/subs!
(fn [valid-token]
(st/emit!
(if is-create
(dwtl/create-token (ctob/make-token {:name name
:type token-type
:value (:value valid-token)
:description description}))
(dwtl/update-token (:id token)
{:name name
:value (:value valid-token)
:description description}))
(dwtp/propagate-workspace-tokens)
(modal/hide))))))))]
[:> fc/form* {:class (stl/css :form-wrapper)
:form form
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(tr "workspace.tokens.create-token" token-type)]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-name"
:name :name
:label (tr "workspace.tokens.token-name")
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:auto-focus true}]
(when (and warning-name-change? (= action "edit"))
[:div {:class (stl/css :warning-name-change-notification-wrapper)}
[:> context-notification*
{:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])]
[:div {:class (stl/css :input-row)}
[:> form-input-token*
{:placeholder (tr "workspace.tokens.token-value-enter")
:label (tr "workspace.tokens.token-value")
:name :value
:token token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-description"
:name :description
:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
:max-length max-input-length
:variant "comfortable"
:is-optional true}]]
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:on-key-down handle-key-down-delete
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:> button* {:on-click on-cancel
:on-key-down handle-key-down-cancel
:type "button"
:id "token-modal-cancel"
:variant "secondary"}
(tr "labels.cancel")]
[:> fc/form-submit* {:variant "primary"
:on-submit on-submit}
(tr "labels.save")]]]]))

View File

@@ -1,221 +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
(ns app.main.ui.workspace.tokens.management.create.font-family
(:require-macros [app.main.style :as stl])
(:require
[app.common.files.tokens :as cft]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.constants :refer [max-input-length]]
[app.main.data.modal :as modal]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.create.combobox-token-fonts :refer [font-picker-combobox*]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as k]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- make-schema
[tokens-tree]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (cft/token-name-path-exists? % tokens-tree))]]]
[:value ::sm/text]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(nil? (cto/token-value-self-reference? name value))))]]))
(mf/defc form*
[{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}]
(let [token
(mf/with-memo [token]
(if token
(update token :value cto/join-font-family)
{:type :font-family}))
token-type
(get token :type)
token-properties
(dwta/get-token-properties token)
token-title (str/lower (:title token-properties))
tokens
(mf/deref refs/workspace-active-theme-sets-tokens)
tokens
(mf/with-memo [tokens token]
;; Ensure that the resolved value uses the currently editing token
;; even if the name has been overriden by a token with the same name
;; in another set below.
(cond-> tokens
(and (:name token) (:value token))
(assoc (:name token) token)))
schema
(mf/with-memo [tokens-tree-in-selected-set]
(make-schema tokens-tree-in-selected-set))
initial
(mf/with-memo [token]
{:name (:name token "")
:value (:value token "")
:description (:description token "")})
form
(fm/use-form :schema schema
:initial initial)
warning-name-change?
(not= (get-in @form [:data :name])
(:name initial))
on-cancel
(mf/use-fn
(fn [e]
(dom/prevent-default e)
(modal/hide!)))
on-delete-token
(mf/use-fn
(mf/deps selected-token-set-id token)
(fn [e]
(dom/prevent-default e)
(modal/hide!)
(st/emit! (dwtl/delete-token selected-token-set-id (:id token)))))
handle-key-down-delete
(mf/use-fn
(mf/deps on-delete-token)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-delete-token e))))
handle-key-down-cancel
(mf/use-fn
(mf/deps on-cancel)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-cancel e))))
on-submit
(mf/use-fn
(mf/deps validate-token token tokens token-type)
(fn [form _event]
(let [name (get-in @form [:clean-data :name])
description (get-in @form [:clean-data :description])
value (get-in @form [:clean-data :value])]
(->> (validate-token {:token-value value
:token-name name
:token-description description
:prev-token token
:tokens tokens})
(rx/subs!
(fn [valid-token]
(st/emit!
(if is-create
(dwtl/create-token (ctob/make-token {:name name
:type token-type
:value (:value valid-token)
:description description}))
(dwtl/update-token (:id token)
{:name name
:value (:value valid-token)
:description description}))
(dwtp/propagate-workspace-tokens)
(modal/hide))))))))]
[:> fc/form* {:class (stl/css :form-wrapper)
:form form
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(tr "workspace.tokens.create-token" token-type)]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-name"
:name :name
:label (tr "workspace.tokens.token-name")
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:auto-focus true}]
(when (and warning-name-change? (= action "edit"))
[:div {:class (stl/css :warning-name-change-notification-wrapper)}
[:> context-notification*
{:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])]
[:div {:class (stl/css :input-row)}
[:> font-picker-combobox*
{:placeholder (tr "workspace.tokens.token-value-enter")
:label (tr "workspace.tokens.token-value")
:name :value
:token token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-description"
:name :description
:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
:max-length max-input-length
:variant "comfortable"
:is-optional true}]]
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:on-key-down handle-key-down-delete
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:> button* {:on-click on-cancel
:on-key-down handle-key-down-cancel
:type "button"
:id "token-modal-cancel"
:variant "secondary"}
(tr "labels.cancel")]
[:> fc/form-submit* {:variant "primary"
:on-submit on-submit}
(tr "labels.save")]]]]))

View File

@@ -1,105 +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
@use "ds/typography.scss" as t;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
.form-wrapper {
width: $sz-384;
position: relative;
}
.button-row {
display: grid;
grid-template-columns: auto auto;
justify-content: end;
gap: var(--sp-m);
padding-block-start: var(--sp-s);
}
.with-delete {
grid-template-columns: 1fr auto auto;
}
.delete-btn {
justify-self: start;
}
.token-rows {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.input-row {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.nested-input-row {
display: flex;
flex-direction: column;
gap: var(--sp-m);
}
.typography-inputs-row {
display: flex;
flex-direction: column;
gap: var(--sp-m);
}
.typography-inputs {
border-inline-start: $b-1 solid var(--color-accent-primary-muted);
padding-inline-start: var(--sp-m);
}
.title-bar {
display: grid;
grid-template-columns: 1fr auto;
}
.title {
@include t.use-typography("body-small");
color: var(--color-foreground-primary);
display: flex;
align-items: center;
}
.warning-name-change-notification-wrapper {
margin-block-start: var(--sp-l);
}
.error {
padding: var(--sp-xs) $sz-6;
margin-block-end: 0;
color: var(--status-color-error-500);
}
.resolved-value {
--input-hint-color: var(--color-foreground-primary);
color: var(--input-hint-color);
}
.resolved-value-error {
--input-hint-color: var(--status-color-error-500);
}
.resolved-value-warning {
--input-hint-color: var(--status-color-warning-500);
}
.form-modal-title {
color: var(--color-foreground-primary);
}
.font-select-wrapper {
position: absolute;
inset: 0;
// This padding from the modal should be shared as a variable
// Need to set this or the font-select will cause scroll
bottom: var(--sp-xxxl);
}

View File

@@ -1,237 +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
(ns app.main.ui.workspace.tokens.management.create.form-color-input-token
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.color :as cl]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[app.main.data.tinycolor :as tinycolor]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.utilities.swatch :refer [swatch*]]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.colorpicker :as colorpicker]
[app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector*]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[rumext.v2 :as mf]))
(defn- resolve-value
[tokens prev-token value]
(let [token
{:value value
:name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"}
tokens
(-> tokens
;; Remove previous token when renaming a token
(dissoc (:name prev-token))
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
(->> tokens
(sd/resolve-tokens-interactive)
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (first errors)}))))))))
(defn- hex->color-obj
[hex]
(when-let [tc (tinycolor/valid-color hex)]
(let [hex (tinycolor/->hex-string tc)
alpha (tinycolor/alpha tc)
[r g b] (cl/hex->rgb hex)
[h s v] (cl/hex->hsv hex)]
{:hex hex
:r r :g g :b b
:h h :s s :v v
:alpha alpha})))
(mf/defc ramp*
[{:keys [color on-change]}]
(let [wrapper-node-ref (mf/use-ref nil)
dragging-ref (mf/use-ref false)
on-start-drag
(mf/use-fn #(mf/set-ref-val! dragging-ref true))
on-finish-drag
(mf/use-fn #(mf/set-ref-val! dragging-ref false))
internal-color*
(mf/use-state #(hex->color-obj color))
internal-color
(deref internal-color*)
on-change'
(mf/use-fn
(mf/deps on-change)
(fn [{:keys [hex alpha] :as selector-color}]
(let [dragging? (mf/ref-val dragging-ref)]
(when-not (and dragging? hex)
(reset! internal-color* selector-color)
(on-change hex alpha)))))]
(mf/use-effect
(mf/deps color)
(fn []
;; Update internal color when user changes input value
(when-let [color (tinycolor/valid-color color)]
(when-not (= (tinycolor/->hex-string color) (:hex internal-color))
(reset! internal-color* (hex->color-obj color))))))
(colorpicker/use-color-picker-css-variables! wrapper-node-ref internal-color)
[:div {:ref wrapper-node-ref}
[:> ramp-selector*
{:color internal-color
:on-start-drag on-start-drag
:on-finish-drag on-finish-drag
:on-change on-change'}]]))
(mf/defc form-color-input-token*
[{:keys [name tokens token] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
touched?
(and (contains? (:data @form) input-name)
(get-in @form [:touched input-name]))
error
(get-in @form [:errors input-name])
value
(get-in @form [:data input-name] "")
hex (if (tinycolor/valid-color value)
(tinycolor/->hex-string (tinycolor/valid-color value))
"#8f9da3")
alpha (if (tinycolor/valid-color value)
(tinycolor/alpha (tinycolor/valid-color value))
1)
resolve-stream
(mf/with-memo [token]
(if-let [value (:value token)]
(rx/behavior-subject value)
(rx/subject)))
hint*
(mf/use-state {})
hint
(deref hint*)
color-ramp-open* (mf/use-state false)
color-ramp-open? (deref color-ramp-open*)
on-click-swatch
(mf/use-fn
(mf/deps color-ramp-open?)
(fn []
(let [open? (not color-ramp-open?)]
(reset! color-ramp-open* open?))))
swatch
(mf/html
[:> swatch*
{:background {:color hex :opacity alpha}
:show-tooltip false
:data-testid "token-form-color-bullet"
:class (stl/css :slot-start)
:on-click on-click-swatch}])
on-change-value
(mf/use-fn
(mf/deps resolve-stream input-name value)
(fn [hex alpha]
(let [;; StyleDictionary will always convert to hex/rgba, so we take the format from the value input field
prev-input-color (some-> value
(tinycolor/valid-color))
;; If the input is a reference we will take the format from the computed value
prev-computed-color (when-not prev-input-color
(some-> value (tinycolor/valid-color)))
prev-format (some-> (or prev-input-color prev-computed-color)
(tinycolor/color-format))
to-rgba? (and
(< alpha 1)
(or (= prev-format "hex") (not prev-format)))
to-hex? (and (not prev-format) (= alpha 1))
format (cond
to-rgba? "rgba"
to-hex? "hex"
prev-format prev-format
:else "hex")
color-value (-> (tinycolor/valid-color hex)
(tinycolor/set-alpha (or alpha 1))
(tinycolor/->string format))]
(when (not= value color-value)
(fm/on-input-change form input-name color-value true)
(rx/push! resolve-stream color-value)))))
on-change
(mf/use-fn
(mf/deps resolve-stream input-name)
(fn [event]
(let [raw-value (-> event dom/get-target dom/get-input-value)
value (if (tinycolor/hex-without-hash-prefix? raw-value)
(dm/str "#" raw-value)
raw-value)]
(fm/on-input-change form input-name value true)
(rx/push! resolve-stream value))))
props
(mf/spread-props props {:on-change on-change
;; TODO: Review this value vs default-value
:value (or value "")
:hint-message (:message hint)
:variant "comfortable"
:slot-start swatch
:hint-type (:type hint)})
props
(if (and error touched?)
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
props)]
(mf/with-effect [resolve-stream tokens token input-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
((:error/fn error) (:error/value error))))))
(rx/subs! (fn [{:keys [error value]}]
(let [touched? (get-in @form [:touched input-name])]
(when touched?
(if error
(do
(swap! form assoc-in [:extra-errors input-name] {:message error})
(reset! hint* {:message error :type "error"}))
(let [message (tr "workspace.tokens.resolved-value" value)]
(swap! form update :extra-errors dissoc input-name)
(reset! hint* {:message message :type "hint"}))))))))]
(fn []
(rx/dispose! subs))))
[:*
[:> input* props]
(when color-ramp-open?
[:> ramp* {:color value :on-change on-change-value}])]))

View File

@@ -1,211 +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
(ns app.main.ui.workspace.tokens.management.create.form-input-token
(:require
[app.common.data :as d]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[app.main.data.workspace.tokens.format :as dwtf]
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.forms :as fc]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- resolve-value
[tokens prev-token value]
(let [token
{:value value
:name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"}
tokens
(-> tokens
;; Remove previous token when renaming a token
(dissoc (:name prev-token))
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
(->> tokens
(sd/resolve-tokens-interactive)
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (first errors)}))))))))
(mf/defc form-input-token*
[{:keys [name tokens token] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
touched?
(and (contains? (:data @form) input-name)
(get-in @form [:touched input-name]))
error
(get-in @form [:errors input-name])
value
(get-in @form [:data input-name] "")
resolve-stream
(mf/with-memo [token]
(if (contains? token :value)
(rx/behavior-subject (:value token))
(rx/subject)))
hint*
(mf/use-state {})
hint
(deref hint*)
on-change
(mf/use-fn
(mf/deps resolve-stream input-name)
(fn [event]
(let [value (-> event dom/get-target dom/get-input-value)]
(fm/on-input-change form input-name value true)
(rx/push! resolve-stream value))))
props
(mf/spread-props props {:on-change on-change
:default-value value
:variant "comfortable"
:hint-message (:message hint)
:hint-type (:type hint)})
props
(if (and error touched?)
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
props)]
(mf/with-effect [resolve-stream tokens token input-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
((:error/fn error) (:error/value error))))))
(rx/subs! (fn [{:keys [error value]}]
(let [touched? (get-in @form [:touched input-name])]
(when touched?
(if error
(do
(swap! form assoc-in [:extra-errors input-name] {:message error})
(reset! hint* {:message error :type "error"}))
(let [message (tr "workspace.tokens.resolved-value" value)]
(swap! form update :extra-errors dissoc input-name)
(reset! hint* {:message message :type "hint"}))))))))]
(fn []
(rx/dispose! subs))))
[:> input* props]))
(defn- on-composite-input-token-change
([form field value]
(on-composite-input-token-change form field value false))
([form field value trim?]
(letfn [(clean-errors [errors]
(-> errors
(dissoc field)
(not-empty)))]
(swap! form (fn [state]
(-> state
(assoc-in [:data :value field] (if trim? (str/trim value) value))
(update :errors clean-errors)
(update :extra-errors clean-errors)))))))
(mf/defc token-composite-value-input*
[{:keys [name tokens token] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
error
(get-in @form [:errors :value input-name])
value
(get-in @form [:data :value input-name] "")
resolve-stream
(mf/with-memo [token]
(if-let [value (get-in token [:value input-name])]
(rx/behavior-subject value)
(rx/subject)))
hint*
(mf/use-state {})
hint
(deref hint*)
on-change
(mf/use-fn
(mf/deps resolve-stream input-name)
(fn [event]
(let [value (-> event dom/get-target dom/get-input-value)]
(on-composite-input-token-change form input-name value true)
(rx/push! resolve-stream value))))
props
(mf/spread-props props {:on-change on-change
:default-value value
:variant "comfortable"
:hint-message (:message hint)
:hint-type (:type hint)})
props
(if error
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
props)
props (if (and (not error) (= input-name :reference))
(mf/spread-props props {:hint-formated true})
props)]
(mf/with-effect [resolve-stream tokens token input-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
(assoc error :message ((:error/fn error) (:error/value error)))))))
(rx/subs!
(fn [{:keys [error value]}]
(cond
(and error (str/empty? (:error/value error)))
(do
(swap! form update-in [:errors :value] dissoc input-name)
(swap! form update-in [:data :value] dissoc input-name)
(swap! form update :extra-errors dissoc :value)
(reset! hint* {}))
(some? error)
(let [error' (:message error)]
(swap! form assoc-in [:extra-errors :value input-name] {:message error'})
(reset! hint* {:message error' :type "error"}))
:else
(let [message (tr "workspace.tokens.resolved-value" (dwtf/format-token-value value))
input-value (get-in @form [:data :value input-name] "")]
(swap! form update :errors dissoc :value)
(swap! form update :extra-errors dissoc :value)
(if (= input-value (str value))
(reset! hint* {})
(reset! hint* {:message message :type "hint"})))))))]
(fn []
(rx/dispose! subs))))
[:> input* props]))

View File

@@ -1,28 +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
(ns app.main.ui.workspace.tokens.management.create.input-token-color-bullet
(:require-macros [app.main.style :as stl])
(:require
[app.main.data.workspace.tokens.color :as dwtc]
[app.main.ui.components.color-bullet :refer [color-bullet]]
[rumext.v2 :as mf]))
(def ^:private schema::input-token-color-bullet
[:map
[:color [:maybe :string]]
[:on-click fn?]])
(mf/defc input-token-color-bullet*
{::mf/props :obj
::mf/schema schema::input-token-color-bullet}
[{:keys [color on-click]}]
[:div {:data-testid "token-form-color-bullet"
:class (stl/css :input-token-color-bullet)
:on-click on-click}
(if-let [color' (dwtc/color-bullet-color color)]
[:> color-bullet {:color color' :mini true}]
[:div {:class (stl/css :input-token-color-bullet-placeholder)}])])

View File

@@ -1,28 +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
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
.input-token-color-bullet {
--bullet-size: var(--sp-l);
--bullet-default-color: var(--color-foreground-secondary);
--bullet-radius: var(--br-4);
margin-inline-end: var(--sp-s);
cursor: pointer;
}
.input-token-color-bullet-placeholder {
width: var(--bullet-size, --sp-l);
height: var(--bullet-size, --sp-l);
min-width: var(--bullet-size, --sp-l);
min-height: var(--bullet-size, --sp-l);
margin-top: 0;
background-color: color-mix(in hsl, var(--bullet-default-color) 30%, transparent);
border-radius: $br-4;
cursor: pointer;
}

View File

@@ -1,78 +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
(ns app.main.ui.workspace.tokens.management.create.input-tokens-value
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.data.workspace.tokens.format :as dwtf]
[app.main.data.workspace.tokens.warnings :as wtw]
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
[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.util.i18n :refer [tr]]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private schema::input-token
[:map
[:label {:optional true} [:maybe :string]]
[:aria-label {:optional true} [:maybe :string]]
[:placeholder {:optional true} :string]
[:value {:optional true} [:maybe :string]]
[:class {:optional true} :string]
[:error {:optional true} :boolean]
[:slot-start {:optional true} [:maybe some?]]
[:icon {:optional true}
[:maybe [:and :string [:fn #(contains? icon-list %)]]]]
[:token-resolve-result {:optional true} :any]])
(mf/defc token-value-hint*
[{:keys [result]}]
(let [{:keys [errors warnings resolved-value]} result
empty-message? (nil? result)
message (cond
empty-message? (tr "workspace.tokens.resolved-value" "-")
warnings (->> (wtw/humanize-warnings warnings)
(str/join "\n"))
errors (->> (wte/humanize-errors errors)
(str/join "\n"))
:else (tr "workspace.tokens.resolved-value" (dwtf/format-token-value (or resolved-value result))))
type (cond
empty-message? "hint"
errors "error"
warnings "warning"
:else "hint")]
[:> hint-message*
{:id "token-value-hint"
:message message
:class (stl/css-case :resolved-value (not (or empty-message? (seq warnings) (seq errors))))
:type type}]))
(mf/defc input-token*
{::mf/forward-ref true
::mf/schema schema::input-token}
[{:keys [class label token-resolve-result] :rest props} ref]
(let [error (not (nil? (:errors token-resolve-result)))
id (mf/use-id)
input-ref (mf/use-ref)
props (mf/spread-props props {:id id
:type "text"
:class (stl/css :input)
:variant "comfortable"
:hint-type (when error "error")
:ref (or ref input-ref)})]
[:*
[:div {:class (dm/str class " " (stl/css-case :wrapper true
:input-error error))}
(when label
[:> label* {:for id} label])
[:> input-field* props]]
(when token-resolve-result
[:> token-value-hint* {:result token-resolve-result}])]))

View File

@@ -1,36 +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
@use "ds/typography.scss" as *;
@use "ds/_sizes.scss" as *;
@use "refactor/common-refactor.scss" as deprecated;
.wrapper {
--label-color: var(--color-foreground-primary);
--input-bg-color: var(--color-background-tertiary);
--input-fg-color: var(--color-foreground-primary);
--input-icon-color: var(--color-foreground-secondary);
--input-outline-color: none;
display: flex;
flex-direction: column;
gap: var(--sp-xs);
&.input-error {
--input-outline-color: var(--color-accent-error);
}
}
.label {
@include use-typography("body-small");
@include deprecated.textEllipsis;
color: var(--label-color);
}
.resolved-value {
white-space: pre;
}

View File

@@ -1,224 +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
(ns app.main.ui.workspace.tokens.management.create.text-case
(:require-macros [app.main.style :as stl])
(:require
[app.common.files.tokens :as cft]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.constants :refer [max-input-length]]
[app.main.data.modal :as modal]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.create.form-input-token :refer [form-input-token*]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as k]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- token-value-error-fn
[{:keys [value]}]
(when (or (str/empty? value)
(str/blank? value))
(tr "workspace.tokens.empty-input")))
(defn- make-schema
[tokens-tree]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (cft/token-name-path-exists? % tokens-tree))]]]
[:value [::sm/text {:error/fn token-value-error-fn}]]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(nil? (cto/token-value-self-reference? name value))))]]))
(mf/defc form*
[{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}]
(let [token
(mf/with-memo [token]
(or token {:type :text-case}))
token-type
(get token :type)
token-properties
(dwta/get-token-properties token)
token-title (str/lower (:title token-properties))
tokens
(mf/deref refs/workspace-active-theme-sets-tokens)
tokens
(mf/with-memo [tokens token]
;; Ensure that the resolved value uses the currently editing token
;; even if the name has been overriden by a token with the same name
;; in another set below.
(cond-> tokens
(and (:name token) (:value token))
(assoc (:name token) token)))
schema
(mf/with-memo [tokens-tree-in-selected-set]
(make-schema tokens-tree-in-selected-set))
initial
(mf/with-memo [token]
{:name (:name token "")
:value (:value token "")
:description (:description token "")})
form
(fm/use-form :schema schema
:initial initial)
warning-name-change?
(not= (get-in @form [:data :name])
(:name initial))
on-cancel
(mf/use-fn
(fn [e]
(dom/prevent-default e)
(modal/hide!)))
on-delete-token
(mf/use-fn
(mf/deps selected-token-set-id token)
(fn [e]
(dom/prevent-default e)
(modal/hide!)
(st/emit! (dwtl/delete-token selected-token-set-id (:id token)))))
handle-key-down-delete
(mf/use-fn
(mf/deps on-delete-token)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-delete-token e))))
handle-key-down-cancel
(mf/use-fn
(mf/deps on-cancel)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-cancel e))))
on-submit
(mf/use-fn
(mf/deps validate-token token tokens token-type)
(fn [form _event]
(let [name (get-in @form [:clean-data :name])
description (get-in @form [:clean-data :description])
value (get-in @form [:clean-data :value])]
(->> (validate-token {:token-value value
:token-name name
:token-description description
:prev-token token
:tokens tokens})
(rx/subs!
(fn [valid-token]
(st/emit!
(if is-create
(dwtl/create-token (ctob/make-token {:name name
:type token-type
:value (:value valid-token)
:description description}))
(dwtl/update-token (:id token)
{:name name
:value (:value valid-token)
:description description}))
(dwtp/propagate-workspace-tokens)
(modal/hide))))))))]
[:> fc/form* {:class (stl/css :form-wrapper)
:form form
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(tr "workspace.tokens.create-token" token-type)]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-name"
:name :name
:label (tr "workspace.tokens.token-name")
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:auto-focus true}]
(when (and warning-name-change? (= action "edit"))
[:div {:class (stl/css :warning-name-change-notification-wrapper)}
[:> context-notification*
{:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])]
[:div {:class (stl/css :input-row)}
[:> form-input-token*
{:placeholder (tr "workspace.tokens.text-case-value-enter")
:label (tr "workspace.tokens.token-value")
:name :value
:token token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-description"
:name :description
:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
:max-length max-input-length
:variant "comfortable"
:is-optional true}]]
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:on-key-down handle-key-down-delete
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:> button* {:on-click on-cancel
:on-key-down handle-key-down-cancel
:type "button"
:id "token-modal-cancel"
:variant "secondary"}
(tr "labels.cancel")]
[:> fc/form-submit* {:variant "primary"
:on-submit on-submit}
(tr "labels.save")]]]]))

View File

@@ -1,58 +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
@use "ds/typography.scss" as t;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
.form-wrapper {
width: $sz-384;
position: relative;
}
.token-rows {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.input-row {
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.title-bar {
display: grid;
grid-template-columns: 1fr auto;
}
.form-modal-title {
@include t.use-typography("headline-medium");
color: var(--color-foreground-primary);
display: flex;
align-items: center;
}
.button-row {
display: grid;
grid-template-columns: auto auto;
justify-content: end;
gap: var(--sp-m);
padding-block-start: var(--sp-s);
}
.with-delete {
grid-template-columns: 1fr auto auto;
}
.warning-name-change-notification-wrapper {
margin-block-start: var(--sp-l);
}
.delete-btn {
justify-self: start;
}

View File

@@ -1,427 +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
(ns app.main.ui.workspace.tokens.management.create.typography
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.files.tokens :as cft]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.constants :refer [max-input-length]]
[app.main.data.modal :as modal]
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.data.workspace.tokens.propagation :as dwtp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.forms :as forms]
[app.main.ui.workspace.tokens.management.create.combobox-token-fonts :refer [font-picker-composite-combobox*]]
[app.main.ui.workspace.tokens.management.create.form-input-token :refer [token-composite-value-input*]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[app.util.keyboard :as k]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(mf/defc composite-form*
[{:keys [token tokens] :as props}]
(let [letter-spacing-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :letter-spacing
:value (cto/join-font-family (get value :letter-spacing))}
{:type :letter-spacing}))
font-family-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :font-family
:value (get value :font-family)}
{:type :font-family}))
font-size-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :font-size
:value (get value :font-size)}
{:type :font-size}))
font-weight-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :font-weight
:value (get value :font-weight)}
{:type :font-weight}))
;; TODO: Review this type
line-height-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :number
:value (get value :line-height)}
{:type :number}))
text-case-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :text-case
:value (get value :text-case)}
{:type :text-case}))
text-decoration-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :text-decoration
:value (get value :text-decoration)}
{:type :text-decoration}))]
[:*
[:div {:class (stl/css :input-row)}
[:> font-picker-composite-combobox*
{:icon i/text-font-family
:placeholder (tr "workspace.tokens.token-font-family-value-enter")
:aria-label (tr "workspace.tokens.token-font-family-value")
:name :font-family
:token font-family-sub-token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token-composite-value-input*
{:aria-label "Font Size"
:icon i/text-font-size
:placeholder (tr "workspace.tokens.font-size-value-enter")
:name :font-size
:token font-size-sub-token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token-composite-value-input*
{:aria-label "Font Weight"
:icon i/text-font-weight
:placeholder (tr "workspace.tokens.font-weight-value-enter")
:name :font-weight
:token font-weight-sub-token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token-composite-value-input*
{:aria-label "Line Height"
:icon i/text-lineheight
:placeholder (tr "workspace.tokens.line-height-value-enter")
:name :line-height
:token line-height-sub-token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token-composite-value-input*
{:aria-label "Letter Spacing"
:icon i/text-letterspacing
:placeholder (tr "workspace.tokens.letter-spacing-value-enter-composite")
:name :letter-spacing
:token letter-spacing-sub-token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token-composite-value-input*
{:aria-label "Text Case"
:icon i/text-mixed
:placeholder (tr "workspace.tokens.text-case-value-enter")
:name :text-case
:token text-case-sub-token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token-composite-value-input*
{:aria-label "Text Decoration"
:icon i/text-underlined
:placeholder (tr "workspace.tokens.text-decoration-value-enter")
:name :text-decoration
:token text-decoration-sub-token
:tokens tokens}]]]))
(mf/defc reference-form*
[{:keys [token tokens] :as props}]
[:div {:class (stl/css :input-row)}
[:> token-composite-value-input*
{:placeholder (tr "workspace.tokens.reference-composite")
:aria-label (tr "labels.reference")
:icon i/text-typography
:name :reference
:token token
:tokens tokens}]])
(defn- make-schema
[tokens-tree active-tab]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255
:error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/token-name-ref assoc
:error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (cft/token-name-path-exists? % tokens-tree))]]]
[:value
[:map
[:font-family {:optional true} [:maybe :string]]
[:font-size {:optional true} [:maybe :string]]
[:font-weight {:optional true} [:maybe :string]]
[:line-height {:optional true} [:maybe :string]]
[:letter-spacing {:optional true} [:maybe :string]]
[:text-case {:optional true} [:maybe :string]]
[:text-decoration {:optional true} [:maybe :string]]
(if (= active-tab :reference)
[:reference {:optional false} ::sm/text]
[:reference {:optional true} [:maybe :string]])]]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:fn {:error/field [:value :reference]
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(let [reference (get value :reference)]
(if (and reference name)
(not (cto/token-value-self-reference? name reference))
true)))]
[:fn {:error/field [:value :line-height]
:error/fn #(tr "workspace.tokens.composite-line-height-needs-font-size")}
(fn [{:keys [value]}]
(let [line-heigh (get value :line-height)
font-size (get value :font-size)]
(if (and line-heigh (not font-size))
false
true)))]
;; This error does not shown on interface, it's just to avoid saving empty composite tokens
;; We don't need to translate it.
[:fn {:error/fn (fn [_] "At least one composite field must be set")
:error/field :value}
(fn [attrs]
(let [result (reduce-kv (fn [_ _ v]
(if (str/empty? v)
false
(reduced true)))
false
(get attrs :value))]
result))]]))
(mf/defc form*
[{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}]
(let [token
(mf/with-memo [token]
(or token {:type :typography}))
active-tab* (mf/use-state #(if (cft/is-reference? token) :reference :composite))
active-tab (deref active-tab*)
token-type
(get token :type)
token-properties
(dwta/get-token-properties token)
token-title (str/lower (:title token-properties))
tokens
(mf/deref refs/workspace-active-theme-sets-tokens)
tokens
(mf/with-memo [tokens token]
;; Ensure that the resolved value uses the currently editing token
;; even if the name has been overriden by a token with the same name
;; in another set below.
(cond-> tokens
(and (:name token) (:value token))
(assoc (:name token) token)))
schema
(mf/with-memo [tokens-tree-in-selected-set active-tab]
(make-schema tokens-tree-in-selected-set active-tab))
initial
(mf/with-memo [token]
(let [value (:value token)
processed-value
(cond
(string? value)
{:reference value}
(map? value)
(let [value (cond-> value
(:font-family value)
(update :font-family cto/join-font-family))]
(select-keys value
[:font-family
:font-size
:font-weight
:line-height
:letter-spacing
:text-case
:text-decoration]))
:else
{})]
{:name (:name token "")
:value processed-value
:description (:description token "")}))
form
(fm/use-form :schema schema
:initial initial)
warning-name-change?
(not= (get-in @form [:data :name])
(:name initial))
on-toggle-tab
(mf/use-fn
(mf/deps)
(fn [new-tab]
(let [new-tab (keyword new-tab)]
(reset! active-tab* new-tab))))
on-cancel
(mf/use-fn
(fn [e]
(dom/prevent-default e)
(modal/hide!)))
on-delete-token
(mf/use-fn
(mf/deps selected-token-set-id token)
(fn [e]
(dom/prevent-default e)
(modal/hide!)
(st/emit! (dwtl/delete-token selected-token-set-id (:id token)))))
handle-key-down-delete
(mf/use-fn
(mf/deps on-delete-token)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-delete-token e))))
handle-key-down-cancel
(mf/use-fn
(mf/deps on-cancel)
(fn [e]
(when (or (k/enter? e) (k/space? e))
(on-cancel e))))
on-submit
(mf/use-fn
(mf/deps validate-token token tokens token-type)
(fn [form _event]
(let [name (get-in @form [:clean-data :name])
description (get-in @form [:clean-data :description])
value (get-in @form [:clean-data :value])]
(->> (validate-token {:token-value (if (contains? value :reference)
(get value :reference)
value)
:token-name name
:token-description description
:prev-token token
:tokens tokens})
(rx/subs!
(fn [valid-token]
(st/emit!
(if is-create
(dwtl/create-token (ctob/make-token {:name name
:type token-type
:value (:value valid-token)
:description description}))
(dwtl/update-token (:id token)
{:name name
:value (:value valid-token)
:description description}))
(dwtp/propagate-workspace-tokens)
(modal/hide))))))))]
[:> forms/form* {:class (stl/css :form-wrapper)
:form form
:on-submit on-submit}
[:div {:class (stl/css :token-rows)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(tr "workspace.tokens.create-token" token-type)]
[:div {:class (stl/css :input-row)}
[:> forms/form-input* {:id "token-name"
:name :name
:label (tr "workspace.tokens.token-name")
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
:max-length max-input-length
:variant "comfortable"
:auto-focus true}]
(when (and warning-name-change? (= action "edit"))
[:div {:class (stl/css :warning-name-change-notification-wrapper)}
[:> context-notification*
{:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])]
[:div {:class (stl/css :title-bar)}
[:div {:class (stl/css :title)} (tr "labels.typography")]
[:& radio-buttons {:class (stl/css :listing-options)
:selected (d/name active-tab)
:on-change on-toggle-tab
:name "reference-composite-tab"}
[:& radio-button {:icon i/layers
:value "composite"
:title (tr "workspace.tokens.individual-tokens")
:id "composite-opt"}]
[:& radio-button {:icon i/tokens
:value "reference"
:title (tr "workspace.tokens.use-reference")
:id "reference-opt"}]]]
[:div {:class (stl/css :inputs-wrapper)}
(if (= active-tab :composite)
[:> composite-form* {:token token
:tokens tokens}]
[:> reference-form* {:token token
:tokens tokens}])]
[:div {:class (stl/css :input-row)}
[:> forms/form-input* {:id "token-description"
:name :description
:label (tr "workspace.tokens.token-description")
:placeholder (tr "workspace.tokens.token-description")
:max-length max-input-length
:variant "comfortable"
:is-optional true}]]
[:div {:class (stl/css-case :button-row true
:with-delete (= action "edit"))}
(when (= action "edit")
[:> button* {:on-click on-delete-token
:on-key-down handle-key-down-delete
:class (stl/css :delete-btn)
:type "button"
:icon i/delete
:variant "secondary"}
(tr "labels.delete")])
[:> button* {:on-click on-cancel
:on-key-down handle-key-down-cancel
:type "button"
:id "token-modal-cancel"
:variant "secondary"}
(tr "labels.cancel")]
[:> forms/form-submit* {:variant "primary"
:on-submit on-submit}
(tr "labels.save")]]]]))

View File

@@ -0,0 +1,53 @@
;; 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.forms.color
(:require
[app.common.files.tokens :as cft]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.main.ui.workspace.tokens.management.forms.controls :as token.controls]
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
[app.util.i18n :refer [tr]]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- token-value-error-fn
[{:keys [value]}]
(when (or (str/empty? value)
(str/blank? value))
(tr "workspace.tokens.empty-input")))
(defn- make-schema
[tokens-tree _]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (cft/token-name-path-exists? % tokens-tree))]]]
[:value [::sm/text {:error/fn token-value-error-fn}]]
[:color-result {:optional true} ::sm/any]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:fn {:error/field :value
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(when (and name value)
(nil? (cto/token-value-self-reference? name value))))]]))
(mf/defc form*
[props]
(let [props (mf/spread-props props {:make-schema make-schema
:input-component token.controls/color-input*})]
[:> generic/form* props]))

View File

@@ -0,0 +1,19 @@
(ns app.main.ui.workspace.tokens.management.forms.controls
(:require
[app.common.data.macros :as dm]
[app.main.ui.workspace.tokens.management.forms.controls.color-input :as color]
[app.main.ui.workspace.tokens.management.forms.controls.fonts-combobox :as fonts]
[app.main.ui.workspace.tokens.management.forms.controls.input :as input]
[app.main.ui.workspace.tokens.management.forms.controls.select :as select]))
(dm/export color/color-input*)
(dm/export color/indexed-color-input*)
(dm/export input/input*)
(dm/export input/input-indexed*)
(dm/export input/input-composite*)
(dm/export fonts/fonts-combobox*)
(dm/export fonts/composite-fonts-combobox*)
(dm/export select/select-indexed*)

View File

@@ -0,0 +1,459 @@
;; 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.forms.controls.color-input
(:require-macros [app.main.style :as stl])
(:require
[app.common.colors :as color]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.types.color :as cl]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[app.main.data.tinycolor :as tinycolor]
[app.main.data.workspace.tokens.format :as dwtf]
[app.main.refs :as refs]
[app.main.ui.ds.controls.input :as ds]
[app.main.ui.ds.utilities.swatch :refer [swatch*]]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.colorpicker :as colorpicker]
[app.main.ui.workspace.colorpicker.ramp :refer [ramp-selector*]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
;; --- Color Inputs -------------------------------------------------------------
;;
;; The color input exists in two variants: the normal (primitive) input and the
;; indexed input. Both support hex, rgb(), hsl(), named colors, opacity changes,
;; color-picker selection, and token references.
;;
;; 1) Normal Color Input (primitive)
;; - Used when the tokens `:value` *is a single color* or a reference.
;; - Writes directly to `:value`.
;; - Validation ensures the color is in an accepted format or is a valid
;; color token reference.
;; 2) Indexed Color Input
;; - Used when the tokens value stores an array of items (e.g. inside
;; shadows, where each shadow layer has its own :color field).
;; - The input writes to a nested subfield:
;; [:value <subfield> <index> :color]
;; - Only that specific color entry is validated.
;; - Other properties (offsets, blur, inset, etc.) remain untouched.
;;
;; Both variants provide identical color-picker and text-input behavior, but
;; differ in how they persist the value within the forms nested structure.
(defn- resolve-value
[tokens prev-token value]
(let [token
{:value value
:name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"}
tokens
(-> tokens
;; Remove previous token when renaming a token
(dissoc (:name prev-token))
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
(->> tokens
(sd/resolve-tokens-interactive)
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (first errors)}))))))))
(defn- hex->color-obj
[hex]
(when-let [tc (tinycolor/valid-color hex)]
(let [hex (tinycolor/->hex-string tc)
alpha (tinycolor/alpha tc)
[r g b] (cl/hex->rgb hex)
[h s v] (cl/hex->hsv hex)]
{:hex hex
:r r :g g :b b
:h h :s s :v v
:alpha alpha})))
(mf/defc ramp*
[{:keys [color on-change]}]
(let [wrapper-node-ref (mf/use-ref nil)
dragging-ref (mf/use-ref false)
on-start-drag
(mf/use-fn #(mf/set-ref-val! dragging-ref true))
on-finish-drag
(mf/use-fn #(mf/set-ref-val! dragging-ref false))
internal-color*
(mf/use-state #(hex->color-obj color))
internal-color
(deref internal-color*)
on-change'
(mf/use-fn
(mf/deps on-change)
(fn [{:keys [hex alpha] :as selector-color}]
(let [dragging? (mf/ref-val dragging-ref)]
(when-not (and dragging? hex)
(reset! internal-color* selector-color)
(on-change hex alpha)))))]
(mf/use-effect
(mf/deps color)
(fn []
;; Update internal color when user changes input value
(when-let [color (tinycolor/valid-color color)]
(when-not (= (tinycolor/->hex-string color) (:hex internal-color))
(reset! internal-color* (hex->color-obj color))))))
(colorpicker/use-color-picker-css-variables! wrapper-node-ref internal-color)
[:div {:ref wrapper-node-ref}
[:> ramp-selector*
{:color internal-color
:on-start-drag on-start-drag
:on-finish-drag on-finish-drag
:on-change on-change'}]]))
(mf/defc color-input*
[{:keys [name tokens token] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
touched?
(and (contains? (:data @form) input-name)
(get-in @form [:touched input-name]))
error
(get-in @form [:errors input-name])
value
(get-in @form [:data input-name] "")
color-resolved
(get-in @form [:data :color-result] "")
valid-color (or (tinycolor/valid-color value)
(tinycolor/valid-color color-resolved))
profile (mf/deref refs/profile)
default-bullet-color
(case (:theme profile)
"light"
color/background-quaternary-light
color/background-quaternary)
hex
(if valid-color
(tinycolor/->hex-string (tinycolor/valid-color valid-color))
default-bullet-color)
alpha
(if (tinycolor/valid-color valid-color)
(tinycolor/alpha (tinycolor/valid-color valid-color))
1)
resolve-stream
(mf/with-memo [token]
(if-let [value (:value token)]
(rx/behavior-subject value)
(rx/subject)))
hint*
(mf/use-state {})
hint
(deref hint*)
color-ramp-open* (mf/use-state false)
color-ramp-open? (deref color-ramp-open*)
on-click-swatch
(mf/use-fn
(mf/deps color-ramp-open?)
(fn []
(let [open? (not color-ramp-open?)]
(reset! color-ramp-open* open?))))
swatch
(mf/html
[:> swatch*
{:background {:color hex :opacity alpha}
:show-tooltip false
:data-testid "token-form-color-bullet"
:class (stl/css :slot-start)
:on-click on-click-swatch}])
on-change-value
(mf/use-fn
(mf/deps resolve-stream input-name value)
(fn [hex alpha]
(let [;; StyleDictionary will always convert to hex/rgba, so we take the format from the value input field
prev-input-color (some-> value
(tinycolor/valid-color))
;; If the input is a reference we will take the format from the computed value
prev-computed-color (when-not prev-input-color
(some-> value (tinycolor/valid-color)))
prev-format (some-> (or prev-input-color prev-computed-color)
(tinycolor/color-format))
to-rgba? (and
(< alpha 1)
(or (= prev-format "hex") (not prev-format)))
to-hex? (and (not prev-format) (= alpha 1))
format (cond
to-rgba? "rgba"
to-hex? "hex"
prev-format prev-format
:else "hex")
color-value (-> (tinycolor/valid-color hex)
(tinycolor/set-alpha (or alpha 1))
(tinycolor/->string format))]
(when (not= value color-value)
(fm/on-input-change form input-name color-value true)
(rx/push! resolve-stream color-value)))))
on-change
(mf/use-fn
(mf/deps resolve-stream input-name)
(fn [event]
(let [raw-value (-> event dom/get-target dom/get-input-value)
value (if (tinycolor/hex-without-hash-prefix? raw-value)
(dm/str "#" raw-value)
raw-value)]
(fm/on-input-change form input-name value true)
(rx/push! resolve-stream value))))
props
(mf/spread-props props {:on-change on-change
;; TODO: Review this value vs default-value
:value (or value "")
:hint-message (:message hint)
:variant "comfortable"
:slot-start swatch
:hint-type (:type hint)})
props
(if (and error touched?)
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
props)]
(mf/with-effect [resolve-stream tokens token input-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
((:error/fn error) (:error/value error))))))
(rx/subs! (fn [{:keys [error value]}]
(let [touched? (get-in @form [:touched input-name])]
(when touched?
(if error
(do
(swap! form assoc-in [:extra-errors input-name] {:message error})
(swap! form assoc-in [:data :color-result] "")
(reset! hint* {:message error :type "error"}))
(let [message (tr "workspace.tokens.resolved-value" (dwtf/format-token-value value))]
(swap! form update :extra-errors dissoc input-name)
(swap! form assoc-in [:data :color-result] value)
(reset! hint* {:message message :type "hint"}))))))))]
(fn []
(rx/dispose! subs))))
[:*
[:> ds/input* props]
(when color-ramp-open?
[:> ramp* {:color value :on-change on-change-value}])]))
(defn- on-indexed-input-change
([form field index value value-subfield]
(on-indexed-input-change form field index value value-subfield false))
([form field index value value-subfield trim?]
(letfn [(clean-errors [errors]
(-> errors
(dissoc field)
(not-empty)))]
(swap! form (fn [state]
(-> state
(assoc-in [:data :value value-subfield index field] (if trim? (str/trim value) value))
(update :errors clean-errors)
(update :extra-errors clean-errors)))))))
(mf/defc indexed-color-input*
[{:keys [name tokens token index value-subfield] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
error
(get-in @form [:errors :value value-subfield index input-name])
value
(get-in @form [:data :value value-subfield index input-name] "")
color-resolved
(get-in @form [:data :value value-subfield index :color-result] "")
valid-color (or (tinycolor/valid-color value)
(tinycolor/valid-color color-resolved))
profile (mf/deref refs/profile)
default-bullet-color
(case (:theme profile)
"light"
color/background-quaternary-light
color/background-quaternary)
hex
(if valid-color
(tinycolor/->hex-string (tinycolor/valid-color valid-color))
default-bullet-color)
alpha
(if (tinycolor/valid-color valid-color)
(tinycolor/alpha (tinycolor/valid-color valid-color))
1)
resolve-stream
(mf/with-memo [token]
(if-let [value (get-in token [:value value-subfield index input-name])]
(rx/behavior-subject value)
(rx/subject)))
hint*
(mf/use-state {})
hint
(deref hint*)
color-ramp-open* (mf/use-state false)
color-ramp-open? (deref color-ramp-open*)
on-click-swatch
(mf/use-fn
(mf/deps color-ramp-open?)
(fn []
(let [open? (not color-ramp-open?)]
(reset! color-ramp-open* open?))))
swatch
(mf/html
[:> swatch*
{:background {:color hex :opacity alpha}
:show-tooltip false
:data-testid "token-form-color-bullet"
:class (stl/css :slot-start)
:on-click on-click-swatch}])
on-change-value
(mf/use-fn
(mf/deps resolve-stream input-name value index)
(fn [hex alpha]
(let [;; StyleDictionary will always convert to hex/rgba, so we take the format from the value input field
prev-input-color (some-> value
(tinycolor/valid-color))
;; If the input is a reference we will take the format from the computed value
prev-computed-color (when-not prev-input-color
(some-> value (tinycolor/valid-color)))
prev-format (some-> (or prev-input-color prev-computed-color)
(tinycolor/color-format))
to-rgba? (and
(< alpha 1)
(or (= prev-format "hex") (not prev-format)))
to-hex? (and (not prev-format) (= alpha 1))
format (cond
to-rgba? "rgba"
to-hex? "hex"
prev-format prev-format
:else "hex")
color-value (-> (tinycolor/valid-color hex)
(tinycolor/set-alpha (or alpha 1))
(tinycolor/->string format))]
(when (not= value color-value)
(on-indexed-input-change form input-name index color-value value-subfield true)
(rx/push! resolve-stream color-value)))))
on-change
(mf/use-fn
(mf/deps resolve-stream input-name index)
(fn [event]
(let [raw-value (-> event dom/get-target dom/get-input-value)
value (if (tinycolor/hex-without-hash-prefix? raw-value)
(dm/str "#" raw-value)
raw-value)]
(on-indexed-input-change form input-name index value value-subfield true)
(rx/push! resolve-stream value))))
props
(mf/spread-props props {:on-change on-change
:value (or value "")
:hint-message (:message hint)
:slot-start swatch
:hint-type (:type hint)})
props
(if error
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
props)]
(mf/with-effect [resolve-stream tokens token input-name index value-subfield]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
(assoc error :message ((:error/fn error) (:error/value error)))))))
(rx/subs!
(fn [{:keys [error value]}]
(cond
(and error (str/empty? (:error/value error)))
(do
(swap! form update-in [:errors :value value-subfield index] dissoc input-name)
(swap! form update-in [:data :value value-subfield index] dissoc input-name)
(swap! form assoc-in [:data :value value-subfield index :color-result] "")
(swap! form update :extra-errors dissoc :value)
(reset! hint* {}))
(some? error)
(let [error' (:message error)]
(swap! form assoc-in [:extra-errors :value value-subfield index input-name] {:message error'})
(swap! form assoc-in [:data :value value-subfield index :color-result] "")
(reset! hint* {:message error' :type "error"}))
:else
(let [message (tr "workspace.tokens.resolved-value" (dwtf/format-token-value value))
input-value (get-in @form [:data :value value-subfield index input-name] "")]
(swap! form update :errors dissoc :value)
(swap! form update :extra-errors dissoc :value)
(swap! form assoc-in [:data :value value-subfield index :color-result] (dwtf/format-token-value value))
(if (= input-value (str value))
(reset! hint* {})
(reset! hint* {:message message :type "hint"})))))))]
(fn []
(rx/dispose! subs))))
[:*
[:> ds/input* props]
(when color-ramp-open?
[:> ramp* {:color value :on-change on-change-value}])]))

View File

@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.management.create.combobox-token-fonts
(ns app.main.ui.workspace.tokens.management.forms.controls.fonts-combobox
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
@@ -24,6 +24,29 @@
[cuerdas.core :as str]
[rumext.v2 :as mf]))
;; --- Font Picker Inputs -------------------------------------------------------
;;
;; We provide two versions of the font picker: the normal (primitive) input and
;; the composite input. Both allow selecting a font-family either by typing free
;; text or by choosing from the list of fonts loaded in the application. Comma-
;; separated values are treated as literal font-family lists.
;;
;; 1) Normal Font Picker (primitive)
;; - Used when the tokens value *is itself* a font-family (string) or a
;; reference to another token.
;; - The input writes directly to `:value`.
;; - Validation ensures the string is a valid font-family or a valid token
;; reference.
;;
;; 2) Composite Font Picker
;; - Used inside typography tokens, where `:value` is a map (e.g. contains
;; :font-family, :font-weight, :letter-spacing, etc.).
;; - The input writes to the specific subfield `[:value :font-family]`.
;; - Only this field is validated and updated—other typography fields remain
;; untouched.
;;
;; Both modes share the same UI behaviour, but differ in how they store and
;; validate data within the form state.
(defn- resolve-value
[tokens prev-token value]
@@ -46,7 +69,7 @@
(rx/of {:value resolved-value})
(rx/of {:error (first errors)}))))))))
(mf/defc font-picker-combobox*
(mf/defc fonts-combobox*
[{:keys [token tokens name] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
@@ -117,7 +140,6 @@
props
(mf/spread-props props {:on-change on-change
;; TODO: Review this value vs default-value
:value (or value "")
:hint-message (:message hint)
:slot-end font-selector-button
@@ -174,7 +196,7 @@
(update :errors clean-errors)
(update :extra-errors clean-errors)))))))
(mf/defc font-picker-composite-combobox*
(mf/defc composite-fonts-combobox*
[{:keys [token tokens name] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
@@ -242,7 +264,6 @@
props
(mf/spread-props props {:on-change on-change
;; TODO: Review this value vs default-value
:value (or value "")
:hint-message (:message hint)
:slot-end font-selector-button

View File

@@ -0,0 +1,430 @@
;; 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.forms.controls.input
(:require
[app.common.data :as d]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[app.main.data.workspace.tokens.format :as dwtf]
[app.main.ui.ds.controls.input :as ds]
[app.main.ui.forms :as fc]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
;; -----------------------------------------------------------------------------
;; WHY WE HAVE THREE INPUT TYPES: INPUT, COMPOSITE, AND INDEXED
;;
;; Our token editor supports multiple token categories (colors, distances,
;; shadows, typography, etc.). Each category stores its data inside the tokens
;; :value field, but not all tokens have the same internal structure. To keep the
;; UI consistent and predictable, we define three input architectures:
;;
;; 1) INPUTS
;; ----------------------------------------------------------
;; Used for tokens where the entire :value is just a single,
;; atomic field. Examples:
;; - :distance → a number or a reference
;; - :text-case → one of {none, uppercase, lowercase, capitalize}
;; - :color → a single color value or a reference
;;
;; Characteristics:
;; * The input writes directly to :value.
;; * Validation logic is simple because :value is a single unit.
;; * Switching to "reference mode" simply replaces :value.
;;
;; Data shape example:
;; {:value "16px"}
;; {:value "{spacing.sm}"}
;;
;;
;; 2) COMPOSITE INPUTS
;; ----------------------------------------------------------
;; Used when the token contains a set of *named fields* inside :value.
;; The UI must write into a specific subfield inside the :value map.
;;
;; Example: typography tokens
;; {:value {:font-family "Inter"
;; :font-weight 600
;; :letter-spacing -0.5
;; :line-height 1.4}}
;;
;; Why this type exists:
;; * Each input (font-family, weight, spacing, etc.) maps to a specific
;; key inside :value.
;; * The UI must update these fields individually without replacing the
;; entire value.
;; * Validation rules apply per-field.
;;
;; In practice:
;; - The component knows which subfield to update.
;; - The form accumulates multiple fields into a single map under :value.
;;
;;
;; 3) INDEXED INPUTS
;; ----------------------------------------------------------
;; Used for tokens where the :value can be in TWO possible shapes:
;; A) A direct reference to another token
;; B) A list (array/vector) of maps describing multiple structured items
;;
;; Main example: shadow tokens
;;
;; ;; Option A — reference mode
;; {:value {:reference "{shadow.soft}"}}
;;
;; ;; Option B — full definition mode
;; {:value {:shadow
;; [{:color "#0003"
;; :offset-x 4
;; :offset-y 6
;; :blur 12
;; :spread 0
;; :inset? false}
;;
;; {:color "rgba(0,0,0,0.1)"
;; :offset-x 0
;; :offset-y 1
;; :blur 3
;; :spread 0
;; :inset? false}]}}
;;
;; Why this type exists:
;; * The UI must handle multiple items (indexed layers).
;; * Each layer has its own internal fields (color, offsets, blur, etc.).
;; * The user can add/remove layers dynamically.
;; * Reference mode must disable/remove the structured mode.
;; * Both shapes are valid, but they cannot coexist at the same time.
;;
;; Indexed inputs therefore need:
;; * A tab system ("Shadow" vs "Reference")
;; * Clear logic to ensure :shadow XOR :reference
;; * Repetition UI (add/remove layer groups)
;;
;;
;; SUMMARY
;; -----------------------------------------------------------------------------
;; - Plain inputs operate on :value itself.
;; - Composite inputs operate on a map stored under :value, writing into
;; predefined keys.
;; - Indexed inputs operate on either:
;; • a reference stored in :value :reference, or
;; • an array of structured items stored in :value :shadow (or similar),
;; but never both at the same time.
;;
;; This 3-tiered input system keeps the editor flexible, predictable,
;; and compatible with the many different token types supported by the app.
;; -----------------------------------------------------------------------------
;;
;; Summary:
;; -------
;; - `input*` → single flat value tokens
;; - `input-composite*` → structured tokens with multiple named fields
;; (e.g. typography tokens with font-family,
;; font-weight, letter-spacing, line-height…)
;; - `input-indexed*` → array-based tokens where each entry is a map
;; (e.g. multiple shadow layers)
;;
;; This separation ensures each form input mirrors the actual structure of the
;; token data model, keeping validation and updates correctly scoped.
;; -----------------------------------------------------------------------------
(defn- resolve-value
[tokens prev-token value]
(let [token
{:value value
:name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"}
tokens
(-> tokens
;; Remove previous token when renaming a token
(dissoc (:name prev-token))
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
(->> tokens
(sd/resolve-tokens-interactive)
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (first errors)}))))))))
(mf/defc input*
[{:keys [name tokens token] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
touched?
(and (contains? (:data @form) input-name)
(get-in @form [:touched input-name]))
error
(get-in @form [:errors input-name])
value
(get-in @form [:data input-name] "")
resolve-stream
(mf/with-memo [token]
(if (contains? token :value)
(rx/behavior-subject (:value token))
(rx/subject)))
hint*
(mf/use-state {})
hint
(deref hint*)
on-change
(mf/use-fn
(mf/deps resolve-stream input-name)
(fn [event]
(let [value (-> event dom/get-target dom/get-input-value)]
(fm/on-input-change form input-name value true)
(rx/push! resolve-stream value))))
props
(mf/spread-props props {:on-change on-change
:default-value value
:variant "comfortable"
:hint-message (:message hint)
:hint-type (:type hint)})
props
(if (and error touched?)
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
props)]
(mf/with-effect [resolve-stream tokens token input-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
((:error/fn error) (:error/value error))))))
(rx/subs! (fn [{:keys [error value]}]
(let [touched? (get-in @form [:touched input-name])]
(when touched?
(if error
(do
(swap! form assoc-in [:extra-errors input-name] {:message error})
(reset! hint* {:message error :type "error"}))
(let [message (tr "workspace.tokens.resolved-value" value)]
(swap! form update :extra-errors dissoc input-name)
(reset! hint* {:message message :type "hint"}))))))))]
(fn []
(rx/dispose! subs))))
[:> ds/input* props]))
(defn- on-composite-input-change
([form field value]
(on-composite-input-change form field value false))
([form field value trim?]
(letfn [(clean-errors [errors]
(-> errors
(dissoc field)
(not-empty)))]
(swap! form (fn [state]
(-> state
(assoc-in [:data :value field] (if trim? (str/trim value) value))
(update :errors clean-errors)
(update :extra-errors clean-errors)))))))
(mf/defc input-composite*
[{:keys [name tokens token] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
error
(get-in @form [:errors :value input-name])
value
(get-in @form [:data :value input-name] "")
resolve-stream
(mf/with-memo [token]
(if-let [value (get-in token [:value input-name])]
(rx/behavior-subject value)
(rx/subject)))
hint*
(mf/use-state {})
hint
(deref hint*)
on-change
(mf/use-fn
(mf/deps resolve-stream input-name)
(fn [event]
(let [value (-> event dom/get-target dom/get-input-value)]
(on-composite-input-change form input-name value true)
(rx/push! resolve-stream value))))
props
(mf/spread-props props {:on-change on-change
:default-value value
:variant "comfortable"
:hint-message (:message hint)
:hint-type (:type hint)})
props
(if error
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
props)
props (if (and (not error) (= input-name :reference))
(mf/spread-props props {:hint-formated true})
props)]
(mf/with-effect [resolve-stream tokens token input-name]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
(assoc error :message ((:error/fn error) (:error/value error)))))))
(rx/subs!
(fn [{:keys [error value]}]
(cond
(and error (str/empty? (:error/value error)))
(do
(swap! form update-in [:errors :value] dissoc input-name)
(swap! form update-in [:data :value] dissoc input-name)
(swap! form update :extra-errors dissoc :value)
(reset! hint* {}))
(some? error)
(let [error' (:message error)]
(swap! form assoc-in [:extra-errors :value input-name] {:message error'})
(reset! hint* {:message error' :type "error"}))
:else
(let [message (tr "workspace.tokens.resolved-value" (dwtf/format-token-value value))
input-value (get-in @form [:data :value input-name] "")]
(swap! form update :errors dissoc :value)
(swap! form update :extra-errors dissoc :value)
(if (= input-value (str value))
(reset! hint* {})
(reset! hint* {:message message :type "hint"})))))))]
(fn []
(rx/dispose! subs))))
[:> ds/input* props]))
(defn- on-indexed-input-change
([form field index value value-subfield]
(on-indexed-input-change form field index value value-subfield false))
([form field index value value-subfield trim?]
(letfn [(clean-errors [errors]
(-> errors
(dissoc field)
(not-empty)))]
(swap! form (fn [state]
(-> state
(assoc-in [:data :value value-subfield index field] (if trim? (str/trim value) value))
(update :errors clean-errors)
(update :extra-errors clean-errors)))))))
(mf/defc input-indexed*
[{:keys [name tokens token index value-subfield] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
error
(get-in @form [:errors :value value-subfield index input-name])
value-from-form
(get-in @form [:data :value value-subfield index input-name] "")
resolve-stream
(mf/with-memo [token index input-name]
(if-let [value (get-in token [:value value-subfield index input-name])]
(rx/behavior-subject value)
(rx/subject)))
hint*
(mf/use-state {})
hint
(deref hint*)
on-change
(mf/use-fn
(mf/deps resolve-stream input-name index)
(fn [event]
(let [value (-> event dom/get-target dom/get-input-value)]
(on-indexed-input-change form input-name index value value-subfield true)
(rx/push! resolve-stream value))))
props
(mf/spread-props props {:on-change on-change
:value value-from-form
:variant "comfortable"
:hint-message (:message hint)
:hint-type (:type hint)})
props
(if error
(mf/spread-props props {:hint-type "error"
:hint-message (:message error)})
props)
props
(if (and (not error) (= input-name :reference))
(mf/spread-props props {:hint-formated true})
props)]
(mf/with-effect [resolve-stream tokens token input-name index value-subfield]
(let [subs (->> resolve-stream
(rx/debounce 300)
(rx/mapcat (partial resolve-value tokens token))
(rx/map (fn [result]
(d/update-when result :error
(fn [error]
(assoc error :message ((:error/fn error) (:error/value error)))))))
(rx/subs!
(fn [{:keys [error value]}]
(cond
(and error (str/empty? (:error/value error)))
(do
(swap! form update-in [:errors :value value-subfield index] dissoc input-name)
(swap! form update-in [:data :value value-subfield index] dissoc input-name)
(swap! form update :extra-errors dissoc :value)
(reset! hint* {}))
(some? error)
(let [error' (:message error)]
(swap! form assoc-in [:extra-errors :value value-subfield index input-name] {:message error'})
(reset! hint* {:message error' :type "error"}))
:else
(let [message (tr "workspace.tokens.resolved-value" (dwtf/format-token-value value))
input-value (get-in @form [:data :value value-subfield index input-name] "")]
(swap! form update :errors dissoc :value)
(swap! form update :extra-errors dissoc :value)
(if (= input-value (str value))
(reset! hint* {})
(reset! hint* {:message message :type "hint"})))))))]
(fn []
(rx/dispose! subs))))
[:> ds/input* props]))

View File

@@ -0,0 +1,45 @@
;; 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.forms.controls.select
(:require
[app.main.ui.ds.controls.select :refer [select*]]
[app.main.ui.forms :as fc]
[rumext.v2 :as mf]))
;; --- Select Input (Indexed) --------------------------------------------------
;;
;; This input type is part of the indexed system, used for fields that exist
;; inside an array of maps stored in a subfield of :value.
;;
;; - Writes to a nested location:
;; [:value <subfield> <index> <field>]
;; - Each item in the array has its own select input, independent of others.
;; - Validation ensures the selected value is valid for that field.
;; - Changing one item does not affect the other items in the array.
;; - Ideal for properties with predefined options (boolean, enum, etc.) where
;; multiple instances exist.
(mf/defc select-indexed*
[{:keys [name index indexed-type] :rest props}]
(let [form (mf/use-ctx fc/context)
input-name name
value
(get-in @form [:data :value indexed-type index input-name] false)
on-change
(mf/use-fn
(mf/deps input-name)
(fn [type]
(let [is-inner? (= type "inner")]
(swap! form assoc-in [:data :value indexed-type index input-name] is-inner?))))
props (mf/spread-props props {:default-selected (if value "inner" "drop")
:variant "ghost"
:on-change on-change})]
[:> select* props]))

View File

@@ -0,0 +1,40 @@
;; 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.forms.font-family
(:require
[app.common.types.token :as cto]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.ui.workspace.tokens.management.forms.controls :as token.controls]
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
[app.main.ui.workspace.tokens.management.forms.validators :refer [check-coll-self-reference default-validate-token]]
[rumext.v2 :as mf]))
(defn- check-font-family-token-self-reference [token]
(check-coll-self-reference (:name token) (:value token)))
(defn- validate-font-family-token
[props]
(-> props
(update :token-value cto/split-font-family)
(assoc :validators [(fn [token]
(when (empty? (:value token))
(wte/get-error-code :error.token/empty-input)))
check-font-family-token-self-reference])
(default-validate-token)))
(mf/defc form*
[{:keys [token token-type] :rest props}]
(let [token
(mf/with-memo [token]
(if token
(update token :value cto/join-font-family)
{:type token-type}))
props (mf/spread-props props {:token token
:token-type token-type
:validator validate-font-family-token
:input-component token.controls/fonts-combobox*})]
[:> generic/form* props]))

View File

@@ -0,0 +1,53 @@
;; 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.forms.form-container
(:require
[app.common.data :as d]
[app.common.files.tokens :as cft]
[app.common.types.tokens-lib :as ctob]
[app.main.refs :as refs]
[app.main.ui.workspace.tokens.management.forms.color :as color]
[app.main.ui.workspace.tokens.management.forms.font-family :as font-family]
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
[app.main.ui.workspace.tokens.management.forms.shadow :as shadow]
[app.main.ui.workspace.tokens.management.forms.typography :as typography]
[app.util.i18n :refer [tr]]
[rumext.v2 :as mf]))
(mf/defc form-container*
[{:keys [token token-type] :rest props}]
(let [token-type
(or (:type token) token-type)
tokens-in-selected-set
(mf/deref refs/workspace-all-tokens-in-selected-set)
token-path
(mf/with-memo [token]
(cft/token-name->path (:name token)))
tokens-tree-in-selected-set
(mf/with-memo [token-path tokens-in-selected-set]
(-> (ctob/tokens-tree tokens-in-selected-set)
(d/dissoc-in token-path)))
props
(mf/spread-props props {:token-type token-type
:tokens-tree-in-selected-set tokens-tree-in-selected-set
:token token})
text-case-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-case-value-enter")})
text-decoration-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-decoration-value-enter")})
font-weight-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")})]
(case token-type
:color [:> color/form* props]
:typography [:> typography/form* props]
:shadow [:> shadow/form* props]
:font-family [:> font-family/form* props]
:text-case [:> generic/form* text-case-props]
:text-decoration [:> generic/form* text-decoration-props]
:font-weight [:> generic/form* font-weight-props]
[:> generic/form* props])))

View File

@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.management.create.border-radius
(ns app.main.ui.workspace.tokens.management.forms.generic-form
(:require-macros [app.main.style :as stl])
(:require
[app.common.files.tokens :as cft]
@@ -23,7 +23,8 @@
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.main.ui.ds.notifications.context-notification :refer [context-notification*]]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.create.form-input-token :refer [form-input-token*]]
[app.main.ui.workspace.tokens.management.forms.controls :as token.controls]
[app.main.ui.workspace.tokens.management.forms.validators :refer [default-validate-token]]
[app.util.dom :as dom]
[app.util.forms :as fm]
[app.util.i18n :refer [tr]]
@@ -38,8 +39,25 @@
(str/blank? value))
(tr "workspace.tokens.empty-input")))
(defn- make-schema
[tokens-tree]
(defn get-value-for-validator
[active-tab value value-subfield form-type]
(case form-type
:indexed
(if (= active-tab :reference)
(:reference value)
(value-subfield value))
:composite
(if (= active-tab :reference)
(get value :reference)
value)
value))
(defn- default-make-schema
[tokens-tree _]
(sm/schema
[:and
[:map
@@ -62,14 +80,37 @@
(nil? (cto/token-value-self-reference? name value))))]]))
(mf/defc form*
[{:keys [token validate-token action is-create selected-token-set-id tokens-tree-in-selected-set] :as props}]
(let [token
(mf/with-memo [token]
(or token {:type :border-radius}))
[{:keys [token
validator
action
is-create
selected-token-set-id
tokens-tree-in-selected-set
token-type
(get token :type)
make-schema
input-component
initial
type
value-subfield
input-value-placeholder] :as props}]
(let [make-schema (or make-schema default-make-schema)
input-component (or input-component token.controls/input*)
validate-token (or validator default-validate-token)
active-tab* (mf/use-state #(if (cft/is-reference? token) :reference :composite))
active-tab (deref active-tab*)
on-toggle-tab
(mf/use-fn
(mf/deps)
(fn [new-tab]
(let [new-tab (keyword new-tab)]
(reset! active-tab* new-tab))))
token
(mf/with-memo [token]
(or token {:type token-type}))
token-properties
(dwta/get-token-properties token)
@@ -89,14 +130,15 @@
(assoc (:name token) token)))
schema
(mf/with-memo [tokens-tree-in-selected-set]
(make-schema tokens-tree-in-selected-set))
(mf/with-memo [tokens-tree-in-selected-set active-tab]
(make-schema tokens-tree-in-selected-set active-tab))
initial
(mf/with-memo [token]
(or initial
{:name (:name token "")
:value (:value token "")
:description (:description token "")})
:description (:description token "")}))
form
(fm/use-form :schema schema
@@ -136,12 +178,13 @@
on-submit
(mf/use-fn
(mf/deps validate-token token tokens token-type)
(mf/deps validate-token token tokens token-type value-subfield type active-tab)
(fn [form _event]
(let [name (get-in @form [:clean-data :name])
description (get-in @form [:clean-data :description])
value (get-in @form [:clean-data :value])]
(->> (validate-token {:token-value value
value (get-in @form [:clean-data :value])
value-for-validation (get-value-for-validator active-tab value value-subfield type)]
(->> (validate-token {:token-value value-for-validation
:token-name name
:token-description description
:prev-token token
@@ -168,7 +211,9 @@
[:div {:class (stl/css :token-rows)}
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
(tr "workspace.tokens.create-token" token-type)]
(if (= action "edit")
(tr "workspace.tokens.edit-token" token-type)
(tr "workspace.tokens.create-token" token-type))]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-name"
@@ -185,12 +230,16 @@
{:level :warning :appearance :ghost} (tr "workspace.tokens.warning-name-change")]])]
[:div {:class (stl/css :input-row)}
[:> form-input-token*
{:placeholder (tr "workspace.tokens.token-value-enter")
[:> input-component
{:placeholder (or input-value-placeholder (tr "workspace.tokens.token-value-enter"))
:label (tr "workspace.tokens.token-value")
:name :value
:form form
:token token
:tokens tokens}]]
:tokens tokens
:tab active-tab
:subfield value-subfield
:toggle on-toggle-tab}]]
[:div {:class (stl/css :input-row)}
[:> fc/form-input* {:id "token-description"

View File

@@ -4,7 +4,7 @@
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.workspace.tokens.management.create.modals
(ns app.main.ui.workspace.tokens.management.forms.modals
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
@@ -13,22 +13,28 @@
[app.main.refs :as refs]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.workspace.tokens.management.create.form :refer [form-wrapper*]]
[app.main.ui.workspace.tokens.management.forms.form-container :refer [form-container*]]
[app.util.i18n :refer [tr]]
[okulary.core :as l]
[rumext.v2 :as mf]))
;; Component -------------------------------------------------------------------
(defn calculate-position
(defn- calculate-position
"Calculates the style properties for the given coordinates and position"
[{vh :height} position x y color?]
(let [;; picker height in pixels
;; TODO: Revisit these harcoded values
h (if color? 610 510)
[{vh :height} position x y token-type]
(let [; TODO: Revisit these harcoded values
modal-height (case token-type
:color
500
:typography
660
:shadow
660
400)
;; Checks for overflow outside the viewport height
max-y (- vh h)
overflow-fix (max 0 (+ y (- 50) h (- vh)))
max-y (- vh modal-height)
overflow-fix (max 0 (+ y (- 50) modal-height (- vh)))
bottom-offset "1rem"
top-offset (dm/str (- y 70) "px")
max-height-top (str "calc(100vh - " top-offset)
@@ -61,17 +67,19 @@
:top (dm/str (- y 70 overflow-fix) "px")
:maxHeight max-height-top}))))
(defn use-viewport-position-style [x y position color?]
(defn- use-viewport-position-style [x y position token-type]
(let [vport (-> (l/derived :vport refs/workspace-local)
(mf/deref))]
(-> (calculate-position vport position x y color?)
(-> (calculate-position vport position x y token-type)
(clj->js))))
(mf/defc token-update-create-modal
{::mf/wrap-props false}
[{:keys [x y position token token-type action selected-token-set-id] :as _args}]
(let [wrapper-style (use-viewport-position-style x y position (= token-type :color))
modal-size-large* (mf/use-state (= token-type :typography))
(let [wrapper-style (use-viewport-position-style x y position token-type)
modal-size-large* (mf/use-state (or (= token-type :typography)
(= token-type :color)
(= token-type :shadow)))
modal-size-large? (deref modal-size-large*)
close-modal (mf/use-fn
(fn []
@@ -89,7 +97,7 @@
:icon i/close
:variant "action"
:aria-label (tr "labels.close")}]
[:> form-wrapper* {:is-create (not (ctob/token? token))
[:> form-container* {:is-create (not (ctob/token? token))
:token token
:action action
:selected-token-set-id selected-token-set-id

View File

@@ -0,0 +1,362 @@
;; 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.forms.shadow
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.files.tokens :as cft]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.forms :as forms]
[app.main.ui.hooks :as hooks]
[app.main.ui.workspace.tokens.management.forms.controls :as token.controls]
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
[app.main.ui.workspace.tokens.management.forms.validators :refer [check-self-reference default-validate-token]]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(defn- check-shadow-token-self-reference
"Check token when any of the attributes in a shadow's value have a self-reference."
[token]
(let [token-name (:name token)
shadow-values (:value token)]
(some (fn [[shadow-idx shadow-map]]
(some (fn [[k v]]
(when-let [err (check-self-reference token-name v)]
(assoc err :shadow-key k :shadow-index shadow-idx)))
shadow-map))
(d/enumerate shadow-values))))
(defn- check-empty-shadow-token [token]
(when (or (empty? (:value token))
(some (fn [shadow] (not-every? #(contains? shadow %) [:offset-x :offset-y :blur :spread :color]))
(:value token)))
(wte/get-error-code :error.token/empty-input)))
(defn- validate-shadow-token
[{:keys [token-value] :as params}]
(cond
;; Entering form without a value - show no error just resolve nil
(nil? token-value) (rx/of nil)
;; Validate refrence string
(cto/shadow-composite-token-reference? token-value) (default-validate-token params)
;; Validate composite token
:else
(let [params (-> params
(update :token-value (fn [value]
(->> (or value [])
(mapv (fn [shadow]
(d/update-when shadow :inset #(cond
(boolean? %) %
(= "true" %) true
:else false)))))))
(assoc :validators [check-empty-shadow-token
check-shadow-token-self-reference]))]
(default-validate-token params))))
(def ^:private default-token-shadow
{:offset-x "4"
:offset-y "4"
:blur "4"
:spread "0"})
(defn get-subtoken
[token index prop value-subfield]
(let [value (get-in token [:value value-subfield index prop])]
(d/without-nils
{:type (if (= prop :color) :color :number)
:value value})))
(mf/defc shadow-formset*
[{:keys [index token tokens remove-shadow-block show-button value-subfield] :as props}]
(let [inset-token (get-subtoken token index :inset value-subfield)
inset-token (hooks/use-equal-memo inset-token)
color-token (get-subtoken token index :color value-subfield)
color-token (hooks/use-equal-memo color-token)
offset-x-token (get-subtoken token index :offset-x value-subfield)
offset-x-token (hooks/use-equal-memo offset-x-token)
offset-y-token (get-subtoken token index :offset-y value-subfield)
offset-y-token (hooks/use-equal-memo offset-y-token)
blur-token (get-subtoken token index :blur value-subfield)
blur-token (hooks/use-equal-memo blur-token)
spread-token (get-subtoken token index :spread value-subfield)
spread-token (hooks/use-equal-memo spread-token)
on-button-click
(mf/use-fn
(mf/deps index)
(fn [event]
(remove-shadow-block index event)))]
[:div {:class (stl/css :shadow-block)
:data-testid (str "shadow-input-fields-" index)}
[:div {:class (stl/css :select-wrapper)}
[:> token.controls/select-indexed* {:options [{:id "drop" :label "drop shadow" :icon i/drop-shadow}
{:id "inner" :label "inner shadow" :icon i/inner-shadow}]
:aria-label (tr "workspace.tokens.shadow-inset")
:token inset-token
:tokens tokens
:index index
:value-subfield value-subfield
:name :inset}]
(when show-button
[:> icon-button* {:variant "ghost"
:type "button"
:aria-label (tr "workspace.tokens.shadow-remove-shadow")
:on-click on-button-click
:icon i/remove}])]
[:div {:class (stl/css :inputs-wrapper)}
[:div {:class (stl/css :input-row)}
[:> token.controls/indexed-color-input*
{:placeholder (tr "workspace.tokens.token-value-enter")
:aria-label (tr "workspace.tokens.color")
:name :color
:token color-token
:value-subfield value-subfield
:index index
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token.controls/input-indexed*
{:aria-label (tr "workspace.tokens.shadow-x")
:icon i/character-x
:placeholder (tr "workspace.tokens.shadow-x")
:name :offset-x
:token offset-x-token
:index index
:value-subfield value-subfield
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token.controls/input-indexed*
{:aria-label (tr "workspace.tokens.shadow-y")
:icon i/character-y
:placeholder (tr "workspace.tokens.shadow-y")
:name :offset-y
:token offset-y-token
:index index
:value-subfield value-subfield
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token.controls/input-indexed*
{:aria-label (tr "workspace.tokens.shadow-blur")
:placeholder (tr "workspace.tokens.shadow-blur")
:name :blur
:slot-start (mf/html [:span {:class (stl/css :visible-label)}
(str (tr "workspace.tokens.shadow-blur") ":")])
:token blur-token
:index index
:value-subfield value-subfield
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token.controls/input-indexed*
{:aria-label (tr "workspace.tokens.shadow-spread")
:placeholder (tr "workspace.tokens.shadow-spread")
:name :spread
:slot-start (mf/html [:span {:class (stl/css :visible-label)}
(str (tr "workspace.tokens.shadow-spread") ":")])
:token spread-token
:value-subfield value-subfield
:index index
:tokens tokens}]]]]))
(mf/defc composite-form*
[{:keys [token tokens remove-shadow-block value-subfield] :as props}]
(let [form
(mf/use-ctx forms/context)
length
(-> form deref :data :value value-subfield count)]
(for [index (range length)]
[:> shadow-formset* {:key index
:index index
:token token
:tokens tokens
:value-subfield value-subfield
:remove-shadow-block remove-shadow-block
:show-button (> length 1)}])))
(mf/defc reference-form*
[{:keys [token tokens] :as props}]
[:div {:class (stl/css :input-row-reference)}
[:> token.controls/input-composite*
{:placeholder (tr "workspace.tokens.reference-composite-shadow")
:aria-label (tr "labels.reference")
:icon i/drop-shadow
:name :reference
:token token
:tokens tokens}]])
(mf/defc tabs-wrapper*
[{:keys [token tokens form tab toggle subfield] :rest props}]
(let [on-add-shadow-block
(mf/use-fn
(mf/deps subfield)
(fn []
(swap! form update-in [:data :value subfield] conj default-token-shadow)))
remove-shadow-block
(mf/use-fn
(mf/deps subfield)
(fn [index event]
(dom/prevent-default event)
(swap! form update-in [:data :value subfield] #(d/remove-at-index % index))))]
[:*
[:div {:class (stl/css :title-bar)}
[:div {:class (stl/css :title)} (tr "labels.shadow")]
[:> icon-button* {:variant "ghost"
:type "button"
:aria-label (tr "workspace.tokens.shadow-add-shadow")
:on-click on-add-shadow-block
:icon i/add}]
[:& radio-buttons {:class (stl/css :listing-options)
:selected (d/name tab)
:on-change toggle
:name "reference-composite-tab"}
[:& radio-button {:icon i/layers
:value "composite"
:title (tr "workspace.tokens.individual-tokens")
:id "composite-opt"}]
[:& radio-button {:icon i/tokens
:value "reference"
:title (tr "workspace.tokens.use-reference")
:id "reference-opt"}]]]
(if (= tab :composite)
[:> composite-form* {:token token
:tokens tokens
:remove-shadow-block remove-shadow-block
:value-subfield subfield}]
[:> reference-form* {:token token
:tokens tokens}])]))
(defn- make-schema
[tokens-tree active-tab]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255
:error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/token-name-ref assoc
:error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (cft/token-name-path-exists? % tokens-tree))]]]
[:value
[:map
[:shadow {:optinal true}
[:vector
[:map
[:offset-x {:optional true} [:maybe :string]]
[:offset-y {:optional true} [:maybe :string]]
[:blur {:optional true} [:maybe :string]]
[:spread {:optional true} [:maybe :string]]
[:color {:optional true} [:maybe :string]]
[:color-result {:optional true} ::sm/any]
[:inset {:optional true} [:maybe :boolean]]]]]
(if (= active-tab :reference)
[:reference {:optional false} ::sm/text]
[:reference {:optional true} [:maybe :string]])]]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:fn {:error/field [:value :reference]
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(let [reference (get value :reference)]
(if (and reference name)
(not (cto/token-value-self-reference? name reference))
true)))]
[:fn {:error/fn (fn [_] "Must be a valid shadow or reference")
:error/field :value}
(fn [{:keys [value]}]
(let [reference (get value :reference)
ref-valid? (and reference (not (str/blank? reference)))
shadows (get value :shadow)
;; To be a valid shadow it must contain one on each valid values
valid-composite-shadow?
(and (seq shadows)
(every?
(fn [{:keys [offset-x offset-y blur spread color]}]
(and (not (str/blank? offset-x))
(not (str/blank? offset-y))
(not (str/blank? blur))
(not (str/blank? spread))
(not (str/blank? color))))
shadows))]
(or ref-valid? valid-composite-shadow?)))]]))
(defn- make-default-value
[value]
(cond
(string? value)
{:reference value
:shadow []}
(vector? value)
{:reference nil
:shadow value}
:else
{:reference nil
:shadow [default-token-shadow]}))
(mf/defc form*
[{:keys [token
token-type] :as props}]
(let [token
(mf/with-memo [token]
(or token
(if-let [value (get token :value)]
{:type token-type
:value (make-default-value value)}
{:type token-type
:value {:reference nil
:shadow [default-token-shadow]}})))
initial
(mf/with-memo [token]
(let [raw-value (:value token)
value (make-default-value raw-value)]
{:name (:name token "")
:description (:description token "")
:value value}))
props (mf/spread-props props {:token token
:token-type token-type
:initial initial
:make-schema make-schema
:type :indexed
:value-subfield :shadow
:input-component tabs-wrapper*
:validator validate-shadow-token})]
[:> generic/form* props]))

View File

@@ -8,17 +8,6 @@
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
.form-wrapper {
width: $sz-384;
position: relative;
}
.token-rows {
display: flex;
flex-direction: column;
gap: var(--sp-l);
}
.inputs-wrapper {
display: flex;
flex-direction: column;
@@ -34,9 +23,19 @@
gap: var(--sp-xs);
}
.input-row-reference {
position: relative;
display: flex;
flex-direction: column;
gap: var(--sp-xs);
border-inline-start: $b-1 solid var(--color-accent-primary-muted);
padding-inline-start: var(--sp-m);
}
.title-bar {
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: 1fr auto auto;
gap: var(--sp-xs);
}
.title {
@@ -46,29 +45,17 @@
align-items: center;
}
.form-modal-title {
@include t.use-typography("headline-medium");
color: var(--color-foreground-primary);
display: flex;
align-items: center;
}
.button-row {
display: grid;
grid-template-columns: auto auto;
justify-content: end;
gap: var(--sp-m);
padding-block-start: var(--sp-s);
}
.with-delete {
grid-template-columns: 1fr auto auto;
}
.warning-name-change-notification-wrapper {
margin-block-start: var(--sp-l);
}
.delete-btn {
justify-self: start;
}
.visible-label {
@include t.use-typography("headline-small");
color: var(--color-foreground-secondary);
line-height: $sz-32;
}
.select-wrapper {
display: grid;
grid-template-columns: 1fr auto;
}

View File

@@ -0,0 +1,304 @@
;; 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.forms.typography
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.files.tokens :as cft]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.workspace.tokens.management.forms.controls :as token.controls]
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
[app.main.ui.workspace.tokens.management.forms.validators :refer [check-coll-self-reference check-self-reference default-validate-token]]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
;; VALIDATORS
(defn- check-typography-token-self-reference
"Check token when any of the attributes in token value have a self-reference."
[token]
(let [token-name (:name token)
token-values (:value token)]
(some (fn [[k v]]
(when-let [err (case k
:font-family (check-coll-self-reference token-name v)
(check-self-reference token-name v))]
(assoc err :typography-key k)))
token-values)))
(defn- check-empty-typography-token [token]
(when (empty? (:value token))
(wte/get-error-code :error.token/empty-input)))
(defn- validate-typography-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
(cto/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? %) (cto/split-font-family %) %)))))
(assoc :validators [check-empty-typography-token
check-typography-token-self-reference])
(default-validate-token))))
;; COMPONENTS
(mf/defc composite-form*
[{:keys [token tokens] :as props}]
(let [letter-spacing-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :letter-spacing
:value (cto/join-font-family (get value :letter-spacing))}
{:type :letter-spacing}))
font-family-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :font-family
:value (get value :font-family)}
{:type :font-family}))
font-size-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :font-size
:value (get value :font-size)}
{:type :font-size}))
font-weight-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :font-weight
:value (get value :font-weight)}
{:type :font-weight}))
line-height-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :number
:value (get value :line-height)}
{:type :number}))
text-case-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :text-case
:value (get value :text-case)}
{:type :text-case}))
text-decoration-sub-token
(mf/with-memo [token]
(if-let [value (get token :value)]
{:type :text-decoration
:value (get value :text-decoration)}
{:type :text-decoration}))]
[:*
[:div {:class (stl/css :input-row)}
[:> token.controls/composite-fonts-combobox*
{:icon i/text-font-family
:placeholder (tr "workspace.tokens.token-font-family-value-enter")
:aria-label (tr "workspace.tokens.token-font-family-value")
:name :font-family
:token font-family-sub-token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token.controls/input-composite*
{:aria-label "Font Size"
:icon i/text-font-size
:placeholder (tr "workspace.tokens.font-size-value-enter")
:name :font-size
:token font-size-sub-token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token.controls/input-composite*
{:aria-label "Font Weight"
:icon i/text-font-weight
:placeholder (tr "workspace.tokens.font-weight-value-enter")
:name :font-weight
:token font-weight-sub-token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token.controls/input-composite*
{:aria-label "Line Height"
:icon i/text-lineheight
:placeholder (tr "workspace.tokens.line-height-value-enter")
:name :line-height
:token line-height-sub-token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token.controls/input-composite*
{:aria-label "Letter Spacing"
:icon i/text-letterspacing
:placeholder (tr "workspace.tokens.letter-spacing-value-enter-composite")
:name :letter-spacing
:token letter-spacing-sub-token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token.controls/input-composite*
{:aria-label "Text Case"
:icon i/text-mixed
:placeholder (tr "workspace.tokens.text-case-value-enter")
:name :text-case
:token text-case-sub-token
:tokens tokens}]]
[:div {:class (stl/css :input-row)}
[:> token.controls/input-composite*
{:aria-label "Text Decoration"
:icon i/text-underlined
:placeholder (tr "workspace.tokens.text-decoration-value-enter")
:name :text-decoration
:token text-decoration-sub-token
:tokens tokens}]]]))
(mf/defc reference-form*
[{:keys [token tokens] :as props}]
[:div {:class (stl/css :input-row)}
[:> token.controls/input-composite*
{:placeholder (tr "workspace.tokens.reference-composite")
:aria-label (tr "labels.reference")
:icon i/text-typography
:name :reference
:token token
:tokens tokens}]])
(mf/defc tabs-wrapper*
[{:keys [token tokens tab toggle] :rest props}]
[:*
[:div {:class (stl/css :title-bar)}
[:div {:class (stl/css :title)} (tr "labels.typography")]
[:& radio-buttons {:class (stl/css :listing-options)
:selected (d/name tab)
:on-change toggle
:name "reference-composite-tab"}
[:& radio-button {:icon i/layers
:value "composite"
:title (tr "workspace.tokens.individual-tokens")
:id "composite-opt"}]
[:& radio-button {:icon i/tokens
:value "reference"
:title (tr "workspace.tokens.use-reference")
:id "reference-opt"}]]]
[:div {:class (stl/css :inputs-wrapper)}
(if (= tab :composite)
[:> composite-form* {:token token
:tokens tokens}]
[:> reference-form* {:token token
:tokens tokens}])]])
;; SCHEMA
(defn- make-schema
[tokens-tree active-tab]
(sm/schema
[:and
[:map
[:name
[:and
[:string {:min 1 :max 255
:error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/token-name-ref assoc
:error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (cft/token-name-path-exists? % tokens-tree))]]]
[:value
[:map
[:font-family {:optional true} [:maybe :string]]
[:font-size {:optional true} [:maybe :string]]
[:font-weight {:optional true} [:maybe :string]]
[:line-height {:optional true} [:maybe :string]]
[:letter-spacing {:optional true} [:maybe :string]]
[:text-case {:optional true} [:maybe :string]]
[:text-decoration {:optional true} [:maybe :string]]
(if (= active-tab :reference)
[:reference {:optional false} ::sm/text]
[:reference {:optional true} [:maybe :string]])]]
[:description {:optional true}
[:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]]
[:fn {:error/field [:value :reference]
:error/fn #(tr "workspace.tokens.self-reference")}
(fn [{:keys [name value]}]
(let [reference (get value :reference)]
(if (and reference name)
(not (cto/token-value-self-reference? name reference))
true)))]
[:fn {:error/field [:value :line-height]
:error/fn #(tr "workspace.tokens.composite-line-height-needs-font-size")}
(fn [{:keys [value]}]
(let [line-heigh (get value :line-height)
font-size (get value :font-size)]
(if (and line-heigh (not font-size))
false
true)))]
;; This error does not shown on interface, it's just to avoid saving empty composite tokens
;; We don't need to translate it.
[:fn {:error/fn (fn [_] "At least one composite field must be set")
:error/field :value}
(fn [attrs]
(let [result (reduce-kv (fn [_ _ v]
(if (str/empty? v)
false
(reduced true)))
false
(get attrs :value))]
result))]]))
(mf/defc form*
[{:keys [token] :as props}]
(let [initial
(mf/with-memo [token]
(let [value (:value token)
processed-value
(cond
(string? value)
{:reference value}
(map? value)
(let [value (cond-> value
(:font-family value)
(update :font-family cto/join-font-family))]
(select-keys value
[:font-family
:font-size
:font-weight
:line-height
:letter-spacing
:text-case
:text-decoration]))
:else
{})]
{:name (:name token "")
:value processed-value
:description (:description token "")}))
props (mf/spread-props props {:initial initial
:make-schema make-schema
:token token
:validator validate-typography-token
:type :composite
:input-component tabs-wrapper*})]
[:> generic/form* props]))

View File

@@ -8,18 +8,16 @@
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
.form-wrapper {
width: $sz-384;
position: relative;
}
.token-rows {
.inputs-wrapper {
display: flex;
flex-direction: column;
gap: var(--sp-l);
gap: var(--sp-m);
border-inline-start: $b-1 solid var(--color-accent-primary-muted);
padding-inline-start: var(--sp-m);
}
.input-row {
position: relative;
display: flex;
flex-direction: column;
gap: var(--sp-xs);
@@ -30,29 +28,9 @@
grid-template-columns: 1fr auto;
}
.form-modal-title {
@include t.use-typography("headline-medium");
.title {
@include t.use-typography("body-small");
color: var(--color-foreground-primary);
display: flex;
align-items: center;
}
.button-row {
display: grid;
grid-template-columns: auto auto;
justify-content: end;
gap: var(--sp-m);
padding-block-start: var(--sp-s);
}
.with-delete {
grid-template-columns: 1fr auto auto;
}
.warning-name-change-notification-wrapper {
margin-block-start: var(--sp-l);
}
.delete-btn {
justify-self: start;
}

View File

@@ -0,0 +1,111 @@
(ns app.main.ui.workspace.tokens.management.forms.validators
(:require
[app.common.data :as d]
[app.common.files.tokens :as cft]
[app.common.schema :as sm]
[app.common.types.token :as cto]
[app.common.types.tokens-lib :as ctob]
[app.main.data.style-dictionary :as sd]
[app.main.data.workspace.tokens.errors :as wte]
[app.util.i18n :refer [tr]]
[beicon.v2.core :as rx]
[cuerdas.core :as str]))
;; Value Validation -------------------------------------------------------------
(defn check-empty-value [token]
(let [token-value (:value token)]
(when (empty? (str/trim token-value))
(wte/get-error-code :error.token/empty-input))))
(defn check-self-reference [token-name token-value]
(when (cto/token-value-self-reference? token-name token-value)
(wte/get-error-code :error.token/direct-self-reference)))
(defn validate-resolve-token
[token prev-token tokens]
(let [token (cond-> token
;; When creating a new token we dont have a name yet or invalid name,
;; but we still want to resolve the value to show in the form.
;; So we use a temporary token name that hopefully doesn't clash with any of the users token names
(not (sm/valid? cto/token-name-ref (:name token))) (assoc :name "__PENPOT__TOKEN__NAME__PLACEHOLDER__"))
tokens' (cond-> tokens
;; Remove previous token when renaming a token
(not= (:name token) (:name prev-token))
(dissoc (:name prev-token))
:always
(update (:name token) #(ctob/make-token (merge % prev-token token))))]
(->> tokens'
(sd/resolve-tokens-interactive)
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))]
(cond
resolved-value (rx/of resolved-token)
:else (rx/throw {:errors (or (seq errors)
[(wte/get-error-code :error/unknown-error)])}))))))))
(defn- validate-token-with [token validators]
(if-let [error (some (fn [validate] (validate token)) validators)]
(rx/throw {:errors [error]})
(rx/of token)))
(def ^:private default-validators
[check-empty-value check-self-reference])
(defn default-validate-token
"Validates a token by confirming a list of `validator` predicates and
resolving the token using `tokens` with StyleDictionary. Returns rx
stream of either a valid resolved token or an errors map.
Props:
token-name, token-value, token-description: Values from the form inputs
prev-token: The existing token currently being edited
tokens: tokens map keyed by token-name
Used to look up the editing token & resolving step.
validators: A list of predicates that will be used to do simple validation on the unresolved token map.
The validators get the token map as input and should either return:
- An errors map .e.g: {:errors []}
- nil (valid token predicate)
Mostly used to do simple checks like invalidating empy token `:name`.
Will default to `default-validators`."
[{:keys [token-name token-value token-description prev-token tokens validators]
:or {validators default-validators}}]
(let [token (-> {:name token-name
:value token-value
:description token-description}
(d/without-nils))]
(->> (rx/of token)
;; Simple validation of the editing token
(rx/mapcat #(validate-token-with % validators))
;; Resolving token via StyleDictionary
(rx/mapcat #(validate-resolve-token % prev-token tokens)))))
(defn check-coll-self-reference
"Invalidate a collection of `token-vals` for a self-refernce against `token-name`.,"
[token-name token-vals]
(when (some #(cto/token-value-self-reference? token-name %) token-vals)
(wte/get-error-code :error.token/direct-self-reference)))
;; This is used in plugins
(defn- make-token-name-schema
"Generate a dynamic schema validation to check if a token path derived
from the name already exists at `tokens-tree`."
[tokens-tree]
[:and
[:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}]
(sm/update-properties cto/token-name-ref assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))
[:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))}
#(not (cft/token-name-path-exists? % tokens-tree))]])
(defn validate-token-name
[tokens-tree name]
(let [schema (make-token-name-schema tokens-tree)
explainer (sm/explainer schema)]
(-> name explainer sm/simplify not-empty)))

View File

@@ -43,6 +43,7 @@
:stroke-width "stroke-size"
:dimensions "expand"
:sizing "expand"
:shadow "drop-shadow"
"add"))
(mf/defc token-group*

View File

@@ -151,8 +151,8 @@
;; export interface Shadow {
;; id?: string;
;; style?: 'drop-shadow' | 'inner-shadow';
;; offsetX?: number;
;; offsetY?: number;
;; offset-x?: number;
;; offset-y?: number;
;; blur?: number;
;; spread?: number;
;; hidden?: boolean;
@@ -164,8 +164,8 @@
(obj/without-empty
#js {:id (-> id format-id)
:style (-> style format-key)
:offsetX offset-x
:offsetY offset-y
:offset-x offset-x
:offset-y offset-y
:blur blur
:spread spread
:hidden hidden

View File

@@ -147,7 +147,7 @@
;; export interface Shadow {
;; id?: string;
;; style?: 'drop-shadow' | 'inner-shadow';
;; offsetX?: number;
;; offset--y?: number;
;; offsetY?: number;
;; blur?: number;
;; spread?: number;
@@ -160,8 +160,8 @@
(d/without-nils
{:id (-> (obj/get shadow "id") parse-id)
:style (-> (obj/get shadow "style") parse-keyword)
:offset-x (obj/get shadow "offsetX")
:offset-y (obj/get shadow "offsetY")
:offset-x (obj/get shadow "offset-x")
:offset-y (obj/get shadow "offset-y")
:blur (obj/get shadow "blur")
:spread (obj/get shadow "spread")
:hidden (obj/get shadow "hidden")

View File

@@ -13,7 +13,7 @@
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.store :as st]
[app.main.ui.workspace.tokens.management.create.form :as token-form]
[app.main.ui.workspace.tokens.management.forms.validators :as form-validator]
[app.main.ui.workspace.tokens.themes.create-modal :as theme-form]
[app.plugins.utils :as u]
[app.util.object :as obj]
@@ -51,7 +51,7 @@
:set
(fn [_ value]
(let [tokens-lib (u/locate-tokens-lib file-id)
errors (token-form/validate-token-name
errors (form-validator/validate-token-name
(ctob/get-tokens tokens-lib set-id)
value)]
(cond

View File

@@ -474,8 +474,8 @@
(t/async
done
(let [shadow-token {:name "shadow.sm"
:value [{:offsetX 10
:offsetY 10
:value [{:offset-x 10
:offset-y 10
:blur 10
:spread 10
:color "rgba(0,0,0,0.5)"

View File

@@ -7753,6 +7753,10 @@ msgstr "Invalid token value: only none, underline and strike-through are accepte
msgid "workspace.tokens.invalid-token-value-typography"
msgstr "Invalid value: must reference a composite typography token."
#: src/app/main/data/workspace/tokens/errors.cljs
msgid "workspace.tokens.invalid-token-value-shadow"
msgstr "Invalid value: must reference a composite shadow token."
#: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77
msgid "workspace.tokens.invalid-value"
msgstr "Invalid token value: %s"
@@ -7870,6 +7874,10 @@ msgstr "Reference is not valid or is not in any active set"
msgid "workspace.tokens.reference-composite"
msgstr "Enter a token typography alias"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:775
msgid "workspace.tokens.reference-composite-shadow"
msgstr "Enter a token shadow alias"
#: src/app/main/ui/workspace/tokens/style_dictionary.cljs
#, unused
msgid "workspace.tokens.reference-error"

View File

@@ -7674,6 +7674,10 @@ msgstr "Tipo de sombra no válida: solo se aceptan 'innerShadow' o 'dropShadow'"
msgid "workspace.tokens.invalid-token-value-typography"
msgstr "Valor no válido: debe hacer referencia a un token tipográfico compuesto."
#: src/app/main/data/workspace/tokens/errors.cljs
msgid "workspace.tokens.invalid-token-value-shadow"
msgstr "Valor no válido: debe hacer referencia a un token de sombra compuesto."
#: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77
msgid "workspace.tokens.invalid-value"
msgstr "Valor de token no válido: %s"