mirror of
https://github.com/penpot/penpot.git
synced 2026-08-01 19:06:18 +00:00
🎉 Token form combobox (#8294)
* 🎉 Create token combobox * ♻️ Extract floating position as hook * ♻️ Extract mouse navigation as hook * ♻️ Extract token parsing * 🎉 Add test * 🎉 Add flag * 🐛 Fix comments * 🐛 Fix some errors on navigation * 🐛 FIx errors on dropdown selection in the middle of the string * 🐛 Only select available options not headers or empty mesage * ♻️ Change component name * 🐛 Intro doesn't trigger dropdown * 🐛 Fix differences between on-option-enter and on-option-click * ♻️ Refactor scrollbar rule * 🐛 Fix update proper option * ♻️ Use tdd to resolve parsing token * ♻️ Add more test * ♻️ Use new fn for token parsing * ♻️ Refactor new fns and add docstrings * 🐛 Fix comments and warnings --------- Co-authored-by: Andrey Antukh <niwi@niwi.nz>
This commit is contained in:
parent
ade0b6b07b
commit
5c989d00d0
@ -1165,3 +1165,40 @@
|
|||||||
[class current-class]
|
[class current-class]
|
||||||
(str (if (some? class) (str class " ") "")
|
(str (if (some? class) (str class " ") "")
|
||||||
current-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))
|
||||||
@ -40,6 +40,13 @@
|
|||||||
(list `c/get key)))
|
(list `c/get key)))
|
||||||
keys)))))
|
keys)))))
|
||||||
|
|
||||||
|
(defmacro number
|
||||||
|
"Coerce number to number in a multiplatform way"
|
||||||
|
[o]
|
||||||
|
(if (:ns &env)
|
||||||
|
(with-meta o {:tag 'number})
|
||||||
|
`(double ~o)))
|
||||||
|
|
||||||
(defmacro str
|
(defmacro str
|
||||||
[& params]
|
[& params]
|
||||||
`(str/concat ~@params))
|
`(str/concat ~@params))
|
||||||
|
|||||||
@ -121,6 +121,7 @@
|
|||||||
:terms-and-privacy-checkbox
|
:terms-and-privacy-checkbox
|
||||||
:tiered-file-data-storage
|
:tiered-file-data-storage
|
||||||
:token-base-font-size
|
:token-base-font-size
|
||||||
|
:token-combobox
|
||||||
:token-color
|
:token-color
|
||||||
:token-shadow
|
:token-shadow
|
||||||
:token-tokenscript
|
:token-tokenscript
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
(ns app.common.types.token
|
(ns app.common.types.token
|
||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.schema.generators :as sg]
|
[app.common.schema.generators :as sg]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
@ -637,3 +638,107 @@
|
|||||||
(when (font-weight-values weight)
|
(when (font-weight-values weight)
|
||||||
(cond-> {:weight weight}
|
(cond-> {:weight weight}
|
||||||
italic? (assoc :style "italic")))))
|
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)))
|
||||||
|
|||||||
@ -113,3 +113,27 @@
|
|||||||
(t/is (= (d/reorder v 3 -1) ["d" "a" "b" "c"]))
|
(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 5 -1) ["d" "a" "b" "c"]))
|
||||||
(t/is (= (d/reorder v -1 5) ["b" "c" "d" "a"]))))
|
(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)))
|
||||||
|
|||||||
@ -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/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})))
|
||||||
|
|||||||
@ -43,7 +43,10 @@ test.describe("Tokens: Apply token", () => {
|
|||||||
test("User applies border-radius token to a shape from sidebar", async ({
|
test("User applies border-radius token to a shape from sidebar", async ({
|
||||||
page,
|
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();
|
await page.getByRole("tab", { name: "Layers" }).click();
|
||||||
|
|
||||||
@ -82,7 +85,8 @@ test.describe("Tokens: Apply token", () => {
|
|||||||
await brTokenPillSM.click();
|
await brTokenPillSM.click();
|
||||||
|
|
||||||
// Change token from dropdown
|
// 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 expect(brTokenOptionXl).toBeVisible();
|
||||||
await brTokenOptionXl.click();
|
await brTokenOptionXl.click();
|
||||||
|
|
||||||
@ -514,7 +518,9 @@ test.describe("Tokens: Apply token", () => {
|
|||||||
await dimensionSMTokenPill.nth(1).click();
|
await dimensionSMTokenPill.nth(1).click();
|
||||||
|
|
||||||
// Change token from dropdown
|
// 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 expect(dimensionTokenOptionXl).toBeVisible();
|
||||||
await dimensionTokenOptionXl.click();
|
await dimensionTokenOptionXl.click();
|
||||||
|
|
||||||
@ -568,7 +574,9 @@ test.describe("Tokens: Apply token", () => {
|
|||||||
await dimensionSMTokenPill.click();
|
await dimensionSMTokenPill.click();
|
||||||
|
|
||||||
// Change token from dropdown
|
// 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 expect(dimensionTokenOptionXl).toBeVisible();
|
||||||
await dimensionTokenOptionXl.click();
|
await dimensionTokenOptionXl.click();
|
||||||
|
|
||||||
@ -622,7 +630,9 @@ test.describe("Tokens: Apply token", () => {
|
|||||||
await dimensionSMTokenPill.click();
|
await dimensionSMTokenPill.click();
|
||||||
|
|
||||||
// Change token from dropdown
|
// 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 expect(dimensionTokenOptionXl).toBeVisible();
|
||||||
await dimensionTokenOptionXl.click();
|
await dimensionTokenOptionXl.click();
|
||||||
|
|
||||||
@ -677,8 +687,9 @@ test.describe("Tokens: Apply token", () => {
|
|||||||
await dimensionXSTokenPill.click();
|
await dimensionXSTokenPill.click();
|
||||||
|
|
||||||
// Change token from dropdown
|
// Change token from dropdown
|
||||||
const dimensionTokenOptionXl =
|
const dimensionTokenOptionXl = borderRadiusSection.getByRole("option", {
|
||||||
borderRadiusSection.getByRole('option', { name: 'dimension.xl' });
|
name: "dimension.xl",
|
||||||
|
});
|
||||||
await expect(dimensionTokenOptionXl).toBeVisible();
|
await expect(dimensionTokenOptionXl).toBeVisible();
|
||||||
await dimensionTokenOptionXl.click();
|
await dimensionTokenOptionXl.click();
|
||||||
|
|
||||||
@ -747,7 +758,9 @@ test.describe("Tokens: Apply token", () => {
|
|||||||
});
|
});
|
||||||
await tokenDropdown.click();
|
await tokenDropdown.click();
|
||||||
|
|
||||||
const widthOptionSmall = firstStrokeRow.getByRole('option', { name: 'width-small' });
|
const widthOptionSmall = firstStrokeRow.getByRole("option", {
|
||||||
|
name: "width-small",
|
||||||
|
});
|
||||||
await expect(widthOptionSmall).toBeVisible();
|
await expect(widthOptionSmall).toBeVisible();
|
||||||
await widthOptionSmall.click();
|
await widthOptionSmall.click();
|
||||||
const strokeWidthPillSmall = firstStrokeRow.getByRole("button", {
|
const strokeWidthPillSmall = firstStrokeRow.getByRole("button", {
|
||||||
|
|||||||
@ -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 }) => {
|
test("User creates dimensions token", async ({ page }) => {
|
||||||
await testTokenCreationFlow(page, {
|
await testTokenCreationFlow(page, {
|
||||||
tokenLabel: "Dimensions",
|
tokenLabel: "Dimensions",
|
||||||
|
|||||||
@ -41,6 +41,34 @@ const createToken = async (page, type, name, textFieldName, value) => {
|
|||||||
await expect(tokensUpdateCreateModal).not.toBeVisible();
|
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 renameToken = async (page, oldName, newName) => {
|
||||||
const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } =
|
const { tokensUpdateCreateModal, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, { flags: ["enable-token-shadow"] });
|
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 ({
|
test("User renames border radius token with alias references", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const { tokensSidebar } = await setupTokensFileRender(page);
|
const { tokensSidebar } = await setupTokensFileRender(page, {
|
||||||
|
flags: ["enable-token-combobox", "enable-feature-token-input"],
|
||||||
|
});
|
||||||
|
|
||||||
// Create base border radius token
|
// 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
|
// Create derived border radius token
|
||||||
await createToken(
|
await createTokenCombobox(
|
||||||
page,
|
page,
|
||||||
"Border Radius",
|
"Border Radius",
|
||||||
"card-radius",
|
"card-radius",
|
||||||
@ -438,13 +474,21 @@ test.describe("Remapping Tokens", () => {
|
|||||||
tokensUpdateCreateModal,
|
tokensUpdateCreateModal,
|
||||||
tokensSidebar,
|
tokensSidebar,
|
||||||
tokenContextMenuForToken,
|
tokenContextMenuForToken,
|
||||||
} = await setupTokensFileRender(page);
|
} = await setupTokensFileRender(page, {
|
||||||
|
flags: ["enable-token-combobox", "enable-feature-token-input"],
|
||||||
|
});
|
||||||
|
|
||||||
// Create base border radius token
|
// 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
|
// Create derived border radius token
|
||||||
await createToken(
|
await createTokenCombobox(
|
||||||
page,
|
page,
|
||||||
"Border Radius",
|
"Border Radius",
|
||||||
"button-radius",
|
"button-radius",
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
[:map
|
[:map
|
||||||
[:class {:optional true} :string]
|
[:class {:optional true} :string]
|
||||||
[:tooltip-class {:optional true} [:maybe :string]]
|
[:tooltip-class {:optional true} [:maybe :string]]
|
||||||
|
[:type {:optional true} [:maybe [:enum "button" "submit" "reset"]]]
|
||||||
[:icon-class {:optional true} :string]
|
[:icon-class {:optional true} :string]
|
||||||
[:icon
|
[:icon
|
||||||
[:and :string [:fn #(contains? icon-list %)]]]
|
[:and :string [:fn #(contains? icon-list %)]]]
|
||||||
@ -29,7 +30,7 @@
|
|||||||
(mf/defc icon-button*
|
(mf/defc icon-button*
|
||||||
{::mf/schema schema:icon-button
|
{::mf/schema schema:icon-button
|
||||||
::mf/memo true}
|
::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
|
(let [variant
|
||||||
(d/nilv variant "primary")
|
(d/nilv variant "primary")
|
||||||
|
|
||||||
@ -50,6 +51,7 @@
|
|||||||
(mf/spread-props props
|
(mf/spread-props props
|
||||||
{:class [class button-class]
|
{:class [class button-class]
|
||||||
:ref button-ref
|
:ref button-ref
|
||||||
|
:type (d/nilv type "button")
|
||||||
:aria-labelledby tooltip-id})]
|
:aria-labelledby tooltip-id})]
|
||||||
|
|
||||||
[:> tooltip* {:content aria-label
|
[:> tooltip* {:content aria-label
|
||||||
|
|||||||
@ -11,6 +11,7 @@ $mint-250: #00d1b8;
|
|||||||
$mint-700: #426158;
|
$mint-700: #426158;
|
||||||
$mint-150-60: #7efff599;
|
$mint-150-60: #7efff599;
|
||||||
$mint-250-10: #00d1b81a;
|
$mint-250-10: #00d1b81a;
|
||||||
|
$mint-250-70: #00d1b8b3;
|
||||||
|
|
||||||
$green-200: #a7e8d9;
|
$green-200: #a7e8d9;
|
||||||
$green-500: #2d9f8f;
|
$green-500: #2d9f8f;
|
||||||
@ -33,6 +34,7 @@ $purple-500: #a977d1;
|
|||||||
$purple-600: #8c33eb;
|
$purple-600: #8c33eb;
|
||||||
$purple-700: #6911d4;
|
$purple-700: #6911d4;
|
||||||
$purple-600-10: #8c33eb1a;
|
$purple-600-10: #8c33eb1a;
|
||||||
|
$purple-600-70: #8c33ebb3;
|
||||||
$purple-700-60: #6911d499;
|
$purple-700-60: #6911d499;
|
||||||
|
|
||||||
$aqua-200: #ddf7ff;
|
$aqua-200: #ddf7ff;
|
||||||
@ -77,6 +79,7 @@ $grayish-red: #bfbfbf;
|
|||||||
--color-accent-quaternary: #{$pink-400};
|
--color-accent-quaternary: #{$pink-400};
|
||||||
--color-accent-overlay: #{$purple-700-60};
|
--color-accent-overlay: #{$purple-700-60};
|
||||||
--color-accent-select: #{$purple-600-10};
|
--color-accent-select: #{$purple-600-10};
|
||||||
|
--color-accent-background-select: #{$purple-600-70};
|
||||||
--color-accent-action: #{$purple-400};
|
--color-accent-action: #{$purple-400};
|
||||||
--color-accent-action-hover: #{$purple-500};
|
--color-accent-action-hover: #{$purple-500};
|
||||||
--color-accent-off: #{$gray-50};
|
--color-accent-off: #{$gray-50};
|
||||||
@ -128,6 +131,7 @@ $grayish-red: #bfbfbf;
|
|||||||
--color-accent-quaternary: #{$pink-400};
|
--color-accent-quaternary: #{$pink-400};
|
||||||
--color-accent-overlay: #{$mint-150-60};
|
--color-accent-overlay: #{$mint-150-60};
|
||||||
--color-accent-select: #{$mint-250-10};
|
--color-accent-select: #{$mint-250-10};
|
||||||
|
--color-accent-background-select: #{$mint-250-70};
|
||||||
--color-accent-action: #{$purple-400};
|
--color-accent-action: #{$purple-400};
|
||||||
--color-accent-action-hover: #{$purple-500};
|
--color-accent-action-hover: #{$purple-500};
|
||||||
--color-accent-off: #{$gray-50};
|
--color-accent-off: #{$gray-50};
|
||||||
|
|||||||
@ -35,6 +35,8 @@
|
|||||||
(def ^:private schema:options-dropdown
|
(def ^:private schema:options-dropdown
|
||||||
[:map
|
[:map
|
||||||
[:ref {:optional true} fn?]
|
[:ref {:optional true} fn?]
|
||||||
|
[:class {:optional true} :string]
|
||||||
|
[:wrapper-ref {:optional true} :any]
|
||||||
[:on-click fn?]
|
[:on-click fn?]
|
||||||
[:options [:vector schema:option]]
|
[:options [:vector schema:option]]
|
||||||
[:selected {:optional true} :any]
|
[:selected {:optional true} :any]
|
||||||
@ -60,6 +62,7 @@
|
|||||||
(case type
|
(case type
|
||||||
:group
|
:group
|
||||||
[:li {:class (stl/css :group-option)
|
[:li {:class (stl/css :group-option)
|
||||||
|
:role "presentation"
|
||||||
:key (weak-key option)}
|
:key (weak-key option)}
|
||||||
[:> icon*
|
[:> icon*
|
||||||
{:icon-id i/arrow-down
|
{:icon-id i/arrow-down
|
||||||
@ -72,7 +75,7 @@
|
|||||||
[:hr {:key (weak-key option) :class (stl/css :option-separator)}]
|
[:hr {:key (weak-key option) :class (stl/css :option-separator)}]
|
||||||
|
|
||||||
:empty
|
: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)]
|
(get option :label)]
|
||||||
|
|
||||||
;; Token option
|
;; Token option
|
||||||
@ -83,6 +86,7 @@
|
|||||||
:name name
|
:name name
|
||||||
:resolved (get option :resolved-value)
|
:resolved (get option :resolved-value)
|
||||||
:ref ref
|
:ref ref
|
||||||
|
:role "option"
|
||||||
:focused (= id focused)
|
:focused (= id focused)
|
||||||
:on-click on-click}]
|
:on-click on-click}]
|
||||||
|
|
||||||
@ -94,6 +98,7 @@
|
|||||||
:aria-label (get option :aria-label)
|
:aria-label (get option :aria-label)
|
||||||
:icon (get option :icon)
|
:icon (get option :icon)
|
||||||
:ref ref
|
:ref ref
|
||||||
|
:role "option"
|
||||||
:focused (= id focused)
|
:focused (= id focused)
|
||||||
:dimmed (true? (:dimmed option))
|
:dimmed (true? (:dimmed option))
|
||||||
:on-click on-click}]))))
|
:on-click on-click}]))))
|
||||||
@ -101,15 +106,16 @@
|
|||||||
|
|
||||||
(mf/defc options-dropdown*
|
(mf/defc options-dropdown*
|
||||||
{::mf/schema schema: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
|
(let [align
|
||||||
(d/nilv align :left)
|
(d/nilv align :left)
|
||||||
|
|
||||||
props
|
props
|
||||||
(mf/spread-props props
|
(mf/spread-props props
|
||||||
{:class (stl/css-case :option-list true
|
{:class [class (stl/css-case :option-list true
|
||||||
:left-align (= align :left)
|
:left-align (= align :left)
|
||||||
:right-align (= align :right))
|
:right-align (= align :right))]
|
||||||
|
:ref wrapper-ref
|
||||||
:tab-index "-1"
|
:tab-index "-1"
|
||||||
:role "listbox"})
|
:role "listbox"})
|
||||||
|
|
||||||
|
|||||||
@ -41,6 +41,7 @@
|
|||||||
:id id
|
:id id
|
||||||
:on-click on-click
|
:on-click on-click
|
||||||
:data-id id
|
:data-id id
|
||||||
|
:aria-label name
|
||||||
:data-testid "dropdown-option"}
|
:data-testid "dropdown-option"}
|
||||||
|
|
||||||
(if selected
|
(if selected
|
||||||
|
|||||||
@ -4,6 +4,10 @@
|
|||||||
//
|
//
|
||||||
// Copyright (c) KALEIDOS INC
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
@use "ds/typography.scss" as t;
|
||||||
|
@use "ds/_borders.scss" as *;
|
||||||
|
@use "ds/_sizes.scss" as *;
|
||||||
|
|
||||||
@mixin textEllipsis {
|
@mixin textEllipsis {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 99%;
|
max-width: 99%;
|
||||||
@ -20,3 +24,73 @@
|
|||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.common.data.macros :as dm]
|
[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.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.fonts-combobox :as fonts]
|
||||||
[app.main.ui.workspace.tokens.management.forms.controls.input :as input]
|
[app.main.ui.workspace.tokens.management.forms.controls.input :as input]
|
||||||
[app.main.ui.workspace.tokens.management.forms.controls.select :as select]))
|
[app.main.ui.workspace.tokens.management.forms.controls.select :as select]))
|
||||||
@ -16,4 +17,6 @@
|
|||||||
(dm/export fonts/fonts-combobox*)
|
(dm/export fonts/fonts-combobox*)
|
||||||
(dm/export fonts/composite-fonts-combobox*)
|
(dm/export fonts/composite-fonts-combobox*)
|
||||||
|
|
||||||
(dm/export select/select-indexed*)
|
(dm/export select/select-indexed*)
|
||||||
|
|
||||||
|
(dm/export combobox/value-combobox*)
|
||||||
@ -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))))]))
|
||||||
@ -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();
|
||||||
|
}
|
||||||
@ -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}))
|
||||||
@ -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}))
|
||||||
@ -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)))
|
||||||
@ -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))
|
||||||
@ -8,8 +8,10 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.types.tokens-lib :as ctob]
|
[app.common.types.tokens-lib :as ctob]
|
||||||
|
[app.config :as cf]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
[app.main.ui.workspace.tokens.management.forms.color :as color]
|
[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.font-family :as font-family]
|
||||||
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
|
[app.main.ui.workspace.tokens.management.forms.generic-form :as generic]
|
||||||
[app.main.ui.workspace.tokens.management.forms.shadow :as shadow]
|
[app.main.ui.workspace.tokens.management.forms.shadow :as shadow]
|
||||||
@ -39,7 +41,10 @@
|
|||||||
:token token})
|
:token token})
|
||||||
text-case-props (mf/spread-props props {:input-value-placeholder (tr "workspace.tokens.text-case-value-enter")})
|
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")})
|
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
|
(case token-type
|
||||||
:color [:> color/form* props]
|
:color [:> color/form* props]
|
||||||
@ -49,4 +54,5 @@
|
|||||||
:text-case [:> generic/form* text-case-props]
|
:text-case [:> generic/form* text-case-props]
|
||||||
:text-decoration [:> generic/form* text-decoration-props]
|
:text-decoration [:> generic/form* text-decoration-props]
|
||||||
:font-weight [:> generic/form* font-weight-props]
|
:font-weight [:> generic/form* font-weight-props]
|
||||||
|
:border-radius [:> generic/form* border-radius-props]
|
||||||
[:> generic/form* props])))
|
[:> generic/form* props])))
|
||||||
@ -21,6 +21,7 @@
|
|||||||
[app.main.data.workspace.tokens.remapping :as remap]
|
[app.main.data.workspace.tokens.remapping :as remap]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
|
[app.main.ui.context :as muc]
|
||||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||||
[app.main.ui.ds.foundations.assets.icon :as i]
|
[app.main.ui.ds.foundations.assets.icon :as i]
|
||||||
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
|
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
|
||||||
@ -97,6 +98,10 @@
|
|||||||
(and (:name token) (:value token))
|
(and (:name token) (:value token))
|
||||||
(assoc (:name token) token)))
|
(assoc (:name token) token)))
|
||||||
|
|
||||||
|
active-tokens-by-type
|
||||||
|
(mf/with-memo [tokens]
|
||||||
|
(delay (ctob/group-by-type tokens)))
|
||||||
|
|
||||||
schema
|
schema
|
||||||
(mf/with-memo [tokens-tree-in-selected-set active-tab]
|
(mf/with-memo [tokens-tree-in-selected-set active-tab]
|
||||||
(make-schema 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)]
|
error-message (first error-messages)]
|
||||||
(swap! form assoc-in [:extra-errors :value] {:message error-message}))))))))]
|
(swap! form assoc-in [:extra-errors :value] {:message error-message}))))))))]
|
||||||
|
|
||||||
[:> fc/form* {:class (stl/css :form-wrapper)
|
[(mf/provider muc/active-tokens-by-type) {:value active-tokens-by-type}
|
||||||
:form form
|
[:> fc/form* {:class (stl/css :form-wrapper)
|
||||||
:on-submit on-submit}
|
:form form
|
||||||
[:div {:class (stl/css :token-rows)}
|
:on-submit on-submit}
|
||||||
|
[:div {:class (stl/css :token-rows)}
|
||||||
|
|
||||||
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
|
[:> heading* {:level 2 :typography "headline-medium" :class (stl/css :form-modal-title)}
|
||||||
(if (= action "edit")
|
(if (= action "edit")
|
||||||
(tr "workspace.tokens.edit-token" token-type)
|
(tr "workspace.tokens.edit-token" token-type)
|
||||||
(tr "workspace.tokens.create-token" token-type))]
|
(tr "workspace.tokens.create-token" token-type))]
|
||||||
|
|
||||||
[:div {:class (stl/css :input-row)}
|
[:div {:class (stl/css :input-row)}
|
||||||
[:> fc/form-input* {:id "token-name"
|
[:> fc/form-input* {:id "token-name"
|
||||||
:name :name
|
:name :name
|
||||||
:label (tr "workspace.tokens.token-name")
|
:label (tr "workspace.tokens.token-name")
|
||||||
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
|
:placeholder (tr "workspace.tokens.enter-token-name" token-title)
|
||||||
:max-length max-input-length
|
:max-length max-input-length
|
||||||
:variant "comfortable"
|
:variant "comfortable"
|
||||||
:trim true
|
:trim true
|
||||||
:auto-focus true}]]
|
:auto-focus true}]]
|
||||||
|
|
||||||
[:div {:class (stl/css :input-row)}
|
[:div {:class (stl/css :input-row)}
|
||||||
(case value-type
|
(case value-type
|
||||||
:indexed
|
:indexed
|
||||||
[:> input-component
|
[:> input-component
|
||||||
{:token token
|
{:token token
|
||||||
:tokens tokens
|
:tokens tokens
|
||||||
:tab active-tab
|
:tab active-tab
|
||||||
:value-subfield value-subfield
|
:value-subfield value-subfield
|
||||||
:handle-toggle on-toggle-tab}]
|
:handle-toggle on-toggle-tab}]
|
||||||
|
|
||||||
:composite
|
:composite
|
||||||
[:> input-component
|
[:> input-component
|
||||||
{:token token
|
{:token token
|
||||||
:tokens tokens
|
:tokens tokens
|
||||||
:tab active-tab
|
:tab active-tab
|
||||||
:handle-toggle on-toggle-tab}]
|
:handle-toggle on-toggle-tab}]
|
||||||
|
|
||||||
[:> input-component
|
[:> input-component
|
||||||
{:placeholder (or input-value-placeholder
|
{:placeholder (or input-value-placeholder
|
||||||
(tr "workspace.tokens.token-value-enter"))
|
(tr "workspace.tokens.token-value-enter"))
|
||||||
:label (tr "workspace.tokens.token-value")
|
:label (tr "workspace.tokens.token-value")
|
||||||
:name :value
|
:name :value
|
||||||
:token token
|
:token token
|
||||||
:tokens tokens}])]
|
:token-type token-type
|
||||||
|
:tokens tokens}])]
|
||||||
|
|
||||||
[:div {:class (stl/css :input-row)}
|
[:div {:class (stl/css :input-row)}
|
||||||
[:> fc/form-input* {:id "token-description"
|
[:> fc/form-input* {:id "token-description"
|
||||||
:name :description
|
:name :description
|
||||||
:label (tr "workspace.tokens.token-description")
|
:label (tr "workspace.tokens.token-description")
|
||||||
:placeholder (tr "workspace.tokens.token-description")
|
:placeholder (tr "workspace.tokens.token-description")
|
||||||
:max-length max-input-length
|
:max-length max-input-length
|
||||||
:variant "comfortable"
|
:variant "comfortable"
|
||||||
:is-optional true}]]
|
:is-optional true}]]
|
||||||
|
|
||||||
[:div {:class (stl/css-case :button-row true
|
[:div {:class (stl/css-case :button-row true
|
||||||
:with-delete (= action "edit"))}
|
:with-delete (= action "edit"))}
|
||||||
(when (= action "edit")
|
(when (= action "edit")
|
||||||
[:> button* {:on-click on-delete-token
|
[:> button* {:on-click on-delete-token
|
||||||
:on-key-down handle-key-down-delete
|
:on-key-down handle-key-down-delete
|
||||||
:class (stl/css :delete-btn)
|
:class (stl/css :delete-btn)
|
||||||
:type "button"
|
:type "button"
|
||||||
:icon i/delete
|
:icon i/delete
|
||||||
:variant "secondary"}
|
:variant "secondary"}
|
||||||
(tr "labels.delete")])
|
(tr "labels.delete")])
|
||||||
|
|
||||||
[:> button* {:on-click on-cancel
|
[:> button* {:on-click on-cancel
|
||||||
:on-key-down handle-key-down-cancel
|
:on-key-down handle-key-down-cancel
|
||||||
:type "button"
|
:type "button"
|
||||||
:id "token-modal-cancel"
|
:id "token-modal-cancel"
|
||||||
:variant "secondary"}
|
:variant "secondary"}
|
||||||
(tr "labels.cancel")]
|
(tr "labels.cancel")]
|
||||||
|
|
||||||
[:> fc/form-submit* {:variant "primary"
|
[:> fc/form-submit* {:variant "primary"
|
||||||
:on-submit on-submit}
|
:on-submit on-submit}
|
||||||
(tr "labels.save")]]]]))
|
(tr "labels.save")]]]]]))
|
||||||
|
|||||||
@ -277,6 +277,16 @@
|
|||||||
(when (and (some? node) (some? (unchecked-get node "select")))
|
(when (and (some? node) (some? (unchecked-get node "select")))
|
||||||
(.select ^js node)))
|
(.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?
|
(defn ^boolean equals?
|
||||||
[^js node-a ^js node-b]
|
[^js node-a ^js node-b]
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user