Compare commits

...

6 Commits

Author SHA1 Message Date
Eva Marco
47c36b24ac 🎉 Add flag 2026-02-23 10:41:31 +01:00
Eva Marco
b24bcccda6 🎉 Add test 2026-02-19 13:19:38 +01:00
Eva Marco
0739a67493 ♻️ Extract token parsing 2026-02-19 12:16:04 +01:00
Eva Marco
f04356d3cf ♻️ Extract mouse navigation as hook 2026-02-19 12:15:55 +01:00
Eva Marco
9db0d250c5 ♻️ Extract floating position as hook 2026-02-19 12:15:44 +01:00
Eva Marco
f78c76ffde 🎉 Create token combobox 2026-02-19 12:13:42 +01:00
17 changed files with 906 additions and 88 deletions

View File

@@ -122,6 +122,7 @@
;; Only for developtment.
:tiered-file-data-storage
:token-base-font-size
:token-combobox
:token-color
:token-shadow
:token-tokenscript

View File

@@ -96,6 +96,7 @@ test("Update an already created text shape by prepending text", async ({
await workspace.clickLeafLayer("Lorem ipsum");
await workspace.textEditor.startEditing();
await workspace.textEditor.moveFromStart(0);
await page.evaluate(() => new Promise((resolve) => globalThis.requestIdleCallback(resolve)));
await page.keyboard.type("Dolor sit amet ");
await workspace.textEditor.stopEditing();
await workspace.waitForSelectedShapeName("Dolor sit amet Lorem ipsum");

View File

@@ -44,7 +44,9 @@ test.describe("Tokens: Apply token", () => {
page,
}) => {
const { workspacePage, tokensSidebar, tokenContextMenuForToken } =
await setupTokensFile(page);
await setupTokensFile(page, {
flags: ["enable-token-combobox", "enable-feature-token-input"],
});
await page.getByRole("tab", { name: "Layers" }).click();
@@ -83,7 +85,9 @@ test.describe("Tokens: Apply token", () => {
await brTokenPillSM.click();
// Change token from dropdown
const brTokenOptionXl = borderRadiusSection.getByLabel("borderRadius.xl");
const brTokenOptionXl = borderRadiusSection
.getByRole("option", { name: "borderRadius.xl" })
.getByLabel("borderRadius.xl");
await expect(brTokenOptionXl).toBeVisible();
await brTokenOptionXl.click();
@@ -518,7 +522,9 @@ test.describe("Tokens: Apply token", () => {
await dimensionSMTokenPill.nth(1).click();
// Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
const dimensionTokenOptionXl = measuresSection.getByRole("option", {
name: "dimension.xl",
});
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
@@ -572,7 +578,9 @@ test.describe("Tokens: Apply token", () => {
await dimensionSMTokenPill.click();
// Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
const dimensionTokenOptionXl = measuresSection.getByRole("option", {
name: "dimension.xl",
});
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
@@ -626,7 +634,9 @@ test.describe("Tokens: Apply token", () => {
await dimensionSMTokenPill.click();
// Change token from dropdown
const dimensionTokenOptionXl = measuresSection.getByLabel("dimension.xl");
const dimensionTokenOptionXl = measuresSection.getByRole("option", {
name: "dimension.xl",
});
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
@@ -681,8 +691,9 @@ test.describe("Tokens: Apply token", () => {
await dimensionXSTokenPill.click();
// Change token from dropdown
const dimensionTokenOptionXl =
borderRadiusSection.getByLabel("dimension.xl");
const dimensionTokenOptionXl = borderRadiusSection.getByRole("option", {
name: "dimension.xl",
});
await expect(dimensionTokenOptionXl).toBeVisible();
await dimensionTokenOptionXl.click();
@@ -751,7 +762,9 @@ test.describe("Tokens: Apply token", () => {
});
await tokenDropdown.click();
const widthOptionSmall = firstStrokeRow.getByLabel("width-small");
const widthOptionSmall = firstStrokeRow.getByRole("option", {
name: "width-small",
});
await expect(widthOptionSmall).toBeVisible();
await widthOptionSmall.click();
const StrokeWidthPillSmall = firstStrokeRow.getByRole("button", {

View File

@@ -30,6 +30,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 setupEmptyTokensFile(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("button", { 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",

View File

@@ -40,6 +40,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 setupTokensFile(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 setupTokensFile(page, { flags: ["enable-token-shadow"] });
@@ -401,13 +429,21 @@ test.describe("Remapping Tokens", () => {
test("User renames border radius token with alias references", async ({
page,
}) => {
const { tokensSidebar } = await setupTokensFile(page);
const { tokensSidebar } = await setupTokensFile(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",
@@ -443,13 +479,21 @@ test.describe("Remapping Tokens", () => {
tokensUpdateCreateModal,
tokensSidebar,
tokenContextMenuForToken,
} = await setupTokensFile(page);
} = await setupTokensFile(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",

View File

@@ -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]
@@ -83,6 +85,7 @@
:name name
:resolved (get option :resolved-value)
:ref ref
:role "option"
:focused (= id focused)
:on-click on-click}]
@@ -94,6 +97,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 +105,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"})

View File

@@ -40,6 +40,7 @@
:id id
:on-click on-click
:data-id id
:aria-label name
:data-testid "dropdown-option"}
(if selected

View File

@@ -0,0 +1,95 @@
(ns app.main.ui.ds.controls.utilities.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]
(if (empty? tokens)
[{:type :empty
:label (if no-sets
(tr "ds.inputs.numeric-input.no-applicable-tokens")
(tr "ds.inputs.numeric-input.no-matches"))}]
(->> tokens
(map (fn [[type 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? (nil? 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))))

View File

@@ -143,8 +143,7 @@
(let [token-ids (set tokens-in-path-ids)
remaining-tokens (filter (fn [token]
(not (contains? token-ids (:id token))))
selected-token-set-tokens)
_ (prn "Remaining tokens:" remaining-tokens)]
selected-token-set-tokens)]
(seq remaining-tokens))))
delete-token

View File

@@ -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*)
(dm/export select/select-indexed*)
(dm/export combobox/combobox*)

View File

@@ -0,0 +1,290 @@
;; 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.main.data.style-dictionary :as sd]
[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.controls.utilities.utils :as csu]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.main.ui.forms :as fc]
[app.main.ui.workspace.tokens.management.forms.controls.floating :refer [use-floating-dropdown]]
[app.main.ui.workspace.tokens.management.forms.controls.navigation :refer [use-navigation]]
[app.main.ui.workspace.tokens.management.forms.controls.token-parsing :as tp]
[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))))]
(->> tokens
(sd/resolve-tokens-interactive)
(rx/mapcat
(fn [resolved-tokens]
(let [{:keys [errors resolved-value] :as resolved-token} (get resolved-tokens (:name token))]
(if resolved-value
(rx/of {:value resolved-value})
(rx/of {:error (first errors)}))))))))
(mf/defc 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)
final-val (tp/select-option-by-id id options-ref input-node value)]
(fm/on-input-change form name final-val true)
(rx/push! resolve-stream final-val)
(reset! filter-term* "")
(reset! is-open* false))))
{:keys [focused-id on-key-down]}
(use-navigation
{:is-open is-open
:options-ref options-ref
:nodes-ref nodes-ref
: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")
final-val (tp/select-option-by-id id options-ref input-node value)]
(fm/on-input-change form name final-val true)
(rx/push! resolve-stream final-val)
(reset! filter-term* "")
(reset! is-open* false)
(dom/focus! input-node)
(let [new-cursor (+ (str/index-of final-val "}") 1)]
(set! (.-selectionStart input-node) new-cursor)
(set! (.-selectionEnd input-node) new-cursor)))))
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))))]))

