Add box shadow token

This commit is contained in:
Florian Schroedl
2025-10-02 10:39:23 +02:00
committed by Andrés Moya
parent 5c8b401037
commit e681f95a70
18 changed files with 1151 additions and 38 deletions

View File

@@ -123,6 +123,7 @@
:token-color :token-color
:token-typography-types :token-typography-types
:token-typography-composite :token-typography-composite
:token-shadow
:transit-readable-response :transit-readable-response
:user-feedback :user-feedback
;; TODO: remove this flag. ;; TODO: remove this flag.

View File

@@ -54,6 +54,7 @@
(def token-type->dtcg-token-type (def token-type->dtcg-token-type
{:boolean "boolean" {:boolean "boolean"
:border-radius "borderRadius" :border-radius "borderRadius"
:shadow "shadow"
:color "color" :color "color"
:dimensions "dimension" :dimensions "dimension"
:font-family "fontFamilies" :font-family "fontFamilies"
@@ -77,7 +78,8 @@
;; Allow these properties to be imported with singular key names for backwards compability ;; Allow these properties to be imported with singular key names for backwards compability
(assoc "fontWeight" :font-weight (assoc "fontWeight" :font-weight
"fontSize" :font-size "fontSize" :font-size
"fontFamily" :font-family))) "fontFamily" :font-family
"boxShadow" :shadow)))
(def composite-token-type->dtcg-token-type (def composite-token-type->dtcg-token-type
"Custom set of conversion keys for composite typography token with `:line-height` available. "Custom set of conversion keys for composite typography token with `:line-height` available.
@@ -115,6 +117,12 @@
(def border-radius-keys (schema-keys schema:border-radius)) (def border-radius-keys (schema-keys schema:border-radius))
(def ^:private schema:shadow
[:map {:title "ShadowTokenAttrs"}
[:shadow {:optional true} token-name-ref]])
(def shadow-keys (schema-keys schema:shadow))
(def ^:private schema:stroke-width (def ^:private schema:stroke-width
[:map [:map
[:stroke-width {:optional true} token-name-ref]]) [:stroke-width {:optional true} token-name-ref]])
@@ -271,6 +279,7 @@
(def all-keys (set/union color-keys (def all-keys (set/union color-keys
border-radius-keys border-radius-keys
shadow-keys
stroke-width-keys stroke-width-keys
sizing-keys sizing-keys
opacity-keys opacity-keys
@@ -289,6 +298,7 @@
[:merge {:title "AppliedTokens"} [:merge {:title "AppliedTokens"}
schema:tokens schema:tokens
schema:border-radius schema:border-radius
schema:shadow
schema:sizing schema:sizing
schema:spacing schema:spacing
schema:rotation schema:rotation
@@ -334,6 +344,7 @@
(font-weight-keys shape-attr) #{shape-attr :typography} (font-weight-keys shape-attr) #{shape-attr :typography}
(border-radius-keys shape-attr) #{shape-attr} (border-radius-keys shape-attr) #{shape-attr}
(shadow-keys shape-attr) #{shape-attr}
(sizing-keys shape-attr) #{shape-attr} (sizing-keys shape-attr) #{shape-attr}
(opacity-keys shape-attr) #{shape-attr} (opacity-keys shape-attr) #{shape-attr}
(spacing-keys shape-attr) #{shape-attr} (spacing-keys shape-attr) #{shape-attr}
@@ -361,6 +372,7 @@
rotation-keys rotation-keys
sizing-keys sizing-keys
opacity-keys opacity-keys
shadow-keys
position-attributes)) position-attributes))
(def rect-attributes (def rect-attributes
@@ -444,6 +456,30 @@
spacing-margin-keys)] spacing-margin-keys)]
(unapply-token-id shape layout-item-attrs))) (unapply-token-id shape layout-item-attrs)))
(def tokens-by-input
"A map from input name to applicable token for that input."
{:width #{:sizing :dimensions}
:height #{:sizing :dimensions}
:max-width #{:sizing :dimensions}
:max-height #{:sizing :dimensions}
:x #{:spacing :dimensions}
:y #{:spacing :dimensions}
:rotation #{:number :rotation}
:border-radius #{:border-radius :dimensions}
:row-gap #{:spacing :dimensions}
:column-gap #{:spacing :dimensions}
:horizontal-padding #{:spacing :dimensions}
:vertical-padding #{:spacing :dimensions}
:sided-paddings #{:spacing :dimensions}
:horizontal-margin #{:spacing :dimensions}
:vertical-margin #{:spacing :dimensions}
:sided-margins #{:spacing :dimensions}
:line-height #{:line-height :number}
:font-size #{:font-size}
:letter-spacing #{:letter-spacing}
:fill #{:color}
:stroke-color #{:color}})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TYPOGRAPHY ;; TYPOGRAPHY
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -514,26 +550,11 @@
[token-value] [token-value]
(string? token-value)) (string? token-value))
(def tokens-by-input ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
"A map from input name to applicable token for that input." ;; SHADOW
{:width #{:sizing :dimensions} ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
:height #{:sizing :dimensions}
:max-width #{:sizing :dimensions} (defn shadow-composite-token-reference?
:max-height #{:sizing :dimensions} "Predicate if a shadow composite token is a reference value - a string pointing to another reference token."
:x #{:spacing :dimensions} [token-value]
:y #{:spacing :dimensions} (string? token-value))
:rotation #{:number :rotation}
:border-radius #{:border-radius :dimensions}
:row-gap #{:spacing :dimensions}
:column-gap #{:spacing :dimensions}
:horizontal-padding #{:spacing :dimensions}
:vertical-padding #{:spacing :dimensions}
:sided-paddings #{:spacing :dimensions}
:horizontal-margin #{:spacing :dimensions}
:vertical-margin #{:spacing :dimensions}
:sided-margins #{:spacing :dimensions}
:line-height #{:line-height :number}
:font-size #{:font-size}
:letter-spacing #{:letter-spacing}
:fill #{:color}
:stroke-color #{:color}})

View File

@@ -1552,6 +1552,46 @@ Will return a value that matches this schema:
;; Reference value ;; Reference value
value)) value))
(defn- convert-dtcg-shadow-composite
"Convert shadow token value from DTCG format to internal format."
[value]
(let [process-shadow (fn [shadow]
(if (map? shadow)
(let [legacy-shadow-type (get "type" shadow)]
(-> shadow
(set/rename-keys {"x" :offsetX
"offsetX" :offsetX
"y" :offsetY
"offsetY" :offsetY
"blur" :blur
"spread" :spread
"color" :color
"inset" :inset})
(update :inset #(cond
(boolean? %) %
(= "true" %) true
(= "false" %) false
(= legacy-shadow-type "innerShadow") true
:else false))
(select-keys [:offsetX :offsetY :blur :spread :color :inset])))
shadow))]
(cond
;; Reference value - keep as string
(string? value)
value
;; Array of shadows - process each
(sequential? value)
(mapv process-shadow value)
;; Single shadow object - wrap in vector
(map? value)
[(process-shadow value)]
;; Fallback - keep as is
:else
value)))
(defn- flatten-nested-tokens-json (defn- flatten-nested-tokens-json
"Convert a tokens tree in the decoded json fragment into a flat map, "Convert a tokens tree in the decoded json fragment into a flat map,
being the keys the token paths after joining the keys with '.'." being the keys the token paths after joining the keys with '.'."
@@ -1574,6 +1614,7 @@ Will return a value that matches this schema:
(case token-type (case token-type
:font-family (convert-dtcg-font-family token-value) :font-family (convert-dtcg-font-family token-value)
:typography (convert-dtcg-typography-composite token-value) :typography (convert-dtcg-typography-composite token-value)
:shadow (convert-dtcg-shadow-composite token-value)
token-value)) token-value))
:description (get v "$description"))) :description (get v "$description")))
;; Discard unknown type tokens ;; Discard unknown type tokens
@@ -1739,11 +1780,32 @@ Will return a value that matches this schema:
{} value) {} value)
value)) value))
(defn- shadow-token->dtcg-token
"Convert shadow token value from internal format to DTCG format."
[value]
(if (sequential? value)
(mapv (fn [shadow]
(if (map? shadow)
(-> shadow
(set/rename-keys {:offsetX "offsetX"
:offsetY "offsetY"
:blur "blur"
:spread "spread"
:color "color"
:inset "inset"})
(select-keys ["offsetX" "offsetY" "blur" "spread" "color" "inset"]))
shadow))
value)
value))
(defn- token->dtcg-token [token] (defn- token->dtcg-token [token]
(cond-> {"$value" (cond-> (:value token) (cond-> {"$value" (cond-> (:value token)
;; Transform typography token values ;; Transform typography token values
(= :typography (:type token)) (= :typography (:type token))
typography-token->dtcg-token) typography-token->dtcg-token
;; Transform shadow token values
(= :shadow (:type token))
shadow-token->dtcg-token)
"$type" (cto/token-type->dtcg-token-type (:type token))} "$type" (cto/token-type->dtcg-token-type (:type token))}
(:description token) (assoc "$description" (:description token)))) (:description token) (assoc "$description" (:description token))))

