mirror of
https://github.com/penpot/penpot.git
synced 2025-12-11 22:14:05 +01:00
✨ Add box shadow token
This commit is contained in:
committed by
Andrés Moya
parent
5c8b401037
commit
e681f95a70
@@ -123,6 +123,7 @@
|
||||
:token-color
|
||||
:token-typography-types
|
||||
:token-typography-composite
|
||||
:token-shadow
|
||||
:transit-readable-response
|
||||
:user-feedback
|
||||
;; TODO: remove this flag.
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
(def token-type->dtcg-token-type
|
||||
{:boolean "boolean"
|
||||
:border-radius "borderRadius"
|
||||
:shadow "shadow"
|
||||
:color "color"
|
||||
:dimensions "dimension"
|
||||
:font-family "fontFamilies"
|
||||
@@ -77,7 +78,8 @@
|
||||
;; Allow these properties to be imported with singular key names for backwards compability
|
||||
(assoc "fontWeight" :font-weight
|
||||
"fontSize" :font-size
|
||||
"fontFamily" :font-family)))
|
||||
"fontFamily" :font-family
|
||||
"boxShadow" :shadow)))
|
||||
|
||||
(def composite-token-type->dtcg-token-type
|
||||
"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 ^:private schema:shadow
|
||||
[:map {:title "ShadowTokenAttrs"}
|
||||
[:shadow {:optional true} token-name-ref]])
|
||||
|
||||
(def shadow-keys (schema-keys schema:shadow))
|
||||
|
||||
(def ^:private schema:stroke-width
|
||||
[:map
|
||||
[:stroke-width {:optional true} token-name-ref]])
|
||||
@@ -271,6 +279,7 @@
|
||||
|
||||
(def all-keys (set/union color-keys
|
||||
border-radius-keys
|
||||
shadow-keys
|
||||
stroke-width-keys
|
||||
sizing-keys
|
||||
opacity-keys
|
||||
@@ -289,6 +298,7 @@
|
||||
[:merge {:title "AppliedTokens"}
|
||||
schema:tokens
|
||||
schema:border-radius
|
||||
schema:shadow
|
||||
schema:sizing
|
||||
schema:spacing
|
||||
schema:rotation
|
||||
@@ -334,6 +344,7 @@
|
||||
(font-weight-keys shape-attr) #{shape-attr :typography}
|
||||
|
||||
(border-radius-keys shape-attr) #{shape-attr}
|
||||
(shadow-keys shape-attr) #{shape-attr}
|
||||
(sizing-keys shape-attr) #{shape-attr}
|
||||
(opacity-keys shape-attr) #{shape-attr}
|
||||
(spacing-keys shape-attr) #{shape-attr}
|
||||
@@ -361,6 +372,7 @@
|
||||
rotation-keys
|
||||
sizing-keys
|
||||
opacity-keys
|
||||
shadow-keys
|
||||
position-attributes))
|
||||
|
||||
(def rect-attributes
|
||||
@@ -444,6 +456,30 @@
|
||||
spacing-margin-keys)]
|
||||
(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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -514,26 +550,11 @@
|
||||
[token-value]
|
||||
(string? token-value))
|
||||
|
||||
(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}})
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SHADOW
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn shadow-composite-token-reference?
|
||||
"Predicate if a shadow composite token is a reference value - a string pointing to another reference token."
|
||||
[token-value]
|
||||
(string? token-value))
|
||||
|
||||
@@ -1552,6 +1552,46 @@ Will return a value that matches this schema:
|
||||
;; Reference 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
|
||||
"Convert a tokens tree in the decoded json fragment into a flat map,
|
||||
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
|
||||
:font-family (convert-dtcg-font-family token-value)
|
||||
:typography (convert-dtcg-typography-composite token-value)
|
||||
:shadow (convert-dtcg-shadow-composite token-value)
|
||||
token-value))
|
||||
:description (get v "$description")))
|
||||
;; Discard unknown type tokens
|
||||
@@ -1739,11 +1780,32 @@ Will return a value that matches this schema:
|
||||
{} 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]
|
||||
(cond-> {"$value" (cond-> (:value token)
|
||||
;; Transform typography token values
|
||||
(= :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))}
|
||||
(:description token) (assoc "$description" (:description token))))
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1362,9 +1362,7 @@
|
||||
{:name "button.primary.background"
|
||||
:type :color
|
||||
:value "{accent.default}"
|
||||
:description ""})))
|
||||
(t/testing "invalid tokens got discarded"
|
||||
(t/is (nil? (ctob/get-token-by-name lib "theme" "boxShadow.default")))))))
|
||||
:description ""}))))))
|
||||
|
||||
#?(:clj
|
||||
(t/deftest parse-multi-set-dtcg-json
|
||||
@@ -1392,9 +1390,7 @@
|
||||
{:name "button.primary.background"
|
||||
:type :color
|
||||
:value "{accent.default}"
|
||||
:description ""})))
|
||||
(t/testing "invalid tokens got discarded"
|
||||
(t/is (nil? (ctob/get-token-by-name lib "theme" "boxShadow.default")))))))
|
||||
:description ""}))))))
|
||||
|
||||
#?(:clj
|
||||
(t/deftest parse-multi-set-dtcg-json-default-team
|
||||
@@ -1893,3 +1889,130 @@
|
||||
(t/is (some? imported-single))
|
||||
(t/is (= (:type imported-single) (:type 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))))))))
|
||||
|
||||
@@ -7,8 +7,14 @@ test.beforeEach(async ({ page }) => {
|
||||
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);
|
||||
if (flags.length > 0) {
|
||||
await workspacePage.mockConfigFlags(flags);
|
||||
}
|
||||
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.mockRPC(
|
||||
"get-team?id=*",
|
||||
@@ -1155,6 +1161,228 @@ test.describe("Tokens: Themes modal", () => {
|
||||
name: newTokenTitle,
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -324,6 +324,106 @@
|
||||
(defn collect-typography-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
|
||||
"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
|
||||
@@ -364,6 +464,7 @@
|
||||
(parse-atomic-typography-value (:type origin-token) value)
|
||||
(case (:type origin-token)
|
||||
:typography (parse-composite-typography-value value)
|
||||
:shadow (parse-sd-token-shadow-value value)
|
||||
:color (parse-sd-token-color-value value)
|
||||
:opacity (parse-sd-token-opacity-value value)
|
||||
:stroke-width (parse-sd-token-stroke-width-value value)
|
||||
|
||||
@@ -149,6 +149,36 @@
|
||||
:ignore-touched true
|
||||
: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
|
||||
([value shape-ids attributes]
|
||||
(update-fill-stroke value shape-ids attributes nil))
|
||||
@@ -643,6 +673,14 @@
|
||||
:fields [{:label "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
|
||||
{:title "Color"
|
||||
:attributes #{:fill}
|
||||
|
||||
@@ -96,6 +96,18 @@
|
||||
{:error/code :error.style-dictionary/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/code :error/unknown
|
||||
:error/fn #(tr "labels.unknown-error")}})
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
:line-height "Line Height"
|
||||
:letter-spacing "Letter Spacing"
|
||||
: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
|
||||
"Converts token value of any shape to a string."
|
||||
@@ -26,6 +32,9 @@
|
||||
(str/join "\n")
|
||||
(str "\n"))
|
||||
|
||||
(and (sequential? token-value) (every? map? token-value))
|
||||
(str/join "\n" (map format-token-value token-value))
|
||||
|
||||
(sequential? token-value)
|
||||
(str/join ", " token-value)
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
ctt/text-case-keys dwta/update-text-case
|
||||
ctt/text-decoration-keys dwta/update-text-decoration
|
||||
ctt/typography-token-keys dwta/update-typography
|
||||
ctt/shadow-keys dwta/update-shadow
|
||||
#{:line-height} dwta/update-line-height
|
||||
|
||||
;; Layout
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
(let [token-units? (contains? cf/flags :token-units)
|
||||
token-typography-composite-types? (contains? cf/flags :token-typography-composite)
|
||||
token-typography-types? (contains? cf/flags :token-typography-types)
|
||||
token-shadow? (contains? cf/flags :token-shadow)
|
||||
all-types (cond-> dwta/token-properties
|
||||
(not token-shadow?) (dissoc :shadow)
|
||||
(not token-units?) (dissoc :number)
|
||||
(not token-typography-composite-types?) (remove-keys ctt/typography-token-keys)
|
||||
(not token-typography-types?) (remove-keys ctt/ff-typography-keys))
|
||||
|
||||
@@ -279,7 +279,8 @@
|
||||
:r3 "Bottom Right"}
|
||||
:hint (tr "workspace.tokens.radius")
|
||||
: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
|
||||
:color (fn [context-data]
|
||||
(concat
|
||||
@@ -303,6 +304,7 @@
|
||||
:text-decoration text-decoration
|
||||
:font-weight font-weight
|
||||
:typography typography
|
||||
:shadow shadow
|
||||
:dimensions (fn [context-data]
|
||||
(-> (concat
|
||||
(when (seq (sizing-attribute-actions context-data)) [{:title "Sizing" :submenu :sizing}])
|
||||
|
||||
@@ -214,6 +214,24 @@
|
||||
(when (empty? (:value token))
|
||||
(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
|
||||
[{:keys [token-value] :as props}]
|
||||
(cond
|
||||
@@ -232,6 +250,27 @@
|
||||
check-typography-token-self-reference])
|
||||
(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
|
||||
"Resolves a token values using `StyleDictionary`.
|
||||
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))
|
||||
(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
|
||||
on-update-value'
|
||||
(mf/use-fn
|
||||
@@ -724,7 +770,8 @@
|
||||
:is-reference-fn is-reference-fn})]
|
||||
[:> composite-tab
|
||||
(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*
|
||||
"Wrapper around form* that manages composite/reference tab state.
|
||||
@@ -828,7 +875,8 @@
|
||||
(fn []
|
||||
(let [open? (not color-ramp-open?)]
|
||||
(reset! color-ramp-open* open?)
|
||||
(on-display-colorpicker open?))))
|
||||
(when on-display-colorpicker
|
||||
(on-display-colorpicker open?)))))
|
||||
|
||||
swatch
|
||||
(mf/html
|
||||
@@ -913,6 +961,278 @@
|
||||
:custom-input-token-value color-picker*
|
||||
: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*
|
||||
[{:keys [font input-ref on-select-font on-close-font-selector]}]
|
||||
(let [current-font* (mf/use-state (or font
|
||||
@@ -1158,8 +1478,9 @@
|
||||
(case token-type'
|
||||
:color [:> color-form* props]
|
||||
:typography [:> typography-form* props]
|
||||
:shadow [:> shadow-form* props]
|
||||
:font-family [:> font-family-form* props]
|
||||
:text-case [:> text-case-form* props]
|
||||
:text-decoration [:> text-decoration-form* props]
|
||||
:font-weight [:> font-weight-form* props]
|
||||
[:> form* props])))
|
||||
[:> form* props])))
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
|
||||
(mf/defc box-shadow-modal
|
||||
{::mf/register modal/components
|
||||
::mf/register-as :tokens/box-shadow}
|
||||
::mf/register-as :tokens/shadow}
|
||||
[properties]
|
||||
[:& token-update-create-modal properties])
|
||||
|
||||
|
||||
@@ -469,6 +469,44 @@
|
||||
(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/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/testing "applies font-size token and updates the text font-size"
|
||||
(t/async
|
||||
|
||||
@@ -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 ""
|
||||
msgstr ""
|
||||
"PO-Revision-Date: 2025-06-09 09:13+0000\n"
|
||||
|
||||
@@ -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 ""
|
||||
msgstr ""
|
||||
"PO-Revision-Date: 2025-10-07 16:35+0000\n"
|
||||
|
||||
Reference in New Issue
Block a user