diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 4cb6cedc60..414179753c 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1165,3 +1165,40 @@ [class current-class] (str (if (some? class) (str class " ") "") current-class)) + + +(defn nth-index-of* + "Finds the nth occurrence of `char` in `string`, searching either forward or backward. + `dir` must be :forward (left to right) or :backward (right to left). + Returns the absolute index of the match, or nil if fewer than n occurrences exist." + [string char n dir] + (loop [s string + offset 0 + cnt 1] + (let [index (case dir + :forward (str/index-of s char) + :backward (str/last-index-of s char))] + (cond + (nil? index) nil + (= cnt n) (case dir + :forward (+ index offset) + :backward index) + :else (case dir + :forward (recur (str/slice s (inc index)) + (+ offset index 1) + (inc cnt)) + :backward (recur (str/slice s 0 index) + offset + (inc cnt))))))) + +(defn nth-index-of + "Returns the index of the nth occurrence of `char` in `string`, searching left to right. + Returns nil if fewer than n occurrences exist." + [string char n] + (nth-index-of* string char n :forward)) + +(defn nth-last-index-of + "Returns the index of the nth occurrence of `char` in `string`, searching right to left. + Returns nil if fewer than n occurrences exist." + [string char n] + (nth-index-of* string char n :backward)) \ No newline at end of file diff --git a/common/src/app/common/data/macros.cljc b/common/src/app/common/data/macros.cljc index dc23426a95..2fdc9bc963 100644 --- a/common/src/app/common/data/macros.cljc +++ b/common/src/app/common/data/macros.cljc @@ -40,6 +40,13 @@ (list `c/get key))) keys))))) +(defmacro number + "Coerce number to number in a multiplatform way" + [o] + (if (:ns &env) + (with-meta o {:tag 'number}) + `(double ~o))) + (defmacro str [& params] `(str/concat ~@params)) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 6d7d49a98d..12398760b3 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -121,6 +121,7 @@ :terms-and-privacy-checkbox :tiered-file-data-storage :token-base-font-size + :token-combobox :token-color :token-shadow :token-tokenscript diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index e3e541da33..55ecc842e7 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -7,6 +7,7 @@ (ns app.common.types.token (:require [app.common.data :as d] + [app.common.data.macros :as dm] [app.common.schema :as sm] [app.common.schema.generators :as sg] [app.common.time :as ct] @@ -637,3 +638,107 @@ (when (font-weight-values weight) (cond-> {:weight weight} italic? (assoc :style "italic"))))) + + +;;;;;; Combobox token parsing + +(defn- inside-ref? + "Returns true if `position` in `value` is inside an open reference block (i.e. after a `{` + that has no matching `}` to its left). + A reference block is considered open when the last `{` appears after the last `}`, + or when there is a `{` but no `}` at all to the left of `position`." + [value position] + (let [left (str/slice value 0 position) + last-open (str/last-index-of left "{") + last-close (str/last-index-of left "}")] + (and (some? last-open) + (or (nil? last-close) + (< last-close last-open))))) + +(defn- block-open-start + "Returns the index of the leftmost `{` in the run of consecutive `{` characters + that contains the last `{` before `position` in `value`. + Used to find where a reference block truly starts when multiple braces are stacked." + [value position] + (let [left (str/slice value 0 position) + last-open (str/last-index-of left "{")] + (loop [i last-open] + (if (and i + (> i 0) + (= (nth left (dec i)) \{)) + (recur (dec i)) + i)))) + +(defn- start-ref-position + "Returns the position where the current token (reference candidate) starts, + relative to `position` in `value`. + The start is determined by whichever comes last: the opening `{` of the current + reference block or the character after the last space before `position`." + [value position] + (let [left (str/slice value 0 position) + open-pos (block-open-start value position) + space-pos (some-> (str/last-index-of left " ") inc)] + (->> [open-pos space-pos] + (remove nil?) + sort + last))) + +(defn- inside-closed-ref? + "Returns true if `position` falls inside a complete (closed) reference block, + i.e. there is a `{` to the left and a `}` to the right with no spaces between + either delimiter and the position. + Returns nil (falsy) when not inside a closed reference." + [value position] + (let [left (str/slice value 0 position) + right (str/slice value position) + + open-pos (d/nth-last-index-of left "{" 1) + close-pos (d/nth-index-of right "}" 1) + last-space-left (d/nth-last-index-of left " " 1) + first-space-right (d/nth-index-of right " " 1)] + + (boolean + (and (number? open-pos) + (number? close-pos) + (or (nil? last-space-left) (> (dm/number open-pos) (dm/number last-space-left))) + (or (nil? first-space-right) (< (dm/number close-pos) (dm/number first-space-right))))))) + +(defn- build-result + "Builds the result map for `insert-ref` by replacing the substring of `value` + between `prefix-end` and `suffix-start` with a formatted reference `{name}`. + Returns a map with: + :value — the updated string + :cursor — the index immediately after the inserted reference" + [value prefix-end suffix-start name] + (let [ref (str "{" name "}") + first-part (str/slice value 0 prefix-end) + second-part (str/slice value suffix-start)] + {:value (str first-part ref second-part) + :cursor (+ (count first-part) (count ref))})) + +(defn insert-ref + "Inserts a reference `{name}` into `value` at `position`, respecting the context: + + - Outside any reference block: inserts `{name}` at the cursor position. + - Inside an open reference block (no closing `}`): replaces from the block's + start up to the cursor with `{name}`. + - Inside a closed reference block (has both `{` and `}`): replaces the entire + existing reference with `{name}`. + + Returns a map with: + :value — the resulting string after insertion + :cursor — the index immediately after the inserted reference" + [value position name] + (if (inside-ref? value position) + (if (inside-closed-ref? value position) + (let [open-pos (-> (str/slice value 0 position) + (d/nth-last-index-of "{" 1)) + close-pos (-> (str/slice value position) + (d/nth-index-of "}" 1)) + close-pos (if (number? close-pos) + (+ position close-pos 1) + position)] + (build-result value open-pos close-pos name)) + + (build-result value (start-ref-position value position) position name)) + (build-result value position position name))) diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index f2885c07f7..c4cbe4c100 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -113,3 +113,27 @@ (t/is (= (d/reorder v 3 -1) ["d" "a" "b" "c"])) (t/is (= (d/reorder v 5 -1) ["d" "a" "b" "c"])) (t/is (= (d/reorder v -1 5) ["b" "c" "d" "a"])))) + +(t/deftest nth-last-index-of-test + (t/is (= (d/nth-last-index-of "" "*" 1) nil)) + (t/is (= (d/nth-last-index-of "*abc" "*" 1) 0)) + (t/is (= (d/nth-last-index-of "**abc" "*" 2) 0)) + (t/is (= (d/nth-last-index-of "abc*def*ghi" "*" 3) nil)) + (t/is (= (d/nth-last-index-of "" "*" 2) nil)) + (t/is (= (d/nth-last-index-of "abc*" "*" 1) 3)) + (t/is (= (d/nth-last-index-of "abc*" "*" 2) nil)) + (t/is (= (d/nth-last-index-of "*abc[*" "*" 1) 5)) + (t/is (= (d/nth-last-index-of "abc*def*ghi" "*" 1) 7)) + (t/is (= (d/nth-last-index-of "abc*def*ghi" "*" 2) 3))) + +(t/deftest nth-index-of-test + (t/is (= (d/nth-index-of "" "*" 1) nil)) + (t/is (= (d/nth-index-of "" "*" 2) nil)) + (t/is (= (d/nth-index-of "abc*" "*" 1) 3)) + (t/is (= (d/nth-index-of "abc*" "*" 1) 3)) + (t/is (= (d/nth-index-of "abc**" "*" 2) 4)) + (t/is (= (d/nth-index-of "abc*" "*" 2) nil)) + (t/is (= (d/nth-index-of "*abc[*" "*" 1) 0)) + (t/is (= (d/nth-index-of "abc*def*ghi" "*" 1) 3)) + (t/is (= (d/nth-index-of "abc*def*ghi" "*" 2) 7)) + (t/is (= (d/nth-index-of "abc*def*ghi" "*" 3) nil))) diff --git a/common/test/common_tests/types/token_test.cljc b/common/test/common_tests/types/token_test.cljc index fb93bd2541..96e642690c 100644 --- a/common/test/common_tests/types/token_test.cljc +++ b/common/test/common_tests/types/token_test.cljc @@ -24,3 +24,89 @@ (t/is (false? (sm/validate cto/schema:token-name "Hey Foo.Bar"))) (t/is (false? (sm/validate cto/schema:token-name "Hey😈Foo.Bar"))) (t/is (false? (sm/validate cto/schema:token-name "Hey%Foo.Bar")))) + + +(t/deftest token-value-with-refs + (t/testing "empty value" + (t/is (= (cto/insert-ref "" 0 "token1") + {:value "{token1}" :cursor 8}))) + + (t/testing "value without references" + (t/is (= (cto/insert-ref "ABC" 0 "token1") + {:value "{token1}ABC" :cursor 8})) + (t/is (= (cto/insert-ref "23 + " 5 "token1") + {:value "23 + {token1}" :cursor 13})) + (t/is (= (cto/insert-ref "23 + " 5 "token1") + {:value "23 + {token1}" :cursor 13}))) + + (t/testing "value with closed references" + (t/is (= (cto/insert-ref "{token2}" 8 "token1") + {:value "{token2}{token1}" :cursor 16})) + (t/is (= (cto/insert-ref "{token2}" 6 "token1") + {:value "{token1}" :cursor 8})) + (t/is (= (cto/insert-ref "{token2} + + {token3}" 10 "token1") + {:value "{token2} +{token1} + {token3}" :cursor 18})) + (t/is (= (cto/insert-ref "{token2} + {token3}" 16 "token1") + {:value "{token2} + {token1}" :cursor 19}))) + + (t/testing "value with open references" + (t/is (= (cto/insert-ref "{tok" 4 "token1") + {:value "{token1}" :cursor 8})) + (t/is (= (cto/insert-ref "{tok" 2 "token1") + {:value "{token1}ok" :cursor 8})) + (t/is (= (cto/insert-ref "{token2}{" 9 "token1") + {:value "{token2}{token1}" :cursor 16})) + (t/is (= (cto/insert-ref "{token2{}" 8 "token1") + {:value "{token2{token1}" :cursor 15})) + (t/is (= (cto/insert-ref "{token2} + { + token3}" 12 "token1") + {:value "{token2} + {token1} + token3}" :cursor 19})) + (t/is (= (cto/insert-ref "{token2{}" 8 "token1") + {:value "{token2{token1}" :cursor 15})) + (t/is (= (cto/insert-ref "{token2} + {{{{{{{{{{ + {token3}" 21 "token1") + {:value "{token2} + {token1} + {token3}" :cursor 19}))) + + (t/testing "value with broken references" + (t/is (= (cto/insert-ref "{tok {en2}" 6 "token1") + {:value "{tok {token1}" :cursor 13})) + (t/is (= (cto/insert-ref "{tok en2}" 5 "token1") + {:value "{tok {token1}en2}" :cursor 13}))) + + (t/testing "edge cases" + (t/is (= (cto/insert-ref "" 0 "x") + {:value "{x}" :cursor 3})) + (t/is (= (cto/insert-ref "abc" 3 "x") + {:value "abc{x}" :cursor 6})) + (t/is (= (cto/insert-ref "{token2}" 0 "x") + {:value "{x}{token2}" :cursor 3})) + (t/is (= (cto/insert-ref "abc" 3 "") + {:value "abc{}" :cursor 5})) + (t/is (= (cto/insert-ref "{a} {b}" 4 "x") + {:value "{a} {x}{b}" :cursor 7})) + (t/is (= (cto/insert-ref "{a {b {c" 8 "x") + {:value "{a {b {x}" :cursor 9})) + (t/is (= (cto/insert-ref "{ { {" 5 "x") + {:value "{ { {x}" :cursor 7}))) + + ;; inside-ref? coverage + (t/is (= (cto/insert-ref "AAA " 4 "x") + {:value "AAA {x}" :cursor 7})) + (t/is (= (cto/insert-ref "{abc}" 5 "x") + {:value "{abc}{x}" :cursor 8})) + (t/is (= (cto/insert-ref "{a}{b}" 6 "x") + {:value "{a}{b}{x}" :cursor 9})) + (t/is (= (cto/insert-ref "abc}" 4 "x") + {:value "abc}{x}" :cursor 7})) + (t/is (= (cto/insert-ref "{abc[}" 0 "x") + {:value "{x}{abc[}" :cursor 3})) + (t/is (= (cto/insert-ref "{abc[}" 1 "x") + {:value "{x}" :cursor 3})) + + ;; inside-closed-ref? coverage + (t/is (= (cto/insert-ref "{abc}" 1 "x") + {:value "{x}" :cursor 3})) + (t/is (= (cto/insert-ref "abc {def}ghi" 8 "x") + {:value "abc {x}ghi" :cursor 7})) + (t/is (= (cto/insert-ref "{ab cd}" 3 "x") + {:value "{x} cd}" :cursor 3})) + (t/is (= (cto/insert-ref "{a}{bc}" 5 "x") + {:value "{a}{x}" :cursor 6}))) diff --git a/frontend/playwright/ui/specs/tokens/apply.spec.js b/frontend/playwright/ui/specs/tokens/apply.spec.js index 7bd34e1801..cbcdf03752 100644 --- a/frontend/playwright/ui/specs/tokens/apply.spec.js +++ b/frontend/playwright/ui/specs/tokens/apply.spec.js @@ -43,7 +43,10 @@ test.describe("Tokens: Apply token", () => { test("User applies border-radius token to a shape from sidebar", async ({ page, }) => { - const { workspacePage, tokensSidebar } = await setupTokensFileRender(page); + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); await page.getByRole("tab", { name: "Layers" }).click(); @@ -82,7 +85,8 @@ test.describe("Tokens: Apply token", () => { await brTokenPillSM.click(); // Change token from dropdown - const brTokenOptionXl = borderRadiusSection.getByRole('option', { name: 'borderRadius.xl' }) + const brTokenOptionXl = borderRadiusSection + .getByRole("option", { name: "borderRadius.xl" }); await expect(brTokenOptionXl).toBeVisible(); await brTokenOptionXl.click(); @@ -514,7 +518,9 @@ test.describe("Tokens: Apply token", () => { await dimensionSMTokenPill.nth(1).click(); // Change token from dropdown - const dimensionTokenOptionXl = measuresSection.getByRole('option', { name: 'dimension.xl' }) + const dimensionTokenOptionXl = measuresSection.getByRole("option", { + name: "dimension.xl", + }); await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); @@ -568,7 +574,9 @@ test.describe("Tokens: Apply token", () => { await dimensionSMTokenPill.click(); // Change token from dropdown - const dimensionTokenOptionXl = measuresSection.getByRole('option', { name: 'dimension.xl' }); + const dimensionTokenOptionXl = measuresSection.getByRole("option", { + name: "dimension.xl", + }); await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); @@ -622,7 +630,9 @@ test.describe("Tokens: Apply token", () => { await dimensionSMTokenPill.click(); // Change token from dropdown - const dimensionTokenOptionXl = measuresSection.getByRole('option', { name: 'dimension.xl' }); + const dimensionTokenOptionXl = measuresSection.getByRole("option", { + name: "dimension.xl", + }); await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); @@ -677,8 +687,9 @@ test.describe("Tokens: Apply token", () => { await dimensionXSTokenPill.click(); // Change token from dropdown - const dimensionTokenOptionXl = - borderRadiusSection.getByRole('option', { name: 'dimension.xl' }); + const dimensionTokenOptionXl = borderRadiusSection.getByRole("option", { + name: "dimension.xl", + }); await expect(dimensionTokenOptionXl).toBeVisible(); await dimensionTokenOptionXl.click(); @@ -747,7 +758,9 @@ test.describe("Tokens: Apply token", () => { }); await tokenDropdown.click(); - const widthOptionSmall = firstStrokeRow.getByRole('option', { name: 'width-small' }); + const widthOptionSmall = firstStrokeRow.getByRole("option", { + name: "width-small", + }); await expect(widthOptionSmall).toBeVisible(); await widthOptionSmall.click(); const strokeWidthPillSmall = firstStrokeRow.getByRole("button", { diff --git a/frontend/playwright/ui/specs/tokens/crud.spec.js b/frontend/playwright/ui/specs/tokens/crud.spec.js index 9830ccb4e3..6d8d48414f 100644 --- a/frontend/playwright/ui/specs/tokens/crud.spec.js +++ b/frontend/playwright/ui/specs/tokens/crud.spec.js @@ -31,6 +31,89 @@ test.describe("Tokens - creation", () => { }); }); + test("User creates border radius token with combobox", async ({ page }) => { + const invalidValueError = "Invalid token value"; + const emptyNameError = "Name should be at least 1 character"; + const selfReferenceError = "Token has self reference"; + const missingReferenceError = "Missing token references"; + + const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = + await setupEmptyTokensFileRender(page , { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + // Open modal + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + + const addTokenButton = tokensTabPanel.getByRole("button", { + name: `Add Token: Border Radius`, + }); + + await addTokenButton.click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + + // Placeholder checks + await expect( + tokensUpdateCreateModal.getByPlaceholder( + "Enter border radius token name", + ), + ).toBeVisible(); + await expect( + tokensUpdateCreateModal.getByPlaceholder( + "Enter a value or alias with {alias}", + ), + ).toBeVisible(); + + // Elements + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + const valueField = tokensUpdateCreateModal.getByRole("combobox", { + name: "Value", + }); + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + + // Create first token + await nameField.fill("my-token"); + await valueField.fill("1 + 2"); + await expect( + tokensUpdateCreateModal.getByText("Resolved value: 3"), + ).toBeVisible(); + + await expect(submitButton).toBeEnabled(); + + await submitButton.click(); + + await expect( + tokensTabPanel.getByRole('checkbox', { name: 'my-token' }), + ).toBeEnabled(); + + // Create second token referencing the first one using the combobox options + await addTokenButton.click(); + + await nameField.fill("my-token-2"); + const toggleDropdownButton = tokensUpdateCreateModal.getByRole("button", { + name: "Open token list", + }); + await toggleDropdownButton.click(); + const option = page.getByRole("option", { name: "my-token" }); + await expect(option).toBeVisible(); + await option.click(); + await expect( + tokensUpdateCreateModal.getByText("Resolved value: 3"), + ).toBeVisible(); + + await valueField.pressSequentially(" + 2"); + await expect( + tokensUpdateCreateModal.getByText("Resolved value: 5"), + ).toBeVisible(); + await valueField.pressSequentially(" + {"); + await option.click(); + await expect( + tokensUpdateCreateModal.getByText("Resolved value: 8"), + ).toBeVisible(); + }); + test("User creates dimensions token", async ({ page }) => { await testTokenCreationFlow(page, { tokenLabel: "Dimensions", diff --git a/frontend/playwright/ui/specs/tokens/remapping.spec.js b/frontend/playwright/ui/specs/tokens/remapping.spec.js index c954bcd317..941c62425f 100644 --- a/frontend/playwright/ui/specs/tokens/remapping.spec.js +++ b/frontend/playwright/ui/specs/tokens/remapping.spec.js @@ -41,6 +41,34 @@ const createToken = async (page, type, name, textFieldName, value) => { await expect(tokensUpdateCreateModal).not.toBeVisible(); }; +const createTokenCombobox = async (page, type, name, textFieldName, value) => { + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); + + const { tokensUpdateCreateModal } = await setupTokensFileRender(page, { + flags: ["enable-token-shadow"], + }); + + // Create base token + await tokensTabPanel + .getByRole("button", { name: `Add Token: ${type}` }) + .click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + await nameField.fill(name); + + const valueFill = tokensUpdateCreateModal.getByRole("combobox", { + name: textFieldName, + }); + await valueFill.fill(value); + + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + await submitButton.click(); + await expect(tokensUpdateCreateModal).not.toBeVisible(); +}; + const renameToken = async (page, oldName, newName) => { const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } = await setupTokensFileRender(page, { flags: ["enable-token-shadow"] }); @@ -396,13 +424,21 @@ test.describe("Remapping Tokens", () => { test("User renames border radius token with alias references", async ({ page, }) => { - const { tokensSidebar } = await setupTokensFileRender(page); + const { tokensSidebar } = await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); // Create base border radius token - await createToken(page, "Border Radius", "base-radius", "Value", "4"); + await createTokenCombobox( + page, + "Border Radius", + "base-radius", + "Value", + "4", + ); // Create derived border radius token - await createToken( + await createTokenCombobox( page, "Border Radius", "card-radius", @@ -438,13 +474,21 @@ test.describe("Remapping Tokens", () => { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken, - } = await setupTokensFileRender(page); + } = await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); // Create base border radius token - await createToken(page, "Border Radius", "radius-sm", "Value", "4"); + await createTokenCombobox( + page, + "Border Radius", + "radius-sm", + "Value", + "4", + ); // Create derived border radius token - await createToken( + await createTokenCombobox( page, "Border Radius", "button-radius", diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs index 1457dabc06..bcfd24240e 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs @@ -17,6 +17,7 @@ [:map [:class {:optional true} :string] [:tooltip-class {:optional true} [:maybe :string]] + [:type {:optional true} [:maybe [:enum "button" "submit" "reset"]]] [:icon-class {:optional true} :string] [:icon [:and :string [:fn #(contains? icon-list %)]]] @@ -29,7 +30,7 @@ (mf/defc icon-button* {::mf/schema schema:icon-button ::mf/memo true} - [{:keys [class icon icon-class variant aria-label children tooltip-placement tooltip-class] :rest props}] + [{:keys [class icon icon-class variant aria-label children tooltip-placement tooltip-class type] :rest props}] (let [variant (d/nilv variant "primary") @@ -50,6 +51,7 @@ (mf/spread-props props {:class [class button-class] :ref button-ref + :type (d/nilv type "button") :aria-labelledby tooltip-id})] [:> tooltip* {:content aria-label diff --git a/frontend/src/app/main/ui/ds/colors.scss b/frontend/src/app/main/ui/ds/colors.scss index d772dc41a9..e5c1525e10 100644 --- a/frontend/src/app/main/ui/ds/colors.scss +++ b/frontend/src/app/main/ui/ds/colors.scss @@ -11,6 +11,7 @@ $mint-250: #00d1b8; $mint-700: #426158; $mint-150-60: #7efff599; $mint-250-10: #00d1b81a; +$mint-250-70: #00d1b8b3; $green-200: #a7e8d9; $green-500: #2d9f8f; @@ -33,6 +34,7 @@ $purple-500: #a977d1; $purple-600: #8c33eb; $purple-700: #6911d4; $purple-600-10: #8c33eb1a; +$purple-600-70: #8c33ebb3; $purple-700-60: #6911d499; $aqua-200: #ddf7ff; @@ -77,6 +79,7 @@ $grayish-red: #bfbfbf; --color-accent-quaternary: #{$pink-400}; --color-accent-overlay: #{$purple-700-60}; --color-accent-select: #{$purple-600-10}; + --color-accent-background-select: #{$purple-600-70}; --color-accent-action: #{$purple-400}; --color-accent-action-hover: #{$purple-500}; --color-accent-off: #{$gray-50}; @@ -128,6 +131,7 @@ $grayish-red: #bfbfbf; --color-accent-quaternary: #{$pink-400}; --color-accent-overlay: #{$mint-150-60}; --color-accent-select: #{$mint-250-10}; + --color-accent-background-select: #{$mint-250-70}; --color-accent-action: #{$purple-400}; --color-accent-action-hover: #{$purple-500}; --color-accent-off: #{$gray-50}; diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs index 3633b0145b..0191891398 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs @@ -35,6 +35,8 @@ (def ^:private schema:options-dropdown [:map [:ref {:optional true} fn?] + [:class {:optional true} :string] + [:wrapper-ref {:optional true} :any] [:on-click fn?] [:options [:vector schema:option]] [:selected {:optional true} :any] @@ -60,6 +62,7 @@ (case type :group [:li {:class (stl/css :group-option) + :role "presentation" :key (weak-key option)} [:> icon* {:icon-id i/arrow-down @@ -72,7 +75,7 @@ [:hr {:key (weak-key option) :class (stl/css :option-separator)}] :empty - [:li {:key (weak-key option) :class (stl/css :option-empty)} + [:li {:key (weak-key option) :class (stl/css :option-empty) :role "presentation"} (get option :label)] ;; Token option @@ -83,6 +86,7 @@ :name name :resolved (get option :resolved-value) :ref ref + :role "option" :focused (= id focused) :on-click on-click}] @@ -94,6 +98,7 @@ :aria-label (get option :aria-label) :icon (get option :icon) :ref ref + :role "option" :focused (= id focused) :dimmed (true? (:dimmed option)) :on-click on-click}])))) @@ -101,15 +106,16 @@ (mf/defc options-dropdown* {::mf/schema schema:options-dropdown} - [{:keys [ref on-click options selected focused empty-to-end align] :rest props}] + [{:keys [ref on-click options selected focused empty-to-end align wrapper-ref class] :rest props}] (let [align (d/nilv align :left) props (mf/spread-props props - {:class (stl/css-case :option-list true - :left-align (= align :left) - :right-align (= align :right)) + {:class [class (stl/css-case :option-list true + :left-align (= align :left) + :right-align (= align :right))] + :ref wrapper-ref :tab-index "-1" :role "listbox"}) diff --git a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs index 18d5fc8a7d..11667ba8f8 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs @@ -41,6 +41,7 @@ :id id :on-click on-click :data-id id + :aria-label name :data-testid "dropdown-option"} (if selected diff --git a/frontend/src/app/main/ui/ds/mixins.scss b/frontend/src/app/main/ui/ds/mixins.scss index c8de5deced..32e2dce255 100644 --- a/frontend/src/app/main/ui/ds/mixins.scss +++ b/frontend/src/app/main/ui/ds/mixins.scss @@ -4,6 +4,10 @@ // // Copyright (c) KALEIDOS INC +@use "ds/typography.scss" as t; +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; + @mixin textEllipsis { display: block; max-width: 99%; @@ -20,3 +24,73 @@ -webkit-line-clamp: 2; -webkit-box-orient: vertical; } + +/// Custom Scrollbar Mixin +/// @param {Color} $thumb-color - Base thumb color +/// @param {Color} $thumb-hover-color - Thumb color on hover +/// @param {Length} $size - Scrollbar size (width/height) +/// @param {Length} $radius - Thumb border radius +/// @param {Length} $border - Inner transparent border size +/// @param {Bool} $include-selection - Include ::selection styles +/// @param {Bool} $include-placeholder - Include placeholder styles +@mixin custom-scrollbar( + $thumb-color: #aab5ba4d, + $thumb-hover-color: #aab5bab3, + $size: $sz-12, + $radius: $br-8, + $border: $b-2, + $include-selection: true, + $include-placeholder: true +) { + // Firefox + scrollbar-width: thin; + scrollbar-color: #{$thumb-color} transparent; + + &:hover { + scrollbar-color: #{$thumb-hover-color} transparent; + } + + // Webkit (legacy support) + &::-webkit-scrollbar { + background: transparent; + cursor: pointer; + width: $size; + height: $size; + } + + &::-webkit-scrollbar-track, + &::-webkit-scrollbar-corner { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background-color: $thumb-color; + background-clip: content-box; + border: $border solid transparent; + border-radius: $radius; + + &:hover { + background-color: $thumb-hover-color; + } + } + + @if $include-selection { + &::selection { + background: var(--color-accent-background-select); + color: var(--color-static-white); + } + } + + @if $include-placeholder { + &::placeholder { + @include t.use-typography("body-small"); + color: var(--color-foreground-secondary); + } + + // Legacy webkit + &::-webkit-input-placeholder { + @include t.use-typography("body-small"); + color: var(--color-foreground-secondary); + } + } +} diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls.cljs index 70feee56ca..47200e365a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls.cljs @@ -2,6 +2,7 @@ (:require [app.common.data.macros :as dm] [app.main.ui.workspace.tokens.management.forms.controls.color-input :as color] + [app.main.ui.workspace.tokens.management.forms.controls.combobox :as combobox] [app.main.ui.workspace.tokens.management.forms.controls.fonts-combobox :as fonts] [app.main.ui.workspace.tokens.management.forms.controls.input :as input] [app.main.ui.workspace.tokens.management.forms.controls.select :as select])) @@ -16,4 +17,6 @@ (dm/export fonts/fonts-combobox*) (dm/export fonts/composite-fonts-combobox*) -(dm/export select/select-indexed*) \ No newline at end of file +(dm/export select/select-indexed*) + +(dm/export combobox/value-combobox*) \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs new file mode 100644 index 0000000000..d53b8d0d60 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs @@ -0,0 +1,308 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.workspace.tokens.management.forms.controls.combobox + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.common.types.token :as cto] + [app.common.types.tokens-lib :as ctob] + [app.config :as cf] + [app.main.data.style-dictionary :as sd] + [app.main.data.tokenscript :as ts] + [app.main.ui.context :as muc] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.input :as ds] + [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.forms :as fc] + [app.main.ui.workspace.tokens.management.forms.controls.combobox-navigation :refer [use-navigation]] + [app.main.ui.workspace.tokens.management.forms.controls.floating-dropdown :refer [use-floating-dropdown]] + [app.main.ui.workspace.tokens.management.forms.controls.token-parsing :as tp] + [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] + [app.util.dom :as dom] + [app.util.forms :as fm] + [app.util.i18n :refer [tr]] + [app.util.object :as obj] + [beicon.v2.core :as rx] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(defn- resolve-value + [tokens prev-token token-name value] + (let [valid-token-name? + (and (string? token-name) + (re-matches cto/token-name-validation-regex token-name)) + + token + {:value value + :name (if (or (not valid-token-name?) (str/blank? token-name)) + "__PENPOT__TOKEN__NAME__PLACEHOLDER__" + token-name)} + tokens + (-> tokens + ;; Remove previous token when renaming a token + (dissoc (:name prev-token)) + (update (:name token) #(ctob/make-token (merge % prev-token token))))] + + (->> (if (contains? cf/flags :tokenscript) + (rx/of (ts/resolve-tokens tokens)) + (sd/resolve-tokens-interactive tokens)) + (rx/mapcat + (fn [resolved-tokens] + (let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token)) + resolved-value (if (contains? cf/flags :tokenscript) + (ts/tokenscript-symbols->penpot-unit resolved-value) + resolved-value)] + (if resolved-value + (rx/of {:value resolved-value}) + (rx/of {:error (first errors)})))))))) + +(mf/defc value-combobox* + [{:keys [name tokens token token-type empty-to-end ref] :rest props}] + + (let [form (mf/use-ctx fc/context) + + token-name (get-in @form [:data :name] nil) + touched? + (and (contains? (:data @form) name) + (get-in @form [:touched name])) + + error + (get-in @form [:errors name]) + + value + (get-in @form [:data name] "") + + is-open* (mf/use-state false) + is-open (deref is-open*) + + listbox-id (mf/use-id) + filter-term* (mf/use-state "") + filter-term (deref filter-term*) + + options-ref (mf/use-ref nil) + dropdown-ref (mf/use-ref nil) + internal-ref (mf/use-ref nil) + nodes-ref (mf/use-ref nil) + wrapper-ref (mf/use-ref nil) + icon-button-ref (mf/use-ref nil) + ref (or ref internal-ref) + + raw-tokens-by-type (mf/use-ctx muc/active-tokens-by-type) + + filtered-tokens-by-type + (mf/with-memo [raw-tokens-by-type token-type] + (csu/filter-tokens-for-input raw-tokens-by-type token-type)) + + visible-options + (mf/with-memo [filtered-tokens-by-type token] + (if token + (tp/remove-self-token filtered-tokens-by-type token) + filtered-tokens-by-type)) + + dropdown-options + (mf/with-memo [visible-options filter-term] + (csu/get-token-dropdown-options visible-options (str "{" filter-term))) + + set-option-ref + (mf/use-fn + (fn [node] + (let [state (mf/ref-val nodes-ref) + state (d/nilv state #js {}) + id (dom/get-data node "id") + state (obj/set! state id node)] + (mf/set-ref-val! nodes-ref state)))) + + toggle-dropdown + (mf/use-fn + (mf/deps is-open) + (fn [event] + (dom/prevent-default event) + (swap! is-open* not) + (let [input-node (mf/ref-val ref)] + (dom/focus! input-node)))) + + resolve-stream + (mf/with-memo [token] + (if (contains? token :value) + (rx/behavior-subject (:value token)) + (rx/subject))) + + on-option-enter + (mf/use-fn + (mf/deps value resolve-stream name) + (fn [id] + (let [input-node (mf/ref-val ref) + input-value (dom/get-input-value input-node) + {:keys [value cursor]} (tp/select-option-by-id id options-ref input-node input-value)] + (when value + (fm/on-input-change form name value true) + (rx/push! resolve-stream value) + (js/setTimeout + (fn [] + (set! (.-selectionStart input-node) cursor) + (set! (.-selectionEnd input-node) cursor)) + 0)) + (reset! filter-term* "") + (reset! is-open* false)))) + + {:keys [focused-id on-key-down]} + (use-navigation + {:is-open is-open + :nodes-ref nodes-ref + :options dropdown-options + :toggle-dropdown toggle-dropdown + :is-open* is-open* + :on-enter on-option-enter}) + + on-change + (mf/use-fn + (mf/deps resolve-stream name form) + (fn [event] + (let [node (dom/get-target event) + value (dom/get-input-value node) + token (tp/active-token value node)] + + (fm/on-input-change form name value) + (rx/push! resolve-stream value) + + (if token + (do + (reset! is-open* true) + (reset! filter-term* (:partial token))) + (do + (reset! is-open* false) + (reset! filter-term* "")))))) + + on-option-click + (mf/use-fn + (mf/deps value resolve-stream ref name) + (fn [event] + (let [input-node (mf/ref-val ref) + node (dom/get-current-target event) + id (dom/get-data node "id") + input-value (dom/get-input-value input-node) + + {:keys [value cursor]} (tp/select-option-by-id id options-ref input-node input-value)] + + (reset! filter-term* "") + (dom/focus! input-node) + + (when value + (reset! is-open* false) + (fm/on-input-change form name value true) + (rx/push! resolve-stream value) + + (js/setTimeout + (fn [] + (set! (.-selectionStart input-node) cursor) + (set! (.-selectionEnd input-node) cursor)) + 0))))) + + hint* + (mf/use-state {}) + + hint + (deref hint*) + + props + (mf/spread-props props {:on-change on-change + :value value + :variant "comfortable" + :hint-message (:message hint) + :on-key-down on-key-down + :hint-type (:type hint) + :ref ref + :role "combobox" + :aria-activedescendant focused-id + :aria-controls listbox-id + :aria-expanded is-open + :slot-end + (when (some? @filtered-tokens-by-type) + (mf/html + [:> icon-button* + {:variant "action" + :icon i/arrow-down + :ref icon-button-ref + :tooltip-class (stl/css :button-tooltip) + :class (stl/css :invisible-button) + :tab-index "-1" + :aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown") + :on-mouse-down dom/prevent-default + :on-click toggle-dropdown}]))}) + props + (if (and error touched?) + (mf/spread-props props {:hint-type "error" + :hint-message (:message error)}) + props) + + + {:keys [style ready?]} (use-floating-dropdown is-open wrapper-ref dropdown-ref)] + + (mf/with-effect [resolve-stream tokens token name token-name] + (let [subs (->> resolve-stream + (rx/debounce 300) + (rx/mapcat (partial resolve-value tokens token token-name)) + (rx/map (fn [result] + (d/update-when result :error + (fn [error] + ((:error/fn error) (:error/value error)))))) + (rx/subs! (fn [{:keys [error value]}] + (let [touched? (get-in @form [:touched name])] + (when touched? + (if error + (do + (swap! form assoc-in [:extra-errors name] {:message error}) + (reset! hint* {:message error :type "error"})) + (let [message (tr "workspace.tokens.resolved-value" value)] + (swap! form update :extra-errors dissoc name) + (reset! hint* {:message message :type "hint"}))))))))] + (fn [] + (rx/dispose! subs)))) + + (mf/with-effect [dropdown-options] + (mf/set-ref-val! options-ref dropdown-options)) + + (mf/with-effect [is-open* ref wrapper-ref] + (when is-open + (let [handler (fn [event] + (let [wrapper-node (mf/ref-val wrapper-ref) + dropdown-node (mf/ref-val dropdown-ref) + target (dom/get-target event)] + (when (and wrapper-node dropdown-node + (not (dom/child? target wrapper-node)) + (not (dom/child? target dropdown-node))) + (reset! is-open* false))))] + + (.addEventListener js/document "mousedown" handler) + + (fn [] + (.removeEventListener js/document "mousedown" handler))))) + + + [:div {:ref wrapper-ref} + [:> ds/input* props] + (when ^boolean is-open + (let [options (if (delay? dropdown-options) @dropdown-options dropdown-options)] + (mf/portal + (mf/html + [:> options-dropdown* {:on-click on-option-click + :class (stl/css :dropdown) + :style {:visibility (if ready? "visible" "hidden") + :left (:left style) + :top (or (:top style) "unset") + :bottom (or (:bottom style) "unset") + :width (:width style)} + :id listbox-id + :options options + :focused focused-id + :selected nil + :align :right + :empty-to-end empty-to-end + :wrapper-ref dropdown-ref + :ref set-option-ref}]) + (dom/get-body))))])) \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss new file mode 100644 index 0000000000..b484cacfce --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss @@ -0,0 +1,16 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "ds/_utils.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/mixins.scss" as *; + +.dropdown { + position: fixed; + max-block-size: $sz-400; + overflow-y: auto; + @include custom-scrollbar(); +} diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs new file mode 100644 index 0000000000..b8be6dad81 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs @@ -0,0 +1,119 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.workspace.tokens.management.forms.controls.combobox-navigation + (:require + [app.main.ui.workspace.tokens.management.forms.controls.utils :refer [focusable-options]] + [app.util.dom :as dom] + [app.util.keyboard :as kbd] + [app.util.object :as obj] + [rumext.v2 :as mf])) + +(defn- focusable-option? + [option] + (and (:id option) + (not= :group (:type option)) + (not= :separator (:type option)))) + +(defn- first-focusable-id + [options] + (some #(when (focusable-option? %) (:id %)) options)) + +(defn next-focus-id + [focusables focused-id direction] + (let [ids (vec (map :id focusables)) + idx (.indexOf (clj->js ids) focused-id) + idx (if (= idx -1) -1 idx) + next-idx (case direction + :down (min (dec (count ids)) (inc idx)) + :up (max 0 (dec (if (= idx -1) 0 idx))))] + (nth ids next-idx nil))) + +(defn use-navigation + [{:keys [is-open options nodes-ref is-open* toggle-dropdown on-enter]}] + + (let [focused-id* (mf/use-state nil) + focused-id (deref focused-id*) + + on-key-down + (mf/use-fn + (mf/deps is-open focused-id) + (fn [event] + (let [up? (kbd/up-arrow? event) + down? (kbd/down-arrow? event) + enter? (kbd/enter? event) + esc? (kbd/esc? event) + open-dropdown (kbd/is-key? event "{") + close-dropdown (kbd/is-key? event "}") + options (if (delay? options) @options options)] + + (cond + down? + (do + (dom/prevent-default event) + (let [focusables (focusable-options options)] + (cond + is-open + (when (seq focusables) + (let [next-id (next-focus-id focusables focused-id :down)] + (reset! focused-id* next-id))) + + (seq focusables) + (do + (toggle-dropdown event) + (reset! focused-id* (first-focusable-id focusables))) + + :else + nil))) + + up? + (when is-open + (dom/prevent-default event) + (let [focusables (focusable-options options) + next-id (next-focus-id focusables focused-id :up)] + (reset! focused-id* next-id))) + + open-dropdown + (reset! is-open* true) + + close-dropdown + (reset! is-open* false) + + enter? + (do + (when (and is-open focused-id) + (let [focusables (focusable-options options)] + (dom/prevent-default event) + (when (some #(= (:id %) focused-id) focusables) + (on-enter focused-id))))) + esc? + (do + (dom/prevent-default event) + (reset! is-open* false)) + :else nil))))] + + ;; Initial focus on first option + (mf/with-effect [is-open options] + (when is-open + (let [opts (if (delay? options) @options options) + focusables (focusable-options opts) + ids (set (map :id focusables))] + (when (and (seq focusables) + (not (contains? ids focused-id))) + (reset! focused-id* (:id (first focusables))))))) + + ;; auto scroll when key down + (mf/with-effect [focused-id nodes-ref] + (when focused-id + (let [nodes (mf/ref-val nodes-ref) + node (obj/get nodes focused-id)] + (when node + (dom/scroll-into-view-if-needed! + node {:block "nearest" + :inline "nearest"}))))) + + {:focused-id focused-id + :on-key-down on-key-down})) \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs new file mode 100644 index 0000000000..739ca0628c --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs @@ -0,0 +1,71 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.workspace.tokens.management.forms.controls.floating-dropdown + (:require + [app.util.dom :as dom] + [rumext.v2 :as mf])) + +(defn use-floating-dropdown [is-open wrapper-ref dropdown-ref] + (let [position* (mf/use-state nil) + position (deref position*) + ready* (mf/use-state false) + ready (deref ready*) + calculate-position + (fn [node] + (let [combobox-rect (dom/get-bounding-rect node) + dropdown-node (mf/ref-val dropdown-ref) + dropdown-height (if dropdown-node + (-> (dom/get-bounding-rect dropdown-node) + (:height)) + 0) + + windows-height (-> (dom/get-window-size) + (:height)) + + space-below (- windows-height (:bottom combobox-rect)) + + open-up? (and dropdown-height + (> dropdown-height space-below)) + + position (if open-up? + {:bottom (str (- windows-height (:top combobox-rect) 12) "px") + :left (str (:left combobox-rect) "px") + :width (str (:width combobox-rect) "px") + :placement :top} + + {:top (str (+ (:bottom combobox-rect) 4) "px") + :left (str (:left combobox-rect) "px") + :width (str (:width combobox-rect) "px") + :placement :bottom})] + (reset! ready* true) + (reset! position* position)))] + + (mf/with-effect [is-open dropdown-ref wrapper-ref] + (when is-open + (let [handler (fn [event] + (let [dropdown-node (mf/ref-val dropdown-ref) + target (dom/get-target event)] + (when (or (nil? dropdown-node) + (not (instance? js/Node target)) + (not (.contains dropdown-node target))) + (js/requestAnimationFrame + (fn [] + (let [wrapper-node (mf/ref-val wrapper-ref)] + (reset! ready* true) + (calculate-position wrapper-node)))))))] + (handler nil) + + (.addEventListener js/window "resize" handler) + (.addEventListener js/window "scroll" handler true) + + (fn [] + (.removeEventListener js/window "resize" handler) + (.removeEventListener js/window "scroll" handler true))))) + + {:style position + :ready? ready + :recalculate calculate-position})) \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs new file mode 100644 index 0000000000..2308bef9c7 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs @@ -0,0 +1,46 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.workspace.tokens.management.forms.controls.token-parsing + (:require + [app.common.types.token :as cto] + [app.main.ui.ds.controls.select :refer [get-option]] + [app.util.dom :as dom] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(defn extract-partial-token + [value cursor] + (let [text-before (subs value 0 cursor) + last-open (str/last-index-of text-before "{") + last-close (str/last-index-of text-before "}")] + (when (and last-open (or (nil? last-close) (> last-open last-close))) + {:start last-open + :end (or (str/index-of value "}" last-open) cursor) + :partial (subs text-before (inc last-open))}))) + + +(defn active-token [value input-node] + (let [cursor (dom/selection-start input-node)] + (extract-partial-token value cursor))) + +(defn remove-self-token [filtered-options current-token] + (let [group (:type current-token) + current-id (:id current-token) + filtered-options (deref filtered-options)] + (update filtered-options group + (fn [options] + (remove #(= (:id %) current-id) options))))) + +(defn select-option-by-id + [id options-ref input-node value] + (let [cursor (dom/selection-start input-node) + options (mf/ref-val options-ref) + options (if (delay? options) @options options) + + option (get-option options id) + name (:name option)] + (cto/insert-ref value cursor name))) \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs new file mode 100644 index 0000000000..f29c348e9d --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs @@ -0,0 +1,102 @@ +(ns app.main.ui.workspace.tokens.management.forms.controls.utils + (:require + [app.common.data.macros :as dm] + [app.common.types.token :as cto] + [app.util.i18n :refer [tr]] + [cuerdas.core :as str])) + +(defn- token->dropdown-option + [token] + {:id (str (get token :id)) + :type :token + :resolved-value (get token :value) + :name (get token :name)}) + +(defn- generate-dropdown-options + [tokens no-sets] + (let [non-empty-groups + (->> tokens + (filter (fn [[_ items]] (seq items))))] + (if (empty? non-empty-groups) + [{:type :empty + :label (if no-sets + (tr "ds.inputs.numeric-input.no-applicable-tokens") + (tr "ds.inputs.numeric-input.no-matches"))}] + (->> non-empty-groups + (keep (fn [[type items]] + (when (seq? items) + (cons {:group true + :type :group + :id (dm/str "group-" (name type)) + :name (name type)} + (map token->dropdown-option items))))) + (interpose [{:separator true + :id "separator" + :type :separator}]) + (apply concat) + (vec) + (not-empty))))) + +(defn- extract-partial-brace-text + [s] + (when-let [start (str/last-index-of s "{")] + (subs s (inc start)))) + +(defn- filter-token-groups-by-name + [tokens filter-text] + (let [lc-filter (str/lower filter-text)] + (into {} + (keep (fn [[group tokens]] + (let [filtered (filter #(str/includes? (str/lower (:name %)) lc-filter) tokens)] + (when (seq filtered) + [group filtered])))) + tokens))) + +(defn- sort-groups-and-tokens + "Sorts both the groups and the tokens inside them alphabetically. + + Input: + A map where: + - keys are groups (keywords or strings, e.g. :dimensions, :colors) + - values are vectors of token maps, each containing at least a :name key + + Example input: + {:dimensions [{:name \"tres\"} {:name \"quini\"}] + :colors [{:name \"azul\"} {:name \"rojo\"}]} + + Output: + A sorted map where: + - groups are ordered alphabetically by key + - tokens inside each group are sorted alphabetically by :name + + Example output: + {:colors [{:name \"azul\"} {:name \"rojo\"}] + :dimensions [{:name \"quini\"} {:name \"tres\"}]}" + + [groups->tokens] + (into (sorted-map) ;; ensure groups are ordered alphabetically by their key + (for [[group tokens] groups->tokens] + [group (sort-by :name tokens)]))) + +(defn get-token-dropdown-options + [tokens filter-term] + (delay + (let [tokens (if (delay? tokens) @tokens tokens) + + sorted-tokens (sort-groups-and-tokens tokens) + partial (extract-partial-brace-text filter-term) + options (if (seq partial) + (filter-token-groups-by-name sorted-tokens partial) + sorted-tokens) + no-sets? (empty? sorted-tokens)] + (generate-dropdown-options options no-sets?)))) + +(defn filter-tokens-for-input + [raw-tokens input-type] + (delay + (-> (deref raw-tokens) + (select-keys (get cto/tokens-by-input input-type)) + (not-empty)))) + +(defn focusable-options [options] + (filter #(= (:type %) :token) options)) \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs index b0aef352ae..0e6d55df03 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/form_container.cljs @@ -8,8 +8,10 @@ (:require [app.common.data :as d] [app.common.types.tokens-lib :as ctob] + [app.config :as cf] [app.main.refs :as refs] [app.main.ui.workspace.tokens.management.forms.color :as color] + [app.main.ui.workspace.tokens.management.forms.controls :as token.controls] [app.main.ui.workspace.tokens.management.forms.font-family :as font-family] [app.main.ui.workspace.tokens.management.forms.generic-form :as generic] [app.main.ui.workspace.tokens.management.forms.shadow :as shadow] @@ -39,7 +41,10 @@ :token token}) text-case-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-case-value-enter")}) text-decoration-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-decoration-value-enter")}) - font-weight-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")})] + font-weight-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.font-weight-value-enter")}) + border-radius-props (if (contains? cf/flags :token-combobox) + (mf/spread-props props {:input-component token.controls/value-combobox*}) + props)] (case token-type :color [:> color/form* props] @@ -49,4 +54,5 @@ :text-case [:> generic/form* text-case-props] :text-decoration [:> generic/form* text-decoration-props] :font-weight [:> generic/form* font-weight-props] + :border-radius [:> generic/form* border-radius-props] [:> generic/form* props]))) \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs index f5541f8a40..8225a52887 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -21,6 +21,7 @@ [app.main.data.workspace.tokens.remapping :as remap] [app.main.refs :as refs] [app.main.store :as st] + [app.main.ui.context :as muc] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.foundations.typography.heading :refer [heading*]] @@ -97,6 +98,10 @@ (and (:name token) (:value token)) (assoc (:name token) token))) + active-tokens-by-type + (mf/with-memo [tokens] + (delay (ctob/group-by-type tokens))) + schema (mf/with-memo [tokens-tree-in-selected-set active-tab] (make-schema tokens-tree-in-selected-set active-tab)) @@ -224,78 +229,80 @@ error-message (first error-messages)] (swap! form assoc-in [:extra-errors :value] {:message error-message}))))))))] - [:> fc/form* {:class (stl/css :form-wrapper) - :form form - :on-submit on-submit} - [:div {:class (stl/css :token-rows)} + [(mf/provider muc/active-tokens-by-type) {:value active-tokens-by-type} + [:> fc/form* {:class (stl/css :form-wrapper) + :form form + :on-submit on-submit} + [:div {:class (stl/css :token-rows)} - [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)} - (if (= action "edit") - (tr "workspace.tokens.edit-token" token-type) - (tr "workspace.tokens.create-token" token-type))] + [:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)} + (if (= action "edit") + (tr "workspace.tokens.edit-token" token-type) + (tr "workspace.tokens.create-token" token-type))] - [:div {:class (stl/css :input-row)} - [:> fc/form-input* {:id "token-name" - :name :name - :label (tr "workspace.tokens.token-name") - :placeholder (tr "workspace.tokens.enter-token-name" token-title) - :max-length max-input-length - :variant "comfortable" - :trim true - :auto-focus true}]] + [:div {:class (stl/css :input-row)} + [:> fc/form-input* {:id "token-name" + :name :name + :label (tr "workspace.tokens.token-name") + :placeholder (tr "workspace.tokens.enter-token-name" token-title) + :max-length max-input-length + :variant "comfortable" + :trim true + :auto-focus true}]] - [:div {:class (stl/css :input-row)} - (case value-type - :indexed - [:> input-component - {:token token - :tokens tokens - :tab active-tab - :value-subfield value-subfield - :handle-toggle on-toggle-tab}] + [:div {:class (stl/css :input-row)} + (case value-type + :indexed + [:> input-component + {:token token + :tokens tokens + :tab active-tab + :value-subfield value-subfield + :handle-toggle on-toggle-tab}] - :composite - [:> input-component - {:token token - :tokens tokens - :tab active-tab - :handle-toggle on-toggle-tab}] + :composite + [:> input-component + {:token token + :tokens tokens + :tab active-tab + :handle-toggle on-toggle-tab}] - [:> input-component - {:placeholder (or input-value-placeholder - (tr "workspace.tokens.token-value-enter")) - :label (tr "workspace.tokens.token-value") - :name :value - :token token - :tokens tokens}])] + [:> input-component + {:placeholder (or input-value-placeholder + (tr "workspace.tokens.token-value-enter")) + :label (tr "workspace.tokens.token-value") + :name :value + :token token + :token-type token-type + :tokens tokens}])] - [:div {:class (stl/css :input-row)} - [:> fc/form-input* {:id "token-description" - :name :description - :label (tr "workspace.tokens.token-description") - :placeholder (tr "workspace.tokens.token-description") - :max-length max-input-length - :variant "comfortable" - :is-optional true}]] + [:div {:class (stl/css :input-row)} + [:> fc/form-input* {:id "token-description" + :name :description + :label (tr "workspace.tokens.token-description") + :placeholder (tr "workspace.tokens.token-description") + :max-length max-input-length + :variant "comfortable" + :is-optional true}]] - [:div {:class (stl/css-case :button-row true - :with-delete (= action "edit"))} - (when (= action "edit") - [:> button* {:on-click on-delete-token - :on-key-down handle-key-down-delete - :class (stl/css :delete-btn) - :type "button" - :icon i/delete - :variant "secondary"} - (tr "labels.delete")]) + [:div {:class (stl/css-case :button-row true + :with-delete (= action "edit"))} + (when (= action "edit") + [:> button* {:on-click on-delete-token + :on-key-down handle-key-down-delete + :class (stl/css :delete-btn) + :type "button" + :icon i/delete + :variant "secondary"} + (tr "labels.delete")]) - [:> button* {:on-click on-cancel - :on-key-down handle-key-down-cancel - :type "button" - :id "token-modal-cancel" - :variant "secondary"} - (tr "labels.cancel")] + [:> button* {:on-click on-cancel + :on-key-down handle-key-down-cancel + :type "button" + :id "token-modal-cancel" + :variant "secondary"} + (tr "labels.cancel")] - [:> fc/form-submit* {:variant "primary" - :on-submit on-submit} - (tr "labels.save")]]]])) + [:> fc/form-submit* {:variant "primary" + :on-submit on-submit} + (tr "labels.save")]]]]])) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index a5e35afb92..0e9d15635d 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -277,6 +277,16 @@ (when (and (some? node) (some? (unchecked-get node "select"))) (.select ^js node))) +(defn selection-start + [^js node] + (when (some? node) + (.-selectionStart node))) + +(defn set-selection-range! + [^js node start end] + (when (some? node) + (.setSelectionRange node start end))) + (defn ^boolean equals? [^js node-a ^js node-b]