View File

@@ -0,0 +1,60 @@
{
"test": {
"shadow-single": {
"$value": {
"x": "0",
"y": "2px",
"blur": "4px",
"spread": "0",
"color": "#000"
},
"$type": "boxShadow"
},
"shadow-multiple": {
"$value": [
{
"x": "0",
"y": "2px",
"blur": "4px",
"spread": "0",
"color": "#000",
"inset": true
},
{
"x": "0",
"y": "8px",
"blur": "16px",
"spread": "0",
"color": "#000",
"inset": "true"
}
],
"$type": "boxShadow"
},
"shadow-ref": {
"$value": "{shadow-single}",
"$type": "boxShadow"
},
"shadow-with-type": {
"$value": {
"x": "0",
"y": "4px",
"blur": "8px",
"spread": "0",
"color": "rgba(0,0,0,0.2)",
"type": "innerShadow"
},
"$type": "boxShadow"
},
"shadow-with-description": {
"$value": {
"x": "1px",
"y": "1px",
"blur": "3px",
"color": "gray"
},
"$type": "boxShadow",
"$description": "A simple shadow token"
}
}
}

View File

@@ -1362,9 +1362,7 @@
{:name "button.primary.background" {:name "button.primary.background"
:type :color :type :color
:value "{accent.default}" :value "{accent.default}"
:description ""}))) :description ""}))))))
(t/testing "invalid tokens got discarded"
(t/is (nil? (ctob/get-token-by-name lib "theme" "boxShadow.default")))))))
#?(:clj #?(:clj
(t/deftest parse-multi-set-dtcg-json (t/deftest parse-multi-set-dtcg-json
@@ -1392,9 +1390,7 @@
{:name "button.primary.background" {:name "button.primary.background"
:type :color :type :color
:value "{accent.default}" :value "{accent.default}"
:description ""}))) :description ""}))))))
(t/testing "invalid tokens got discarded"
(t/is (nil? (ctob/get-token-by-name lib "theme" "boxShadow.default")))))))
#?(:clj #?(:clj
(t/deftest parse-multi-set-dtcg-json-default-team (t/deftest parse-multi-set-dtcg-json-default-team
@@ -1893,3 +1889,130 @@
(t/is (some? imported-single)) (t/is (some? imported-single))
(t/is (= (:type imported-single) (:type original-single))) (t/is (= (:type imported-single) (:type original-single)))
(t/is (= (:value imported-single) (:value original-single)))))))) (t/is (= (:value imported-single) (:value original-single))))))))
#?(:clj
(t/deftest parse-shadow-tokens
(let [json (-> (slurp "test/common_tests/types/data/tokens-shadow-example.json")
(json/decode {:key-fn identity}))
lib (ctob/parse-decoded-json json "shadow-test")]
(t/testing "single shadow token"
(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}]
(: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}]
(:value token)))))
(t/testing "shadow token with reference"
(let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-ref")]
(t/is (some? token))
(t/is (= :shadow (:type token)))
(t/is (= "{shadow-single}" (:value token)))))
(t/testing "shadow token with type"
(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}]
(:value token)))))
(t/testing "shadow token with description"
(let [token (ctob/get-token-by-name lib "shadow-test" "test.shadow-with-description")]
(t/is (some? token))
(t/is (= :shadow (:type token)))
(t/is (= "A simple shadow token" (:description token))))))))
#?(:clj
(t/deftest export-shadow-tokens
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set
(ctob/make-token-set
:name "shadow-set"
:tokens {"shadow.single"
(ctob/make-token
{:name "shadow.single"
:type :shadow
:value [{:offsetX "0" :offsetY "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"}]})
"shadow.ref"
(ctob/make-token
{:name "shadow.ref"
:type :shadow
:value "{shadow.single}"})
"shadow.empty"
(ctob/make-token
{:name "shadow.empty"
:type :shadow
:value {}})})))
result (ctob/export-dtcg-json tokens-lib)
shadow-set (get result "shadow-set")]
(t/testing "single shadow token export"
(let [single-token (get-in shadow-set ["shadow" "single"])]
(t/is (= "shadow" (get single-token "$type")))
(t/is (= [{"offsetX" "0" "offsetY" "2px" "blur" "4px" "spread" "0" "color" "#0000001A"}] (get single-token "$value")))
(t/is (= "A single shadow" (get single-token "$description")))))
(t/testing "multiple shadow token export"
(let [multiple-token (get-in shadow-set ["shadow" "multiple"])]
(t/is (= "shadow" (get multiple-token "$type")))
(t/is (= [{"offsetX" "0" "offsetY" "2px" "blur" "4px" "spread" "0" "color" "#0000001A"}
{"offsetX" "0" "offsetY" "8px" "blur" "16px" "spread" "0" "color" "#0000001A"}]
(get multiple-token "$value")))))
(t/testing "reference shadow token export"
(let [ref-token (get-in shadow-set ["shadow" "ref"])]
(t/is (= "shadow" (get ref-token "$type")))
(t/is (= "{shadow.single}" (get ref-token "$value")))))
(t/testing "empty shadow token export"
(let [empty-token (get-in shadow-set ["shadow" "empty"])]
(t/is (= "shadow" (get empty-token "$type")))
(t/is (= {} (get empty-token "$value"))))))))
#?(:clj
(t/deftest shadow-token-round-trip
(let [original-lib (-> (ctob/make-tokens-lib)
(ctob/add-set
(ctob/make-token-set
:name "test-set"
:tokens {"shadow.test"
(ctob/make-token
{:name "shadow.test"
:type :shadow
:value [{:offsetX "1" :offsetY "1" :blur "1" :spread "1" :color "red" :inset true}]
:description "Round trip test"})
"shadow.ref"
(ctob/make-token
{:name "shadow.ref"
:type :shadow
:value "{shadow.test}"})})))
exported (ctob/export-dtcg-json original-lib)
imported-lib (ctob/parse-decoded-json exported "")]
(t/testing "round trip preserves shadow tokens"
(let [original-token (ctob/get-token-by-name original-lib "test-set" "shadow.test")
imported-token (ctob/get-token-by-name imported-lib "test-set" "shadow.test")]
(t/is (some? imported-token))
(t/is (= (:type original-token) (:type imported-token)))
(t/is (= (:value original-token) (:value imported-token)))
(t/is (= (:description original-token) (:description imported-token))))
(let [original-ref (ctob/get-token-by-name original-lib "test-set" "shadow.ref")
imported-ref (ctob/get-token-by-name imported-lib "test-set" "shadow.ref")]
(t/is (some? imported-ref))
(t/is (= (:type original-ref) (:type imported-ref)))
(t/is (= (:value imported-ref) (:value original-ref))))))))