View File

@@ -0,0 +1,19 @@
// 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 *;
// TODO: Remove this after creating a non deprecated scrollbar class
@use "refactor/common-refactor.scss" as deprecated;
.dropdown {
// TODO: create new scrollbar class not deprecated.
@extend .new-scrollbar;
position: fixed;
max-block-size: $sz-400;
max-block-size: 200px;
overflow-y: auto;
}

View File

@@ -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
(: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}))

View File

@@ -0,0 +1,114 @@
;; 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.navigation
(:require
[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
[options focused-id direction]
(let [focusable (filter focusable-option? options)
ids (map :id focusable)
idx (.indexOf (clj->js ids) focused-id)
next-idx (case direction
:down (min (dec (count ids)) (inc (if (= idx -1) -1 idx)))
:up (max 0 (dec (if (= idx -1) 0 idx))))]
(nth ids next-idx nil)))
(defn use-navigation
[{:keys [is-open options-ref 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)
;; TODO: this should be optional?
open-dropdown (kbd/is-key? event "{")
close-dropdown (kbd/is-key? event "}")
options (mf/ref-val options-ref)
options (if (delay? options) @options options)]
(cond
down?
(do
(dom/prevent-default event)
(if is-open
(let [next-id (next-focus-id options focused-id :down)]
(reset! focused-id* next-id))
(do
(toggle-dropdown event)
(reset! focused-id* (first-focusable-id options)))))
up?
(when is-open
(dom/prevent-default event)
(let [next-id (next-focus-id options focused-id :up)]
(reset! focused-id* next-id)))
open-dropdown
(reset! is-open* true)
close-dropdown
(reset! is-open* false)
enter?
(do
(dom/prevent-default event)
(if is-open
(on-enter focused-id)
(do
(reset! focused-id* (first-focusable-id options))
(toggle-dropdown event))))
esc?
(do
(dom/prevent-default event)
(reset! is-open* false))
:else nil))))]
;; Initial focus on first option
(mf/with-effect [is-open options-ref]
(when is-open
(let [options (mf/ref-val options-ref)
options (if (delay? options) @options options)
first-id (first-focusable-id options)]
(when first-id
(reset! focused-id* first-id)))))
;; 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}))

View File

@@ -0,0 +1,66 @@
;; 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.main.ui.ds.controls.select :refer [get-option]]
[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
:partial (subs text-before (inc last-open))})))
(defn replace-active-token
[value cursor new-name]
(let [before (subs value 0 cursor)
last-open (str/last-index-of before "{")
last-close (str/last-index-of before "}")]
(if (and last-open
(or (nil? last-close)
(> last-open last-close)))
(let [after-start (subs value last-open)
close-pos (str/index-of after-start "}")
end (if close-pos
(+ last-open close-pos 1)
cursor)]
(str (subs value 0 last-open)
"{" new-name "}"
(subs value end)))
(str (subs value 0 cursor)
"{" new-name "}"
(subs value cursor)))))
(defn active-token [value input-node]
(let [cursor (.-selectionStart 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 (.-selectionStart input-node)
options (mf/ref-val options-ref)
options (if (delay? options) @options options)
option (get-option options id)
name (:name option)
final-val (replace-active-token value cursor name)]
final-val))

View File

@@ -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/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])))

View File

@@ -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 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 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")]]]]]))