View File

@@ -7,8 +7,14 @@ test.beforeEach(async ({ page }) => {
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json"); await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams-tokens.json");
}); });
const setupEmptyTokensFile = async (page) => { const setupEmptyTokensFile = async (page, options = {}) => {
const { flags = [] } = options;
const workspacePage = new WorkspacePage(page); const workspacePage = new WorkspacePage(page);
if (flags.length > 0) {
await workspacePage.mockConfigFlags(flags);
}
await workspacePage.setupEmptyFile(); await workspacePage.setupEmptyFile();
await workspacePage.mockRPC( await workspacePage.mockRPC(
"get-team?id=*", "get-team?id=*",
@@ -1155,6 +1161,228 @@ test.describe("Tokens: Themes modal", () => {
name: newTokenTitle, name: newTokenTitle,
}); });
await expect(newToken).toBeVisible(); await expect(newToken).toBeVisible();
});
test("User adds shadow token with multiple shadows and applies it to shape", async ({
page,
}) => {
const { tokensUpdateCreateModal, tokensSidebar, workspacePage, tokenContextMenuForToken } =
await setupTokensFile(page, { flags: ["enable-token-shadow"] });
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
await test.step("Stage 1: Basic open", async () => {
// User adds shadow via the sidebar
await tokensTabPanel
.getByRole("button", { name: "Add Token: Shadow" })
.click();
await expect(tokensUpdateCreateModal).toBeVisible();
const nameField = tokensUpdateCreateModal.getByLabel("Name");
await nameField.fill("shadow.primary");
// User adds first shadow with a color from the color ramp
const firstShadowFields = tokensUpdateCreateModal.getByTestId(
"shadow-input-fields-0",
);
await expect(firstShadowFields).toBeVisible();
// 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");
await offsetXInput.fill("2");
await offsetYInput.fill("2");
await blurInput.fill("4");
await spreadInput.fill("0");
// Add color using the color picker
const colorBullet = firstShadowFields.getByTestId(
"token-form-color-bullet",
);
await colorBullet.click();
// Click on the color ramp to select a color
const valueSaturationSelector = tokensUpdateCreateModal.getByTestId(
"value-saturation-selector",
);
await expect(valueSaturationSelector).toBeVisible();
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(.*)$/);
// Wait for validation to complete
await expect(tokensUpdateCreateModal.getByText(/Resolved value:/).first()).toBeVisible();
// Save button should be enabled
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await expect(submitButton).toBeEnabled();
});
await test.step("Stage 2: Shadow adding/removing works", async () => {
const firstShadowFields = tokensUpdateCreateModal.getByTestId(
"shadow-input-fields-0",
);
const colorInput = firstShadowFields.getByLabel("Color");
const firstColorValue = await colorInput.inputValue();
// User adds a second shadow
const addButton = firstShadowFields.getByTestId("shadow-add-button-0");
await addButton.click();
const secondShadowFields = tokensUpdateCreateModal.getByTestId(
"shadow-input-fields-1",
);
await expect(secondShadowFields).toBeVisible();
// User adds a third shadow
const addButton2 = secondShadowFields.getByTestId("shadow-add-button-1");
await addButton2.click();
const thirdShadowFields = tokensUpdateCreateModal.getByTestId(
"shadow-input-fields-2",
);
await expect(thirdShadowFields).toBeVisible();
// 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");
await thirdOffsetXInput.fill("10");
await thirdOffsetYInput.fill("10");
await thirdBlurInput.fill("20");
await thirdSpreadInput.fill("5");
await thirdColorInput.fill("#FF0000");
// User removes the 2nd shadow
const removeButton2 = secondShadowFields.getByTestId("shadow-remove-button-1");
await removeButton2.click();
// Verify second shadow is removed
await expect(secondShadowFields.getByTestId("shadow-add-button-3")).not.toBeVisible();
// Verify that the first shadow kept its values
const firstOffsetXValue = await firstShadowFields.getByLabel("X").inputValue();
const firstOffsetYValue = await firstShadowFields.getByLabel("Y").inputValue();
const firstBlurValue = await firstShadowFields.getByLabel("Blur").inputValue();
const firstSpreadValue = await firstShadowFields.getByLabel("Spread").inputValue();
const firstColorValueAfter = await firstShadowFields.getByLabel("Color").inputValue();
await expect(firstOffsetXValue).toBe("2");
await expect(firstOffsetYValue).toBe("2");
await expect(firstBlurValue).toBe("4");
await expect(firstSpreadValue).toBe("0");
await expect(firstColorValueAfter).toBe(firstColorValue);
// Verify that the third shadow (now second) kept its values
// After removing index 1, the third shadow becomes the second shadow at index 1
const newSecondShadowFields = tokensUpdateCreateModal.getByTestId(
"shadow-input-fields-1",
);
await expect(newSecondShadowFields).toBeVisible();
const secondOffsetXValue = await newSecondShadowFields.getByLabel("X").inputValue();
const secondOffsetYValue = await newSecondShadowFields.getByLabel("Y").inputValue();
const secondBlurValue = await newSecondShadowFields.getByLabel("Blur").inputValue();
const secondSpreadValue = await newSecondShadowFields.getByLabel("Spread").inputValue();
const secondColorValue = await newSecondShadowFields.getByLabel("Color").inputValue();
await expect(secondOffsetXValue).toBe("10");
await expect(secondOffsetYValue).toBe("10");
await expect(secondBlurValue).toBe("20");
await expect(secondSpreadValue).toBe("5");
await expect(secondColorValue).toBe("#FF0000");
});
await test.step("Stage 3: Restore when switching tabs works", async () => {
const firstShadowFields = tokensUpdateCreateModal.getByTestId(
"shadow-input-fields-0",
);
const newSecondShadowFields = tokensUpdateCreateModal.getByTestId(
"shadow-input-fields-1",
);
const colorInput = firstShadowFields.getByLabel("Color");
const firstColorValue = await colorInput.inputValue();
// Switch to reference tab
const referenceTabButton = tokensUpdateCreateModal.getByTestId("reference-opt");
await referenceTabButton.click();
// Verify we're in reference mode - the composite fields should not be visible
await expect(firstShadowFields).not.toBeVisible();
// Switch back to composite tab
const compositeTabButton = tokensUpdateCreateModal.getByTestId("composite-opt");
await compositeTabButton.click();
// Verify that shadows are restored
await expect(firstShadowFields).toBeVisible();
await expect(newSecondShadowFields).toBeVisible();
// Verify first shadow values are still there
const restoredFirstOffsetX = await firstShadowFields.getByLabel("X").inputValue();
const restoredFirstOffsetY = await firstShadowFields.getByLabel("Y").inputValue();
const restoredFirstBlur = await firstShadowFields.getByLabel("Blur").inputValue();
const restoredFirstSpread = await firstShadowFields.getByLabel("Spread").inputValue();
const restoredFirstColor = await firstShadowFields.getByLabel("Color").inputValue();
await expect(restoredFirstOffsetX).toBe("2");
await expect(restoredFirstOffsetY).toBe("2");
await expect(restoredFirstBlur).toBe("4");
await expect(restoredFirstSpread).toBe("0");
await expect(restoredFirstColor).toBe(firstColorValue);
// Verify second shadow values are still there
const restoredSecondOffsetX = await newSecondShadowFields.getByLabel("X").inputValue();
const restoredSecondOffsetY = await newSecondShadowFields.getByLabel("Y").inputValue();
const restoredSecondBlur = await newSecondShadowFields.getByLabel("Blur").inputValue();
const restoredSecondSpread = await newSecondShadowFields.getByLabel("Spread").inputValue();
const restoredSecondColor = await newSecondShadowFields.getByLabel("Color").inputValue();
await expect(restoredSecondOffsetX).toBe("10");
await expect(restoredSecondOffsetY).toBe("10");
await expect(restoredSecondBlur).toBe("20");
await expect(restoredSecondSpread).toBe("5");
await expect(restoredSecondColor).toBe("#FF0000");
});
await test.step("Stage 4: Layer application works", async () => {
// Save the token
const submitButton = tokensUpdateCreateModal.getByRole("button", {
name: "Save",
});
await submitButton.click();
await expect(tokensUpdateCreateModal).not.toBeVisible();
// Verify token appears in sidebar
const shadowToken = tokensSidebar.getByRole("button", {
name: "shadow.primary",
});
await expect(shadowToken).toBeEnabled();
// Apply the shadow
await workspacePage.clickLayers();
await workspacePage.clickLeafLayer("Button");
const shadowSection = workspacePage.rightSidebar.getByText("Drop shadow");
await expect(shadowSection).toHaveCount(0);
await page.getByRole("tab", { name: "Tokens" }).click();
await shadowToken.click();
await expect(shadowSection).toHaveCount(2);
});
}); });
}); });
}); });

View File

@@ -324,6 +324,106 @@
(defn collect-typography-errors [token] (defn collect-typography-errors [token]
(group-by :typography-key (:errors token))) (group-by :typography-key (:errors token)))
(defn- parse-sd-token-shadow-inset
[value]
(let [references (seq (cto/find-token-value-references value))]
(cond
(boolean? value)
{:value value}
references
{:errors [(wte/error-with-value :error.style-dictionary/missing-reference references)]
:references references}
:else
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-shadow-type value)]})))
(defn- parse-sd-token-shadow-blur
"Parses shadow blur value (non-negative number)."
[value]
(let [parsed (parse-sd-token-general-value value)
valid? (and (:value parsed) (>= (:value parsed) 0))]
(cond
valid?
parsed
:else
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-shadow-blur value)]})))
(defn- parse-sd-token-shadow-spread
"Parses shadow spread value (non-negative number)."
[value]
(let [parsed (parse-sd-token-general-value value)
valid? (and (:value parsed) (>= (:value parsed) 0))]
(cond
valid?
parsed
:else
{:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value-shadow-spread value)]})))
(defn- parse-single-shadow
"Parses a single shadow map with properties: x, y, blur, spread, color, type."
[shadow-map shadow-index]
(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
:blur parse-sd-token-shadow-blur
:spread parse-sd-token-shadow-spread
:color parse-sd-token-color-value
:inset parse-sd-token-shadow-inset}
valid-shadow (reduce
(fn [acc [k v]]
(if-let [parser (get parsers k)]
(let [{:keys [errors value]} (parser v)]
(if (seq errors)
(add-keyed-errors acc k errors)
(assoc-in acc [:value k] (or value v))))
acc))
{:value {}}
shadow-map)]
valid-shadow))
(defn- parse-sd-token-shadow-value
"Parses shadow value and validates it."
[value]
(cond
;; Reference value (string)
(string? value) {:value value}
;; Empty value
(nil? value) {:errors [(wte/get-error-code :error.token/empty-input)]}
;; Invalid value
(not (js/Array.isArray value)) {:errors [(wte/error-with-value :error.style-dictionary/invalid-token-value value)]}
;; Array of shadows
:else
(let [converted (js->clj value :keywordize-keys true)
;; Parse each shadow with its index
parsed-shadows (map-indexed
(fn [idx shadow-map]
(parse-single-shadow shadow-map idx))
converted)
;; Collect all errors from all shadows
all-errors (mapcat :errors parsed-shadows)
;; Collect all values from shadows that have values
all-values (into [] (keep :value parsed-shadows))]
(if (seq all-errors)
{:errors all-errors
:value all-values}
{:value all-values}))))
(defn collect-shadow-errors [token shadow-index]
(group-by :shadow-key
(filter #(= (:shadow-index %) shadow-index)
(:errors token))))
(defn process-sd-tokens (defn process-sd-tokens
"Converts a StyleDictionary dictionary with resolved tokens (aka `sd-tokens`) back to clojure. "Converts a StyleDictionary dictionary with resolved tokens (aka `sd-tokens`) back to clojure.
The `get-origin-token` argument should be a function that takes an The `get-origin-token` argument should be a function that takes an
@@ -364,6 +464,7 @@
(parse-atomic-typography-value (:type origin-token) value) (parse-atomic-typography-value (:type origin-token) value)
(case (:type origin-token) (case (:type origin-token)
:typography (parse-composite-typography-value value) :typography (parse-composite-typography-value value)
:shadow (parse-sd-token-shadow-value value)
:color (parse-sd-token-color-value value) :color (parse-sd-token-color-value value)
:opacity (parse-sd-token-opacity-value value) :opacity (parse-sd-token-opacity-value value)
:stroke-width (parse-sd-token-stroke-width-value value) :stroke-width (parse-sd-token-stroke-width-value value)

View File

@@ -149,6 +149,36 @@
:ignore-touched true :ignore-touched true
:changed-sub-attr [:stroke-color]})))) :changed-sub-attr [:stroke-color]}))))
(defn value->shadow
"Transform a token shadow value into penpot shadow data structure"
[value]
(mapv (fn [{:keys [offsetX offsetY blur spread color inset]}]
{:id (random-uuid)
:hidden false
:offset-x offsetX
:offset-y offsetY
:blur blur
:color (value->color color)
:spread spread
:style
(case inset
true :inner-shadow
:drop-shadow)})
value))
(defn update-shadow
([value shape-ids attributes]
(update-shadow value shape-ids attributes nil))
([value shape-ids _attributes page-id]
(when (sequential? value)
(let [shadows (value->shadow value)]
(dwsh/update-shapes shape-ids
#(assoc % :shadow shadows)
{:reg-objects? true
:ignore-touched true
:page-id page-id
:attrs [:shadow]})))))
(defn update-fill-stroke (defn update-fill-stroke
([value shape-ids attributes] ([value shape-ids attributes]
(update-fill-stroke value shape-ids attributes nil)) (update-fill-stroke value shape-ids attributes nil))
@@ -643,6 +673,14 @@
:fields [{:label "Border Radius" :fields [{:label "Border Radius"
:key :border-radius}]}} :key :border-radius}]}}
:shadow
{:title "Shadow"
:attributes ctt/shadow-keys
:on-update-shape update-shadow
:modal {:key :tokens/shadow
:fields [{:label "Shadow"
:key :shadow}]}}
:color :color
{:title "Color" {:title "Color"
:attributes #{:fill} :attributes #{:fill}

View File

@@ -96,6 +96,18 @@
{:error/code :error.style-dictionary/composite-line-height-needs-font-size {:error/code :error.style-dictionary/composite-line-height-needs-font-size
:error/fn #(tr "workspace.tokens.composite-line-height-needs-font-size" %)} :error/fn #(tr "workspace.tokens.composite-line-height-needs-font-size" %)}
:error.style-dictionary/invalid-token-value-shadow-type
{:error/code :error.style-dictionary/invalid-token-value-shadow-type
:error/fn #(tr "workspace.tokens.invalid-shadow-type-token-value" %)}
:error.style-dictionary/invalid-token-value-shadow-blur
{:error/code :error.style-dictionary/invalid-token-value-shadow-blur
:error/fn #(tr "workspace.tokens.shadow-blur-range")}
:error.style-dictionary/invalid-token-value-shadow-spread
{:error/code :error.style-dictionary/invalid-token-value-shadow-spread
:error/fn #(tr "workspace.tokens.shadow-spread-range")}
:error/unknown :error/unknown
{:error/code :error/unknown {:error/code :error/unknown
:error/fn #(tr "labels.unknown-error")}}) :error/fn #(tr "labels.unknown-error")}})

View File

@@ -15,7 +15,13 @@
:line-height "Line Height" :line-height "Line Height"
:letter-spacing "Letter Spacing" :letter-spacing "Letter Spacing"
:text-case "Text Case" :text-case "Text Case"
:text-decoration "Text Decoration"}) :text-decoration "Text Decoration"
:offsetX "X"
:offsetY "Y"
:blur "Blur"
:spread "Spread"
:color "Color"
:inset "Inner Shadow"})
(defn format-token-value (defn format-token-value
"Converts token value of any shape to a string." "Converts token value of any shape to a string."
@@ -26,6 +32,9 @@
(str/join "\n") (str/join "\n")
(str "\n")) (str "\n"))
(and (sequential? token-value) (every? map? token-value))
(str/join "\n" (map format-token-value token-value))
(sequential? token-value) (sequential? token-value)
(str/join ", " token-value) (str/join ", " token-value)

View File

@@ -63,6 +63,7 @@
ctt/text-case-keys dwta/update-text-case ctt/text-case-keys dwta/update-text-case
ctt/text-decoration-keys dwta/update-text-decoration ctt/text-decoration-keys dwta/update-text-decoration
ctt/typography-token-keys dwta/update-typography ctt/typography-token-keys dwta/update-typography
ctt/shadow-keys dwta/update-shadow
#{:line-height} dwta/update-line-height #{:line-height} dwta/update-line-height
;; Layout ;; Layout

View File

@@ -34,7 +34,9 @@
(let [token-units? (contains? cf/flags :token-units) (let [token-units? (contains? cf/flags :token-units)
token-typography-composite-types? (contains? cf/flags :token-typography-composite) token-typography-composite-types? (contains? cf/flags :token-typography-composite)
token-typography-types? (contains? cf/flags :token-typography-types) token-typography-types? (contains? cf/flags :token-typography-types)
token-shadow? (contains? cf/flags :token-shadow)
all-types (cond-> dwta/token-properties all-types (cond-> dwta/token-properties
(not token-shadow?) (dissoc :shadow)
(not token-units?) (dissoc :number) (not token-units?) (dissoc :number)
(not token-typography-composite-types?) (remove-keys ctt/typography-token-keys) (not token-typography-composite-types?) (remove-keys ctt/typography-token-keys)
(not token-typography-types?) (remove-keys ctt/ff-typography-keys)) (not token-typography-types?) (remove-keys ctt/ff-typography-keys))

View File

@@ -279,7 +279,8 @@
:r3 "Bottom Right"} :r3 "Bottom Right"}
:hint (tr "workspace.tokens.radius") :hint (tr "workspace.tokens.radius")
:on-update-shape-all dwta/update-shape-radius-all :on-update-shape-all dwta/update-shape-radius-all
:on-update-shape update-shape-radius-for-corners})] :on-update-shape update-shape-radius-for-corners})
shadow (partial generic-attribute-actions #{:shadow} "Shadow")]
{:border-radius border-radius {:border-radius border-radius
:color (fn [context-data] :color (fn [context-data]
(concat (concat
@@ -303,6 +304,7 @@
:text-decoration text-decoration :text-decoration text-decoration
:font-weight font-weight :font-weight font-weight
:typography typography :typography typography
:shadow shadow
:dimensions (fn [context-data] :dimensions (fn [context-data]
(-> (concat (-> (concat
(when (seq (sizing-attribute-actions context-data)) [{:title "Sizing" :submenu :sizing}]) (when (seq (sizing-attribute-actions context-data)) [{:title "Sizing" :submenu :sizing}])

View File

@@ -214,6 +214,24 @@
(when (empty? (:value token)) (when (empty? (:value token))
(wte/get-error-code :error.token/empty-input))) (wte/get-error-code :error.token/empty-input)))
(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 %) [:offsetX :offsetY :blur :spread :color]))
(:value token)))
(wte/get-error-code :error.token/empty-input)))
(defn validate-typography-token (defn validate-typography-token
[{:keys [token-value] :as props}] [{:keys [token-value] :as props}]
(cond (cond
@@ -232,6 +250,27 @@
check-typography-token-self-reference]) check-typography-token-self-reference])
(default-validate-token)))) (default-validate-token))))
(defn validate-shadow-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/shadow-composite-token-reference? token-value) (default-validate-token props)
;; Validate composite token
:else
(-> props
(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))))
(defn use-debonced-resolve-callback (defn use-debonced-resolve-callback
"Resolves a token values using `StyleDictionary`. "Resolves a token values using `StyleDictionary`.
This function is debounced as the resolving might be an expensive calculation. This function is debounced as the resolving might be an expensive calculation.
@@ -690,6 +729,13 @@
(on-external-update-value (get @backup-state-ref next-tab)) (on-external-update-value (get @backup-state-ref next-tab))
(set-active-tab next-tab)))) (set-active-tab next-tab))))
update-composite-value
(mf/use-fn
(fn [f]
(clear-resolve-value)
(swap! backup-state-ref f)
(on-external-update-value (get @backup-state-ref :composite))))
;; Store updated value in backup-state-ref ;; Store updated value in backup-state-ref
on-update-value' on-update-value'
(mf/use-fn (mf/use-fn
@@ -724,7 +770,8 @@
:is-reference-fn is-reference-fn})] :is-reference-fn is-reference-fn})]
[:> composite-tab [:> composite-tab
(mf/spread-props props {:default-value default-value (mf/spread-props props {:default-value default-value
:on-update-value on-update-value'})])]])) :on-update-value on-update-value'
:update-composite-value update-composite-value})])]]))
(mf/defc composite-form* (mf/defc composite-form*
"Wrapper around form* that manages composite/reference tab state. "Wrapper around form* that manages composite/reference tab state.
@@ -828,7 +875,8 @@
(fn [] (fn []
(let [open? (not color-ramp-open?)] (let [open? (not color-ramp-open?)]
(reset! color-ramp-open* open?) (reset! color-ramp-open* open?)
(on-display-colorpicker open?)))) (when on-display-colorpicker
(on-display-colorpicker open?)))))
swatch swatch
(mf/html (mf/html
@@ -913,6 +961,278 @@
:custom-input-token-value color-picker* :custom-input-token-value color-picker*
:custom-input-token-value-props custom-input-token-value-props})])) :custom-input-token-value-props custom-input-token-value-props})]))
(mf/defc shadow-color-picker-wrapper*
"Wrapper for color-picker* that passes shadow color state from parent.
Similar to color-form* but receives color state from shadow-value-inputs*."
[{:keys [placeholder label default-value input-ref on-update-value on-external-update-value token-resolve-result shadow-color]}]
(let [;; Use the color state passed from parent (shadow-value-inputs*)
resolved-color (get token-resolve-result :resolved-value)
color (or shadow-color resolved-color default-value "")
custom-input-token-value-props
(mf/use-memo
(mf/deps color)
(fn []
{:color color}))]
[:> color-picker*
{:placeholder placeholder
:label label
:default-value default-value
:input-ref input-ref
:on-update-value on-update-value
:on-external-update-value on-external-update-value
:custom-input-token-value-props custom-input-token-value-props
:token-resolve-result token-resolve-result}]))
(def ^:private shadow-inputs
#(d/ordered-map
:offsetX
{:label (tr "workspace.tokens.shadow-x")
:placeholder (tr "workspace.tokens.shadow-x")}
:offsetY
{:label (tr "workspace.tokens.shadow-y")
:placeholder (tr "workspace.tokens.shadow-y")}
:blur
{:label (tr "workspace.tokens.shadow-blur")
:placeholder (tr "workspace.tokens.shadow-blur")}
:spread
{:label (tr "workspace.tokens.shadow-spread")
:placeholder (tr "workspace.tokens.shadow-spread")}
:color
{:label (tr "workspace.tokens.shadow-color")
:placeholder (tr "workspace.tokens.shadow-color")}
:inset
{:label (tr "workspace.tokens.shadow-inset")
:placeholder (tr "workspace.tokens.shadow-inset")}))
(mf/defc inset-type-select*
[{:keys [default-value shadow-idx label on-change]}]
(let [selected* (mf/use-state (or (str default-value) "false"))
selected (deref selected*)
on-change
(mf/use-fn
(mf/deps on-change selected shadow-idx)
(fn [value e]
(obj/set! e "tokenValue" (if (= "true" value) true false))
(on-change e)
(reset! selected* (str value))))]
[:div {:class (stl/css :input-row)}
[:div {:class (stl/css :inset-label)} label]
[:& radio-buttons {:selected selected
:on-change on-change
:name (str "inset-select-" shadow-idx)}
[:& radio-button {:value "false"
:title "false"
:icon "❌"
:id (str "inset-default-" shadow-idx)}]
[:& radio-button {:value "true"
:title "true"
:icon "✅"
:id (str "inset-false-" shadow-idx)}]]]))
(mf/defc shadow-input*
[{:keys [default-value label placeholder shadow-idx input-type on-update-value on-external-update-value token-resolve-result errors-by-key shadow-color]}]
(let [color-input-ref (mf/use-ref)
on-change
(mf/use-fn
(mf/deps shadow-idx input-type on-update-value)
(fn [e]
(-> (obj/set! e "tokenTypeAtIndex" [shadow-idx input-type])
(on-update-value))))
on-external-update-value'
(mf/use-fn
(mf/deps shadow-idx input-type on-external-update-value)
(fn [v]
(on-external-update-value [shadow-idx input-type] v)))
resolved (get-in token-resolve-result [:resolved-value shadow-idx input-type])
errors (get errors-by-key input-type)
should-show? (or (some? resolved) (seq errors))
token-prop (when should-show?
(d/without-nils
{:resolved-value resolved
:errors errors}))]
(case input-type
:inset
[:> inset-type-select*
{:default-value default-value
:shadow-idx shadow-idx
:label label
:on-change on-change}]
:color
[:> shadow-color-picker-wrapper*
{:placeholder placeholder
:label label
:default-value default-value
:input-ref color-input-ref
:on-update-value on-change
:on-external-update-value on-external-update-value'
:token-resolve-result token-prop
:shadow-color shadow-color
:data-testid (str "shadow-color-input-" shadow-idx)}]
[:div {:class (stl/css :input-row)
:data-testid (str "shadow-" (name input-type) "-input-" shadow-idx)}
[:> input-token*
{:label label
:placeholder placeholder
:default-value default-value
:on-change on-change
:token-resolve-result token-prop}]])))
(mf/defc shadow-input-fields*
[{:keys [shadow shadow-idx on-remove-shadow on-add-shadow is-remove-disabled on-update-value token-resolve-result errors-by-key on-external-update-value shadow-color] :as props}]
(let [on-remove-shadow
(mf/use-fn
(mf/deps shadow-idx on-remove-shadow)
#(on-remove-shadow shadow-idx))]
[:div {:data-testid (str "shadow-input-fields-" shadow-idx)}
[:> icon-button* {:icon i/add
:type "button"
:on-click on-add-shadow
:data-testid (str "shadow-add-button-" shadow-idx)
:aria-label (tr "workspace.tokens.shadow-add-shadow")}]
[:> icon-button* {:variant "ghost"
:type "button"
:icon i/remove
:on-click on-remove-shadow
:disabled is-remove-disabled
:data-testid (str "shadow-remove-button-" shadow-idx)
:aria-label (tr "workspace.tokens.shadow-remove-shadow")}]
(for [[input-type {:keys [label placeholder]}] (shadow-inputs)]
[:> shadow-input*
{:key (str input-type shadow-idx)
:input-type input-type
:label label
:placeholder placeholder
:shadow-idx shadow-idx
:default-value (get shadow input-type)
:on-update-value on-update-value
:token-resolve-result token-resolve-result
:errors-by-key errors-by-key
:on-external-update-value on-external-update-value
:shadow-color shadow-color}])]))
(mf/defc shadow-value-inputs*
[{:keys [default-value on-update-value token-resolve-result update-composite-value] :as props}]
(let [shadows* (mf/use-state (or default-value [{}]))
shadows (deref shadows*)
shadows-count (count shadows)
composite-token? (not (cto/typography-composite-token-reference? (:value token-resolve-result)))
;; Maintain a map of color states for each shadow to prevent reset on add/remove
shadow-colors* (mf/use-state {})
shadow-colors (deref shadow-colors*)
;; Initialize color states for each shadow index
_ (mf/use-effect
(mf/deps shadows)
(fn []
(doseq [[idx shadow] (d/enumerate shadows)]
(when-not (contains? shadow-colors idx)
(let [resolved-color (get-in token-resolve-result [:resolved-value idx :color])
initial-color (or resolved-color (get shadow :color) "")]
(swap! shadow-colors* assoc idx initial-color))))))
;; Define on-external-update-value here where we have access to on-update-value
on-external-update-value
(mf/use-callback
(mf/deps on-update-value shadow-colors*)
(fn [token-type-at-index value]
(let [[idx token-type] token-type-at-index
e (js-obj)]
;; Update shadow color state if this is a color update
(when (= token-type :color)
(swap! shadow-colors* assoc idx value))
(obj/set! e "tokenTypeAtIndex" token-type-at-index)
(obj/set! e "target" #js {:value value})
(on-update-value e))))
on-add-shadow
(mf/use-fn
(mf/deps shadows update-composite-value)
(fn []
(update-composite-value
(fn [state]
(let [new-state (update state :composite (fnil conj []) {})]
(reset! shadows* (:composite new-state))
new-state)))))
on-remove-shadow
(mf/use-fn
(mf/deps shadows update-composite-value)
(fn [idx]
(update-composite-value
(fn [state]
(let [new-state (update state :composite d/remove-at-index idx)]
(reset! shadows* (:composite new-state))
new-state)))))]
[:div {:class (stl/css :nested-input-row)}
(for [[shadow-idx shadow] (d/enumerate shadows)
:let [is-remove-disabled (= shadows-count 1)
key (str shadows-count shadow-idx)
errors-by-key (when composite-token?
(sd/collect-shadow-errors token-resolve-result shadow-idx))]]
[:div {:key key
:class (stl/css :nested-input-row)}
[:> shadow-input-fields*
{:is-remove-disabled is-remove-disabled
:shadow-idx shadow-idx
:on-add-shadow on-add-shadow
:on-remove-shadow on-remove-shadow
:shadow shadow
:on-update-value on-update-value
:token-resolve-result token-resolve-result
:errors-by-key errors-by-key
:on-external-update-value on-external-update-value
:shadow-color (get shadow-colors shadow-idx "")}]])]))
(mf/defc shadow-form*
[{:keys [token] :rest props}]
(let [on-get-token-value
(mf/use-callback
(fn [e prev-composite-value]
(let [prev-composite-value (or prev-composite-value [])
[idx token-type :as token-type-at-index] (obj/get e "tokenTypeAtIndex")
input-value (case token-type
:inset (obj/get e "tokenValue")
(dom/get-target-val e))
reference-value-input? (not token-type-at-index)]
(cond
reference-value-input? input-value
(and (string? input-value) (empty? input-value)) (update prev-composite-value idx dissoc token-type)
:else (assoc-in prev-composite-value token-type-at-index input-value)))))
update-composite-backup-value
(mf/use-callback
(fn [prev-composite-value e]
(let [[idx token-type :as token-type-at-index] (obj/get e "tokenTypeAtIndex")
token-value (case token-type
:inset (obj/get e "tokenValue")
(dom/get-target-val e))
valid? (case token-type
:inset (boolean? token-value)
(seq token-value))]
(if valid?
(assoc-in (or prev-composite-value []) token-type-at-index token-value)
;; Remove empty values so they don't retrigger validation when switching tabs
(update prev-composite-value idx dissoc token-type)))))]
[:> composite-form*
(mf/spread-props props {:token token
:composite-tab shadow-value-inputs*
:reference-icon i/text-typography
:is-reference-fn cto/typography-composite-token-reference?
:title (tr "workspace.tokens.shadow-title")
:validate-token validate-shadow-token
:on-get-token-value on-get-token-value
:update-composite-backup-value update-composite-backup-value})]))
(mf/defc font-selector-wrapper* (mf/defc font-selector-wrapper*
[{:keys [font input-ref on-select-font on-close-font-selector]}] [{:keys [font input-ref on-select-font on-close-font-selector]}]
(let [current-font* (mf/use-state (or font (let [current-font* (mf/use-state (or font
@@ -1158,6 +1478,7 @@
(case token-type' (case token-type'
:color [:> color-form* props] :color [:> color-form* props]
:typography [:> typography-form* props] :typography [:> typography-form* props]
:shadow [:> shadow-form* props]
:font-family [:> font-family-form* props] :font-family [:> font-family-form* props]
:text-case [:> text-case-form* props] :text-case [:> text-case-form* props]
:text-decoration [:> text-decoration-form* props] :text-decoration [:> text-decoration-form* props]

View File

@@ -124,7 +124,7 @@
(mf/defc box-shadow-modal (mf/defc box-shadow-modal
{::mf/register modal/components {::mf/register modal/components
::mf/register-as :tokens/box-shadow} ::mf/register-as :tokens/shadow}
[properties] [properties]
[:& token-update-create-modal properties]) [:& token-update-create-modal properties])

View File

@@ -469,6 +469,44 @@
(t/is (= (:stroke-width (:applied-tokens rect-without-stroke')) (:name token-target'))) (t/is (= (:stroke-width (:applied-tokens rect-without-stroke')) (:name token-target')))
(t/is (= (get-in rect-without-stroke' [:strokes 0 :stroke-width]) 10)))))))))) (t/is (= (get-in rect-without-stroke' [:strokes 0 :stroke-width]) 10))))))))))
(t/deftest test-apply-shadow
(t/testing "applies shadow token and updates the shapes with shadow"
(t/async
done
(let [shadow-token {:name "shadow.sm"
:value [{:offsetX 10
:offsetY 10
:blur 10
:spread 10
:color "rgba(0,0,0,0.5)"
:inset false}]
:type :shadow}
file (-> (setup-file-with-tokens)
(update-in [:data :tokens-lib]
#(ctob/add-token % (cthi/id :set-a)
(ctob/make-token shadow-token))))
store (ths/setup-store file)
rect-1 (cths/get-shape file :rect-1)
events [(dwta/apply-token {:shape-ids [(:id rect-1)]
:attributes #{:shadow}
:token (toht/get-token file "shadow.sm")
:on-update-shape dwta/update-shadow})]]
(tohs/run-store-async
store done events
(fn [new-state]
(let [file' (ths/get-file-from-state new-state)
token-target' (toht/get-token file' "shadow.sm")
rect-1' (cths/get-shape file' :rect-1)
shadow (first (:shadow rect-1'))]
(t/testing "token got applied to rect with shadow and shape shadow got updated"
(t/is (= (:shadow (:applied-tokens rect-1')) (:name token-target')))
(t/is (= (:offset-x shadow) 10))
(t/is (= (:offset-y shadow) 10))
(t/is (= (:blur shadow) 10))
(t/is (= (:spread shadow) 10))
(t/is (= (get-in shadow [:color :color]) "#000000"))
(t/is (= (get-in shadow [:color :opacity]) 0.5))))))))))
(t/deftest test-apply-font-size (t/deftest test-apply-font-size
(t/testing "applies font-size token and updates the text font-size" (t/testing "applies font-size token and updates the text font-size"
(t/async (t/async

View File

@@ -1,3 +1,50 @@
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1135
msgid "workspace.tokens.shadow-title"
msgstr "Shadows"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:943
msgid "workspace.tokens.shadow-x"
msgstr "X"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:946
msgid "workspace.tokens.shadow-y"
msgstr "Y"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:949
msgid "workspace.tokens.shadow-blur"
msgstr "Blur"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:952
msgid "workspace.tokens.shadow-spread"
msgstr "Spread"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:955
msgid "workspace.tokens.shadow-color"
msgstr "Color"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:958
msgid "workspace.tokens.shadow-inset"
msgstr "Inset"
#: src/app/main/data/workspace/tokens/errors.cljs:109
msgid "workspace.tokens.shadow-spread-range"
msgstr "Shadow spread must be greater than or equal to 0."
#: src/app/main/data/workspace/tokens/errors.cljs:105
msgid "workspace.tokens.shadow-blur-range"
msgstr "Shadow blur must be greater than or equal to 0."
#: src/app/main/data/workspace/tokens/errors.cljs:101
msgid "workspace.tokens.invalid-shadow-type-token-value"
msgstr "Invalid shadow type: only 'innerShadow' or 'dropShadow' are accepted"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1037
msgid "workspace.tokens.shadow-add-shadow"
msgstr "Add Shadow"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1044
msgid "workspace.tokens.shadow-remove-shadow"
msgstr "Remove Shadow"
msgid "" msgid ""
msgstr "" msgstr ""
"PO-Revision-Date: 2025-06-09 09:13+0000\n" "PO-Revision-Date: 2025-06-09 09:13+0000\n"

View File

@@ -1,3 +1,50 @@
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1135
msgid "workspace.tokens.shadow-title"
msgstr "Sombras"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:943
msgid "workspace.tokens.shadow-x"
msgstr "X"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:946
msgid "workspace.tokens.shadow-y"
msgstr "Y"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:949
msgid "workspace.tokens.shadow-blur"
msgstr "Blur"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:952
msgid "workspace.tokens.shadow-spread"
msgstr "Spread"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:955
msgid "workspace.tokens.shadow-color"
msgstr "Color"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:958
msgid "workspace.tokens.shadow-inset"
msgstr "Interior"
#: src/app/main/data/workspace/tokens/errors.cljs:109
msgid "workspace.tokens.shadow-spread-range"
msgstr "La extensión (spread) de la sombra debe ser mayor o igual a 0."
#: src/app/main/data/workspace/tokens/errors.cljs:105
msgid "workspace.tokens.shadow-blur-range"
msgstr "El desenfoque (blur) de la sombra debe ser mayor o igual a 0."
#: src/app/main/data/workspace/tokens/errors.cljs:101
msgid "workspace.tokens.invalid-shadow-type-token-value"
msgstr "Tipo de sombra no válida: solo se aceptan 'innerShadow' o 'dropShadow'"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1037
msgid "workspace.tokens.shadow-add-shadow"
msgstr "Añadir Sombra"
#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1044
msgid "workspace.tokens.shadow-remove-shadow"
msgstr "Eliminar Sombra"
msgid "" msgid ""
msgstr "" msgstr ""
"PO-Revision-Date: 2025-10-07 16:35+0000\n" "PO-Revision-Date: 2025-10-07 16:35+0000\n"