diff --git a/frontend/resources/images/icons/tokens.svg b/frontend/resources/images/icons/tokens.svg
new file mode 100644
index 0000000000..8c7784a623
--- /dev/null
+++ b/frontend/resources/images/icons/tokens.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs
index 446c011cd1..d25c60b99d 100644
--- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs
+++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs
@@ -214,7 +214,7 @@
ptk/WatchEvent
(watch [_ state _]
(let [data (dsh/lookup-file-data state)
- tlib (get-tokens-lib state)
+ tlib (get-tokens-lib state)
changes (-> (pcb/empty-changes)
(pcb/with-library-data data)
(clt/generate-toggle-token-set tlib name))]
@@ -351,7 +351,6 @@
(:id token)
token))]
- (js/console.log "Creating token" (clj->js changes))
(rx/of (dch/commit-changes changes)
(ptk/data-event ::ev/event {::ev/name "create-token" :type token-type})))
diff --git a/frontend/src/app/main/ui/ds.cljs b/frontend/src/app/main/ui/ds.cljs
index 33eaff367f..0c17ed51b2 100644
--- a/frontend/src/app/main/ui/ds.cljs
+++ b/frontend/src/app/main/ui/ds.cljs
@@ -11,6 +11,7 @@
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.combobox :refer [combobox*]]
[app.main.ui.ds.controls.input :refer [input*]]
+ [app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
@@ -66,6 +67,7 @@
:Tooltip tooltip*
:ContextNotification context-notification*
:NotificationPill notification-pill*
+ :NumericInput numeric-input*
:Actionable actionable*
:TokenStatusIcon token-status-icon*
:Swatch swatch*
diff --git a/frontend/src/app/main/ui/ds/_borders.scss b/frontend/src/app/main/ui/ds/_borders.scss
index f6e060d4bd..cccca570ff 100644
--- a/frontend/src/app/main/ui/ds/_borders.scss
+++ b/frontend/src/app/main/ui/ds/_borders.scss
@@ -9,6 +9,7 @@
// TODO: create actual tokens once we have them from design
$br-8: px2rem(8);
$br-4: px2rem(4);
+$br-6: px2rem(6);
$br-circle: 50%;
$b-1: px2rem(1);
diff --git a/frontend/src/app/main/ui/ds/_sizes.scss b/frontend/src/app/main/ui/ds/_sizes.scss
index bbfa2f0056..dd737f6a5a 100644
--- a/frontend/src/app/main/ui/ds/_sizes.scss
+++ b/frontend/src/app/main/ui/ds/_sizes.scss
@@ -7,6 +7,7 @@
@use "./utils.scss" as *;
// TODO: create actual tokens once we have them from design
+$sz-6: px2rem(6);
$sz-16: px2rem(16);
$sz-24: px2rem(24);
$sz-32: px2rem(32);
diff --git a/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx b/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx
index 448ba3ac57..a9c169861c 100644
--- a/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx
+++ b/frontend/src/app/main/ui/ds/controls/combobox.stories.jsx
@@ -189,25 +189,28 @@ export const TestInteractions = {
await userEvent.clear(input);
});
- await step("Filter with 'es' (Tuesday, Wednesday) and select Wednesday", async () => {
- await userEvent.clear(input);
- await userEvent.keyboard("{Escape}");
+ await step(
+ "Filter with 'es' (Tuesday, Wednesday) and select Wednesday",
+ async () => {
+ await userEvent.clear(input);
+ await userEvent.keyboard("{Escape}");
- await userEvent.click(input);
+ await userEvent.click(input);
- await userEvent.type(input, "es");
+ await userEvent.type(input, "es");
- const options = await canvas.findAllByTestId("dropdown-option");
- expect(options).toHaveLength(2);
+ const options = await canvas.findAllByTestId("dropdown-option");
+ expect(options).toHaveLength(2);
- await userEvent.keyboard("[ArrowDown]");
- await userEvent.keyboard("[ArrowDown]");
+ await userEvent.keyboard("[ArrowDown]");
+ await userEvent.keyboard("[ArrowDown]");
- await userEvent.keyboard("{Enter}");
+ await userEvent.keyboard("{Enter}");
- expect(input).toHaveValue("Wednesday");
- expect(lastValue).toBe("Wednesday");
- });
+ expect(input).toHaveValue("Wednesday");
+ expect(lastValue).toBe("Wednesday");
+ },
+ );
await step("Close dropdown when focusing out", async () => {
await userEvent.clear(input);
diff --git a/frontend/src/app/main/ui/ds/controls/input.cljs b/frontend/src/app/main/ui/ds/controls/input.cljs
index ec26c84318..0a3735703b 100644
--- a/frontend/src/app/main/ui/ds/controls/input.cljs
+++ b/frontend/src/app/main/ui/ds/controls/input.cljs
@@ -13,19 +13,15 @@
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
[app.main.ui.ds.controls.utilities.label :refer [label*]]
- [app.main.ui.ds.foundations.assets.icon :refer [icon-list]]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private schema:input
[:map
[:id {:optional true} :string]
- [:label {:optional true} :string]
[:class {:optional true} :string]
+ [:label {:optional true} :string]
[:is-optional {:optional true} :boolean]
- [:placeholder {:optional true} :string]
- [:icon {:optional true}
- [:and :string [:fn #(contains? icon-list %)]]]
[:type {:optional true} :string]
[:max-length {:optional true} :int]
[:variant {:optional true} [:maybe [:enum "seamless" "dense" "comfortable"]]]
diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs
new file mode 100644
index 0000000000..b32eea6790
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs
@@ -0,0 +1,677 @@
+;; 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.ds.controls.numeric-input
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [app.common.math :as mth]
+ [app.common.schema :as sm]
+ [app.main.constants :refer [max-input-length]]
+ [app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
+ [app.main.ui.ds.controls.select :refer [get-option handle-focus-change]]
+ [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown*]]
+ [app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
+ [app.main.ui.ds.controls.utilities.token-field :refer [token-field*]]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list]]
+ [app.main.ui.ds.tooltip :refer [tooltip*]]
+ [app.main.ui.formats :as fmt]
+ [app.util.dom :as dom]
+ [app.util.i18n :refer [tr]]
+ [app.util.keyboard :as kbd]
+ [app.util.object :as obj]
+ [app.util.simple-math :as smt]
+ [cuerdas.core :as str]
+ [goog.events :as events]
+ [rumext.v2 :as mf]
+ [rumext.v2.util :as mfu]))
+
+(defn- increment
+ "Increments `val` by `step`, clamped to [`min-val`, `max-val`]."
+ [val step min-val max-val]
+ (mth/clamp (+ val step) min-val max-val))
+
+(defn- decrement
+ "Decrements `val` by `step`, clamped to [`min-val`, `max-val`]."
+ [val step min-val max-val]
+ (mth/clamp (- val step) min-val max-val))
+
+(defn- parse-value
+ "Parses and clamps `raw-value` as a number within bounds;
+ returns nil if invalid or empty."
+ [raw-value last-value min-value max-value nillable]
+ (let [new-value (-> raw-value
+ (str)
+ (str/strip-suffix ".")
+ (smt/expr-eval (d/parse-double last-value)))]
+ (cond
+ (and nillable (nil? raw-value))
+ nil
+
+ (d/num? new-value)
+ (-> new-value
+ (mth/max (/ sm/min-safe-int 2))
+ (mth/min (/ sm/max-safe-int 2))
+ (cond-> (d/num? min-value)
+ (mth/max min-value))
+ (cond-> (d/num? max-value)
+ (mth/min max-value)))
+
+ :else nil)))
+
+(defn- get-option-by-name
+ [options name]
+ (d/seek #(= name (get % :name)) options))
+
+(defn- get-token-op
+ [tokens name]
+ (->> tokens
+ vals
+ (apply concat)
+ (some #(when (= (:name %) name) %))))
+
+(defn- clean-token-name
+ [s]
+ (some-> s
+ (str/replace #"^\{" "")
+ (str/replace #"\}$" "")))
+
+(defn- token->dropdown-option
+ [token]
+ {:id (str (get token :id))
+ :type :token
+ :resolved-value (get token :resolved-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-by-type 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-by-type)))
+
+(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-index
+ [options focused-id direction]
+ (let [len (count options)
+ start-index (or (d/index-of-pred options #(= focused-id (:id %))) -1)
+ indices (case direction
+ :down (range (inc start-index) (+ len start-index))
+ :up (range (dec start-index) (- start-index len) -1))]
+ (some (fn [i]
+ (let [j (mod i len)]
+ (when (focusable-option? (nth options j))
+ j)))
+ indices)))
+
+(def ^:private schema:icon
+ [:and :string [:fn #(contains? icon-list %)]])
+
+;; TODO: Review schema props
+(def ^:private schema:numeric-input
+ [:map
+ [:id {:optional true} :string]
+ [:class {:optional true} :string]
+ ;; [:value {:optional true} [:maybe [:or
+ ;; :int
+ ;; :string]]]
+ [:default {:optional true} [:maybe :string]]
+ [:placeholder {:optional true} :string]
+ [:icon {:optional true} [:maybe schema:icon]]
+ [:disabled {:optional true} [:maybe :boolean]]
+ [:min {:optional true} [:maybe :int]]
+ [:max {:optional true} [:maybe :int]]
+ [:max-length {:optional true} :int]
+ [:step {:optional true} [:maybe :int]]
+ [:is-selected-on-focus {:optional true} :boolean]
+ [:nillable {:optional true} :boolean]
+ [:applied-token {:optional true} [:maybe [:or :string [:= :multiple]]]]
+ [:empty-to-end {:optional true} :boolean]
+ [:on-change {:optional true} fn?]
+ [:on-blur {:optional true} fn?]
+ [:on-focus {:optional true} fn?]
+ [:on-detach {:optional true} fn?]
+ [:property {:optional true} :string]
+ [:align {:optional true} [:enum :left :right]]])
+
+(mf/defc numeric-input*
+ {::mf/schema schema:numeric-input}
+ [{:keys [id class value default placeholder icon disabled
+ min max max-length step
+ is-selected-on-focus nillable
+ tokens applied-token empty-to-end
+ on-change on-blur on-focus on-detach
+ property align ref] :rest props}]
+ (let [;; NOTE: we use mfu/bean here for transparently handle
+ ;; options provide as clojure data structures or javascript
+ ;; plain objects and lists.
+ tokens (if (object? tokens)
+ (mfu/bean tokens)
+ tokens)
+
+ value (if (= :multiple applied-token)
+ :multiple
+ value)
+ is-multiple? (= :multiple value)
+ value (cond
+ is-multiple? nil
+ (and nillable (nil? value)) nil
+ :else (d/parse-double value default))
+
+ ;; Default props
+ nillable (d/nilv nillable false)
+ disabled (d/nilv disabled false)
+ select-on-focus (d/nilv is-selected-on-focus true)
+
+ default (mf/with-memo [default nillable]
+ (d/parse-double default (when-not nillable 0)))
+
+ step (mf/with-memo [step]
+ (d/parse-double step 1))
+
+ min (mf/with-memo [min]
+ (d/parse-double min sm/min-safe-int))
+
+ max (mf/with-memo [max]
+ (d/parse-double max sm/max-safe-int))
+
+ max-length (d/nilv max-length max-input-length)
+ empty-to-end (d/nilv empty-to-end false)
+ internal-id (mf/use-id)
+ id (d/nilv id internal-id)
+ listbox-id (mf/use-id)
+ align (d/nilv align :left)
+
+ ;; State and values
+ is-open* (mf/use-state false)
+ is-open (deref is-open*)
+
+ token-applied* (mf/use-state applied-token)
+ token-applied (deref token-applied*)
+
+ focused-id* (mf/use-state nil)
+ focused-id (deref focused-id*)
+
+ filter-id* (mf/use-state "")
+ filter-id (deref filter-id*)
+
+ raw-value* (mf/use-ref nil)
+ last-value* (mf/use-ref nil)
+
+ ;; Refs
+ wrapper-ref (mf/use-ref nil)
+ nodes-ref (mf/use-ref nil)
+ options-ref (mf/use-ref nil)
+ token-wrapper-ref (mf/use-ref nil)
+ internal-ref (mf/use-ref nil)
+ ref (or ref internal-ref)
+ dirty-ref (mf/use-ref false)
+ open-dropdown-ref (mf/use-ref nil)
+ token-detach-btn-ref (mf/use-ref nil)
+
+ dropdown-options
+ (mf/with-memo [tokens filter-id]
+ (let [partial (extract-partial-brace-text filter-id)
+ options (if (seq partial)
+ (filter-token-groups-by-name tokens partial)
+ tokens)
+ no-sets? (nil? tokens)]
+ (generate-dropdown-options options no-sets?)))
+
+ selected-id*
+ (mf/use-state (fn []
+ (if applied-token
+ (:id (get-option-by-name dropdown-options applied-token))
+ nil)))
+ selected-id
+ (deref selected-id*)
+
+ 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)
+ (fn []
+ (let [state (mf/ref-val nodes-ref)
+ state (d/nilv state #js {})
+ id (dom/get-data node "id")
+ state (obj/unset! state id)]
+ (mf/set-ref-val! nodes-ref state))))))
+
+ ;; Callbacks
+ update-input
+ (mf/use-fn
+ (fn [new-value]
+ (when-let [node (mf/ref-val ref)]
+ (dom/set-value! node new-value))))
+
+ apply-value
+ (mf/use-fn
+ (mf/deps on-change update-input value nillable min max)
+ (fn [raw-value]
+ (if-let [parsed (parse-value raw-value (mf/ref-val last-value*) min max nillable)]
+ (when-not (= parsed (mf/ref-val last-value*))
+ (mf/set-ref-val! last-value* parsed)
+ (reset! token-applied* nil)
+ (when (fn? on-change)
+ (on-change parsed))
+
+ ;; Comprar si es valor es necesario, sino borrar
+ (mf/set-ref-val! raw-value* (fmt/format-number parsed))
+ (update-input (fmt/format-number parsed)))
+
+ (if (and nillable (empty? raw-value))
+ (do
+ (mf/set-ref-val! last-value* nil)
+ (mf/set-ref-val! raw-value* "")
+ (reset! token-applied* nil)
+ (update-input "")
+ (when (fn? on-change)
+ (on-change nil)))
+
+ (let [fallback-value (or (mf/ref-val last-value*) default)]
+ (mf/set-ref-val! raw-value* fallback-value)
+ (mf/set-ref-val! last-value* fallback-value)
+ (reset! token-applied* nil)
+ (update-input (fmt/format-number fallback-value))
+
+ (when (and (fn? on-change) (not= fallback-value (str value)))
+ (on-change fallback-value)))))))
+
+ apply-token
+ (mf/use-fn
+ (mf/deps min max nillable on-change tokens)
+ (fn [value name]
+ (let [parsed (parse-value value (mf/ref-val last-value*) min max nillable)]
+ (when-not (= parsed (mf/ref-val last-value*))
+ (mf/set-ref-val! last-value* parsed)
+ (when (fn? on-change)
+ (on-change (get-token-op tokens name)))))))
+
+ store-raw-value
+ (mf/use-fn
+ (fn [event]
+ (let [text (dom/get-target-val event)]
+ (mf/set-ref-val! raw-value* text)
+ (reset! filter-id* text))))
+
+ on-token-apply
+ (mf/use-fn
+ (mf/deps apply-token)
+ (fn [id value name]
+ (reset! selected-id* id)
+ (reset! focused-id* nil)
+ (reset! is-open* false)
+ (reset! token-applied* name)
+ (apply-token value name)))
+
+ on-option-click
+ (mf/use-fn
+ (mf/deps dropdown-options on-token-apply)
+ (fn [event]
+ (let [node (dom/get-current-target event)
+ id (dom/get-data node "id")
+ option (get-option dropdown-options id)
+ value (get option :resolved-value)
+ name (get option :name)]
+ (on-token-apply id value name)
+ (reset! filter-id* ""))))
+
+ on-option-enter
+ (mf/use-fn
+ (mf/deps dropdown-options focused-id on-token-apply)
+ (fn [_]
+ (let [option (get-option dropdown-options focused-id)
+ value (get option :resolved-value)
+ name (get option :name)]
+ (on-token-apply focused-id value name)
+ (reset! filter-id* ""))))
+
+ on-blur
+ (mf/use-fn
+ (mf/deps apply-value on-blur)
+ (fn [event]
+ (let [target (dom/get-related-target event)
+ self-node (mf/ref-val wrapper-ref)]
+ (when-not (dom/is-child? self-node target)
+ (reset! filter-id* "")
+ (reset! focused-id* nil)
+ (reset! is-open* false)))
+ (when (mf/ref-val dirty-ref)
+ (apply-value (mf/ref-val raw-value*))
+ (when (fn? on-blur)
+ (on-blur event)))))
+
+ handle-key-down
+ (mf/use-fn
+ (mf/deps dropdown-options is-open apply-value update-input is-open focused-id handle-focus-change)
+ (fn [event]
+ (mf/set-ref-val! dirty-ref true)
+ (let [up? (kbd/up-arrow? event)
+ down? (kbd/down-arrow? event)
+ enter? (kbd/enter? event)
+ esc? (kbd/esc? event)
+ node (mf/ref-val ref)
+ open-tokens (kbd/is-key? event "{")
+ close-tokens (kbd/is-key? event "}")
+ options (mf/ref-val options-ref)]
+
+ (cond
+ (and (some? options) open-tokens)
+ (reset! is-open* true)
+
+ close-tokens
+ (do
+ (let [name (clean-token-name (mf/ref-val raw-value*))
+ token (get-option-by-name options name)]
+ (if token
+ (apply-token (:resolved-value token) name)
+ (apply-value (mf/ref-val last-value*)))))
+
+ enter?
+ (if is-open
+ (do
+ (dom/prevent-default event)
+ (if focused-id
+ (on-option-enter event)
+ (let [option-id (first-focusable-id dropdown-options)
+ option (get-option dropdown-options option-id)
+ value (get option :resolved-value)
+ name (get option :name)]
+ (on-token-apply option-id value name)
+ (reset! filter-id* ""))))
+ (on-blur event))
+
+ esc?
+ (do
+ (update-input (fmt/format-number (mf/ref-val last-value*)))
+ (reset! is-open* false)
+ (dom/blur! node))
+
+ (kbd/home? event)
+ (handle-focus-change options focused-id* 0 (mf/ref-val nodes-ref))
+
+ up?
+ (if is-open
+ (let [new-index (next-focus-index options focused-id :up)]
+ (dom/prevent-default event)
+ (handle-focus-change options focused-id* new-index (mf/ref-val nodes-ref)))
+
+ (let [parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable)
+ current-value (or parsed default)
+ new-val (increment current-value step min max)]
+ (dom/prevent-default event)
+ (update-input (fmt/format-number new-val))
+ (apply-value (dm/str new-val))))
+
+ down?
+ (if is-open
+ (let [new-index (next-focus-index options focused-id :down)]
+ (dom/prevent-default event)
+ (handle-focus-change options focused-id* new-index (mf/ref-val nodes-ref)))
+
+ (let [parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable)
+ current-value (or parsed default)
+ new-val (decrement current-value step min max)]
+ (dom/prevent-default event)
+ (update-input (fmt/format-number new-val))
+ (apply-value (dm/str new-val))))))))
+
+ handle-focus
+ (mf/use-fn
+ (mf/deps on-focus select-on-focus)
+ (fn [event]
+ (when (fn? on-focus)
+ (on-focus event))
+ (let [target (dom/get-target event)]
+ (when select-on-focus
+ (dom/select-text! target)
+ ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect
+ (.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))
+
+ handle-mouse-wheel
+ (mf/use-fn
+ (mf/deps apply-value parse-value min max nillable ref default step min max)
+ (fn [event]
+ (when-let [node (mf/ref-val ref)]
+ (when (dom/active? node)
+ (let [inc? (->> (dom/get-delta-position event)
+ :y
+ (neg?))
+ parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable)
+ current-value (or parsed default)
+ new-val (if inc?
+ (increment current-value step min max)
+ (decrement current-value step min max))]
+ (dom/prevent-default event)
+ (dom/stop-propagation event)
+ (apply-value (dm/str new-val)))))))
+
+ open-dropdown
+ (mf/use-fn
+ (mf/deps disabled ref)
+ (fn [event]
+ (when-not disabled
+ (dom/prevent-default event)
+ (swap! is-open* not)
+ (dom/focus! (mf/ref-val ref)))))
+
+ open-dropdown-token
+ (mf/use-fn
+ (mf/deps disabled token-wrapper-ref)
+ (fn [event]
+ (when-not disabled
+ (dom/prevent-default event)
+ (swap! is-open* not)
+ (dom/focus! (mf/ref-val token-wrapper-ref)))))
+
+ detach-token
+ (mf/use-fn
+ (mf/deps on-detach tokens disabled token-applied)
+ (fn [event]
+ (let [token (get-token-op tokens token-applied)]
+ (when-not disabled
+ (dom/prevent-default event)
+ (dom/stop-propagation event)
+ (reset! token-applied* nil)
+ (reset! selected-id* nil)
+ (reset! focused-id* nil)
+ (dom/focus! (mf/ref-val ref))
+ (when on-detach
+ (on-detach token))))))
+
+ on-token-key-down
+ (mf/use-fn
+ (mf/deps detach-token is-open)
+ (fn [event]
+ (let [esc? (kbd/esc? event)
+ delete? (kbd/delete? event)
+ backspace? (kbd/backspace? event)
+ enter? (kbd/enter? event)
+ up? (kbd/up-arrow? event)
+ down? (kbd/down-arrow? event)
+ options (mf/ref-val options-ref)
+ detach-btn (mf/ref-val token-detach-btn-ref)
+ target (dom/get-target event)]
+
+ (when-not disabled
+ (cond
+ (or delete? backspace?)
+ (do
+ (dom/prevent-default event)
+ (detach-token event)
+ (dom/focus! (mf/ref-val ref)))
+
+ enter?
+ (if is-open
+ (do
+ (dom/prevent-default event)
+ (dom/stop-propagation event)
+ (on-option-enter event))
+ (when (not= target detach-btn)
+ (dom/prevent-default event)
+ (reset! is-open* true)))
+
+ esc?
+ (dom/blur! (mf/ref-val token-wrapper-ref))
+
+ up?
+ (when is-open
+ (let [new-index (next-focus-index options focused-id :up)]
+ (dom/prevent-default event)
+ (handle-focus-change options focused-id* new-index nodes-ref)))
+
+ down?
+ (when is-open
+ (let [new-index (next-focus-index options focused-id :down)]
+ (dom/prevent-default event)
+ (handle-focus-change options focused-id* new-index nodes-ref))))))))
+
+ input-props
+ (mf/spread-props props {:ref ref
+ :type "text"
+ :id id
+ :placeholder (if is-multiple?
+ (tr "labels.mixed-values")
+ placeholder)
+ :default-value (or (mf/ref-val last-value*) (fmt/format-number value))
+ :on-blur on-blur
+ :on-key-down handle-key-down
+ :on-focus handle-focus
+ :on-change store-raw-value
+ :disabled disabled
+ :slot-start (when icon
+ (mf/html [:> tooltip*
+ {:content property
+ :id property}
+ [:> icon* {:icon-id icon
+ :aria-labelledby property
+ :class (stl/css :icon)}]]))
+ :slot-end (when-not disabled
+ (when (some? tokens)
+ (mf/html [:> icon-button* {:variant "action"
+ :icon "tokens"
+ :class (stl/css :invisible-button)
+ :aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown")
+ :ref open-dropdown-ref
+ :on-click open-dropdown}])))
+ :max-length max-length})
+
+ token-props
+ (when (and token-applied (not= :multiple token-applied))
+ (let [token (get-option-by-name dropdown-options token-applied)
+ id (get token :id)
+ label (get token :name)
+ token-value (or (get token :resolved-value)
+ (or (mf/ref-val last-value*) (fmt/format-number value)))]
+ (mf/spread-props props
+ {:id id
+ :label label
+ :value token-value
+ :on-click open-dropdown-token
+ :on-token-key-down on-token-key-down
+ :disabled disabled
+ :on-blur on-blur
+ :slot-start (when icon
+ (mf/html [:> tooltip*
+ {:content property
+ :id property}
+ [:> icon* {:icon-id icon
+ :aria-labelledby property
+ :class (stl/css :icon)}]]))
+ :token-wrapper-ref token-wrapper-ref
+ :token-detach-btn-ref token-detach-btn-ref
+ :detach-token detach-token})))]
+
+ (mf/with-effect [value default applied-token]
+ (let [value' (cond
+ is-multiple?
+ ""
+
+ (and nillable (nil? value))
+ ""
+
+ :else
+ (fmt/format-number (d/parse-double value default)))]
+
+ (mf/set-ref-val! raw-value* value')
+ (mf/set-ref-val! last-value* value')
+ (reset! token-applied* applied-token)
+ (if applied-token
+ (let [token-id (:id (get-option-by-name dropdown-options applied-token))]
+ (reset! selected-id* token-id))
+ (reset! selected-id* nil))
+
+ (when-let [node (mf/ref-val ref)]
+ (dom/set-value! node value'))))
+
+ (mf/with-layout-effect [handle-mouse-wheel]
+ (when-let [node (mf/ref-val ref)]
+ (let [key (events/listen node "wheel" handle-mouse-wheel #js {:passive false})]
+ #(events/unlistenByKey key))))
+
+ (mf/with-effect [dropdown-options]
+ (mf/set-ref-val! options-ref dropdown-options))
+
+ [:div {:class (dm/str class " " (stl/css :input-wrapper))
+ :ref wrapper-ref}
+
+ (if (and (some? token-applied)
+ (not= :multiple token-applied))
+ [:> token-field* token-props]
+ [:> input-field* input-props])
+
+ (when ^boolean is-open
+ [:> options-dropdown* {:on-click on-option-click
+ :id listbox-id
+ :options dropdown-options
+ :selected selected-id
+ :focused focused-id
+ :align align
+ :empty-to-end empty-to-end
+ :ref set-option-ref}])]))
diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.mdx b/frontend/src/app/main/ui/ds/controls/numeric_input.mdx
new file mode 100644
index 0000000000..9eaac5dd46
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/numeric_input.mdx
@@ -0,0 +1,11 @@
+{ /* 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 */ }
+import { Canvas, Meta } from '@storybook/blocks';
+import * as InputStories from "./numeric_input.stories";
+
+
+
+# Numeric Input
\ No newline at end of file
diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.scss b/frontend/src/app/main/ui/ds/controls/numeric_input.scss
new file mode 100644
index 0000000000..a44717f826
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/numeric_input.scss
@@ -0,0 +1,42 @@
+// 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 "../spacing.scss" as *;
+@use "../borders.scss" as *;
+@use "../sizes.scss" as *;
+@use "../typography.scss" as t;
+
+.input-wrapper {
+ --opacity-button: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-xs);
+ inline-size: 100%;
+ position: relative;
+
+ &:hover {
+ --opacity-button: 1;
+ }
+ &:focus-within {
+ --opacity-button: 1;
+ }
+}
+
+.icon {
+ color: var(--color-foreground-secondary);
+ min-width: var(--sp-l);
+}
+
+.invisible-button {
+ opacity: var(--opacity-button);
+
+ &:hover {
+ --opacity-button: 1;
+ }
+ &:focus {
+ --opacity-button: 1;
+ }
+}
diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.stories.jsx b/frontend/src/app/main/ui/ds/controls/numeric_input.stories.jsx
new file mode 100644
index 0000000000..8cbf44a481
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/numeric_input.stories.jsx
@@ -0,0 +1,121 @@
+// 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
+import * as React from "react";
+import Components from "@target/components";
+
+const { NumericInput } = Components;
+const { icons } = Components.meta;
+
+export default {
+ title: "Controls/Numeric Input",
+ component: Components.NumericInput,
+ argTypes: {
+ placeholder: {
+ control: { type: "text" },
+ },
+ disabled: {
+ control: { type: "boolean" },
+ },
+ nillable: {
+ control: { type: "boolean" },
+ },
+ min: {
+ control: { type: "number" },
+ },
+ max: {
+ control: { type: "number" },
+ },
+ step: {
+ control: { type: "number" },
+ },
+ icon: {
+ options: icons,
+ control: { type: "select" },
+ },
+ },
+ args: {
+ placeholder: "--",
+ disabled: false,
+ nillable: false,
+ icon: "search",
+ property: "search",
+ },
+ parameters: {
+ controls: { exclude: [ "tokens" ] },
+ },
+ render: ({ ...args }) => ,
+};
+
+export const Default = {};
+
+export const WithTokens = {
+ args: {
+ placeholder: "--",
+ disabled: false,
+ nillable: false,
+ icon: "search",
+ min: 0,
+ max: 100,
+ step: 1,
+ tokens: {
+ dimensions: [
+ {
+ id: "ef79ae43-3f3f-8008-8006-988189c5e56e",
+ name: "dimension-1",
+ type: "dimensions",
+ value: "30",
+ description: "",
+ "modified-at": "2025-08-04T12:02:00.087-00:00",
+ resolvedValue: 30,
+ unit: null,
+ },
+ {
+ id: "ef79ae43-3f3f-8008-8006-98a5d53d85d0",
+ name: "dimension-2",
+ type: "dimensions",
+ value: "20",
+ description: "",
+ "modified-at": "2025-08-04T14:40:34.550-00:00",
+ resolvedValue: 20,
+ unit: null,
+ },
+ {
+ id: "ef79ae43-3f3f-8008-8006-98a5dd078629",
+ name: "dimension-3",
+ type: "dimensions",
+ value: "100",
+ description: "",
+ "modified-at": "2025-08-04T14:40:42.526-00:00",
+ resolvedValue: 100,
+ unit: null,
+ },
+ {
+ id: "ef79ae43-3f3f-8008-8006-98a5e75c0299",
+ name: "dimension-4",
+ type: "dimensions",
+ value: "500",
+ description: "",
+ "modified-at": "2025-08-04T14:40:53.104-00:00",
+ resolvedValue: 500,
+ unit: null,
+ },
+ ],
+ spacing: [
+ {
+ id: "ef79ae43-3f3f-8008-8006-98a5f1c64d5a",
+ name: "spacing-1",
+ type: "spacing",
+ value: "32",
+ description: "",
+ "modified-at": "2025-08-04T14:41:03.769-00:00",
+ resolvedValue: 32,
+ unit: "px",
+ },
+ ],
+ },
+ },
+ render: ({ ...args }) => ,
+};
diff --git a/frontend/src/app/main/ui/ds/controls/select.cljs b/frontend/src/app/main/ui/ds/controls/select.cljs
index f324e605ef..d5c91a3b81 100644
--- a/frontend/src/app/main/ui/ds/controls/select.cljs
+++ b/frontend/src/app/main/ui/ds/controls/select.cljs
@@ -28,7 +28,6 @@
(let [option (get-option options default)]
(get option :id)))
-
;; Also used in combobox
(defn handle-focus-change
[options focused* new-index nodes]
diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.cljs b/frontend/src/app/main/ui/ds/controls/shared/option.cljs
new file mode 100644
index 0000000000..d4cc48f76d
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/shared/option.cljs
@@ -0,0 +1,69 @@
+
+;; 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.ds.controls.shared.option
+ (:require-macros
+ [app.main.style :as stl])
+ (:require
+ [app.main.ui.ds.foundations.assets.icon :as i]
+ [rumext.v2 :as mf]))
+
+(def ^:private schema:option
+ "A schema for option* component props"
+ [:and
+ [:map {:title "option"}
+ [:id :string]
+ [:icon {:optional true} [:maybe :string]]
+ [:selected {:optional true} :boolean]
+ [:focused {:optional true} :boolean]
+ [:dimmed {:optional true} :boolean]
+ [:label {:optional true} :string]
+ [:aria-label {:optional true} [:maybe :string]]
+ [:on-click {:optional true} fn?]]
+ [:fn {:error/message "invalid data: missing required props"}
+ (fn [props]
+ (or (and (contains? props :icon)
+ (or (contains? props :label)
+ (contains? props :aria-label)))
+ (contains? props :label)))]])
+
+(mf/defc option*
+ {::mf/schema schema:option}
+ [{:keys [id ref label icon aria-label on-click selected focused dimmed] :rest props}]
+ (let [class (stl/css-case :option true
+ :option-with-icon (some? icon)
+ :option-selected selected
+ :option-current focused)]
+
+ [:li {:value id
+ :class class
+ :aria-selected selected
+ :ref ref
+ :role "option"
+ :id id
+ :on-click on-click
+ :data-id id
+ :data-testid "dropdown-option"}
+
+ (when (some? icon)
+ [:> i/icon*
+ {:icon-id icon
+ :size "s"
+ :class (stl/css :option-icon)
+ :aria-hidden (when label true)
+ :aria-label (when (not label) aria-label)}])
+
+ [:span {:class (stl/css-case :option-text true
+ :option-text-dimmed dimmed)}
+ label]
+
+ (when ^boolean selected
+ [:> i/icon*
+ {:icon-id i/tick
+ :size "s"
+ :class (stl/css :option-check)
+ :aria-hidden (when ^boolean label true)}])]))
diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.scss b/frontend/src/app/main/ui/ds/controls/shared/option.scss
new file mode 100644
index 0000000000..9720ad3960
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/shared/option.scss
@@ -0,0 +1,65 @@
+// 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 "../../_borders.scss" as *;
+@use "../../_sizes.scss" as *;
+@use "../../typography.scss" as *;
+
+.option {
+ --options-fg-color: var(--color-foreground-primary);
+ --options-bg-color: unset;
+ --options-empty: var(--color-canvas);
+
+ display: grid;
+ align-items: center;
+ justify-items: start;
+ grid-template-columns: 1fr auto;
+ gap: var(--sp-xs);
+ width: 100%;
+ height: $sz-32;
+ padding: var(--sp-s);
+ border-radius: $br-8;
+ outline: $b-1 solid var(--options-outline-color);
+ outline-offset: calc(-1 * $b-1);
+ background-color: var(--options-bg-color);
+ color: var(--options-fg-color);
+
+ &:hover,
+ &[aria-selected="true"] {
+ --options-bg-color: var(--color-background-quaternary);
+ }
+}
+
+.option-with-icon {
+ grid-template-columns: auto 1fr auto;
+}
+
+.option-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+ min-width: 0;
+ padding-inline-start: var(--sp-xxs);
+}
+
+.option-text-dimmed {
+ color: var(--options-empty);
+}
+
+.option-icon {
+ color: var(--options-icon-fg-color);
+}
+
+.option-current {
+ --options-outline-color: var(--color-accent-primary);
+ outline: $b-1 solid var(--options-outline-color);
+}
+
+.option-selected {
+ --options-fg-color: var(--color-accent-primary);
+ --options-icon-fg-color: var(--color-accent-primary);
+}
diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs
index 36be6a9370..4d6de219e2 100644
--- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs
+++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs
@@ -8,6 +8,10 @@
(:require-macros
[app.main.style :as stl])
(:require
+ [app.common.data :as d]
+ [app.common.weak :refer [weak-key]]
+ [app.main.ui.ds.controls.shared.option :refer [option*]]
+ [app.main.ui.ds.controls.shared.token-option :refer [token-option*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@@ -17,27 +21,26 @@
[:fn {:error/message "invalid data: invalid icon"} #(contains? i/icon-list %)]])
(def schema:option
- [:and
- [:map {:title "option"}
- [:id :string]
- [:icon {:optional true} schema:icon-list]
- [:label {:optional true} :string]
- [:aria-label {:optional true} :string]]
- [:fn {:error/message "invalid data: missing required props"}
- (fn [option]
- (or (and (contains? option :icon)
- (or (contains? option :label)
- (contains? option :aria-label)))
- (contains? option :label)))]])
+ "A schema for the option data structure expected to receive on props
+ for the `options-dropdown*` component."
+ [:map
+ [:id {:optional true} :string]
+ [:resolved-value {:optional true}
+ [:or :int :string]]
+ [:name {:optional true} :string]
+ [:icon {:optional true} schema:icon-list]
+ [:label {:optional true} :string]
+ [:aria-label {:optional true} :string]])
(def ^:private schema:options-dropdown
[:map
[:ref {:optional true} fn?]
[:on-click fn?]
[:options [:vector schema:option]]
- [:selected :any]
+ [:selected {:optional true} :any]
[:focused {:optional true} :any]
- [:empty-to-end {:optional true} :boolean]])
+ [:empty-to-end {:optional true} [:maybe :boolean]]
+ [:align {:optional true} [:maybe [:enum :left :right]]]])
(def ^:private
xf:filter-blank-id
@@ -47,46 +50,66 @@
xf:filter-non-blank-id
(remove #(str/blank? (get % :id))))
-(mf/defc option*
- {::mf/private true}
- [{:keys [id ref label icon aria-label on-click selected focused] :rest props}]
- (let [class (stl/css-case :option true
- :option-with-icon (some? icon)
- :option-selected selected
- :option-current focused)]
- [:li {:value id
- :class class
- :aria-selected selected
- :ref ref
- :role "option"
- :id id
- :on-click on-click
- :data-id id
- :data-testid "dropdown-option"}
+(defn- render-option
+ [option ref on-click selected focused]
+ (let [id (get option :id)
+ name (get option :name)
+ type (get option :type)]
- (when (some? icon)
- [:> i/icon*
- {:icon-id icon
- :size "s"
- :class (stl/css :option-icon)
- :aria-hidden (when label true)
- :aria-label (when (not label) aria-label)}])
+ (mf/html
+ (case type
+ :group
+ [:li {:class (stl/css :group-option)
+ :key (weak-key option)}
+ [:> i/icon*
+ {:icon-id i/arrow-down
+ :size "m"
+ :class (stl/css :option-check)
+ :aria-hidden (when name true)}]
+ (d/name name)]
- [:span {:class (stl/css :option-text)} label]
+ :separator
+ [:hr {:key (weak-key option) :class (stl/css :option-separator)}]
+
+ :empty
+ [:li {:key (weak-key option) :class (stl/css :option-empty)}
+ (get option :label)]
+
+ ;; Token option
+ :token
+ [:> token-option* {:selected (= id selected)
+ :key (weak-key option)
+ :id id
+ :name name
+ :resolved (get option :resolved-value)
+ :ref ref
+ :focused (= id focused)
+ :on-click on-click}]
+
+ ;; Normal option
+ [:> option* {:selected (= id selected)
+ :key (weak-key option)
+ :id id
+ :label (get option :label)
+ :aria-label (get option :aria-label)
+ :icon (get option :icon)
+ :ref ref
+ :focused (= id focused)
+ :dimmed false
+ :on-click on-click}]))))
- (when selected
- [:> i/icon*
- {:icon-id i/tick
- :size "s"
- :class (stl/css :option-check)
- :aria-hidden (when label true)}])]))
(mf/defc options-dropdown*
{::mf/schema schema:options-dropdown}
- [{:keys [ref on-click options selected focused empty-to-end] :rest props}]
- (let [props
+ [{:keys [ref on-click options selected focused empty-to-end align] :rest props}]
+ (let [align
+ (d/nilv align :left)
+
+ props
(mf/spread-props props
- {:class (stl/css :option-list)
+ {:class (stl/css-case :option-list true
+ :left-align (= align :left)
+ :right-align (= align :right))
:tab-index "-1"
:role "listbox"})
@@ -103,19 +126,7 @@
[:> :ul props
(for [option options]
- (let [id (get option :id)
- label (get option :label)
- aria-label (get option :aria-label)
- icon (get option :icon)]
- [:> option* {:selected (= id selected)
- :key id
- :id id
- :label label
- :icon icon
- :aria-label aria-label
- :ref ref
- :focused (= id focused)
- :on-click on-click}]))
+ (render-option option ref on-click selected focused))
(when (seq options-blank)
[:*
@@ -123,16 +134,4 @@
[:hr {:class (stl/css :option-separator)}])
(for [option options-blank]
- (let [id (get option :id)
- label (get option :label)
- aria-label (get option :aria-label)
- icon (get option :icon)]
- [:> option* {:selected (= id selected)
- :key id
- :id id
- :label label
- :icon icon
- :aria-label aria-label
- :ref ref
- :focused (= id focused)
- :on-click on-click}]))])]))
+ (render-option option ref on-click selected focused))])]))
diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss
index 978f34e9e3..7b98d05ccc 100644
--- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss
+++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss
@@ -13,12 +13,10 @@
--options-dropdown-bg-color: var(--color-background-tertiary);
--options-dropdown-outline-color: none;
--options-dropdown-border-color: var(--color-background-quaternary);
- --options-dropdown-empty: var(--color-canvas);
position: absolute;
- right: 0;
top: $sz-36;
- width: 100%;
+ width: var(--dropdown-width, 100%);
background-color: var(--options-dropdown-bg-color);
border-radius: $br-8;
border: $b-1 solid var(--options-dropdown-border-color);
@@ -30,55 +28,12 @@
z-index: var(--z-index-dropdown);
}
-.option {
- --options-dropdown-fg-color: var(--color-foreground-primary);
- --options-dropdown-bg-color: unset;
-
- display: grid;
- align-items: center;
- justify-items: start;
- grid-template-columns: 1fr auto;
- gap: var(--sp-xs);
- width: 100%;
- height: $sz-32;
- padding: var(--sp-s);
- border-radius: $br-8;
- outline: $b-1 solid var(--options-dropdown-outline-color);
- outline-offset: -1px;
- background-color: var(--options-dropdown-bg-color);
- color: var(--options-dropdown-fg-color);
-
- &:hover,
- &[aria-selected="true"] {
- --options-dropdown-bg-color: var(--color-background-quaternary);
- }
+.left-align {
+ left: 0;
}
-.option-with-icon {
- grid-template-columns: auto 1fr auto;
-}
-
-.option-text {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- width: 100%;
- min-width: 0;
- padding-inline-start: var(--sp-xxs);
-}
-
-.option-icon {
- color: var(--options-dropdown-icon-fg-color);
-}
-
-.option-current {
- --options-dropdown-outline-color: var(--color-accent-primary);
- outline: $b-1 solid var(--options-dropdown-outline-color);
-}
-
-.option-selected {
- --options-dropdown-fg-color: var(--color-accent-primary);
- --options-dropdown-icon-fg-color: var(--color-accent-primary);
+.right-align {
+ right: 0;
}
.option-separator {
@@ -86,3 +41,20 @@
margin-top: var(--sp-xs);
margin-bottom: var(--sp-xs);
}
+
+.group-option,
+.option-empty {
+ @include use-typography("body-small");
+ display: flex;
+ align-items: center;
+ gap: var(--sp-xs);
+ color: var(--color-foreground-secondary);
+ padding-inline: var(--sp-s);
+ height: var(--sp-xxxl);
+}
+
+.option-empty {
+ justify-content: center;
+ text-align: center;
+ padding: 0 40px;
+}
diff --git a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs
new file mode 100644
index 0000000000..2760eeadf6
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs
@@ -0,0 +1,61 @@
+;; 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.ds.controls.shared.token-option
+ (:require-macros
+ [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
+ [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]]
+ [rumext.v2 :as mf]))
+
+(def ^:private schema:token-option
+ [:map
+ [:id {:optiona true} :string]
+ [:ref some?]
+ [:resolved {:optional true} [:or :number :string]]
+ [:name {:optional true} :string]
+ [:on-click {:optional true} fn?]
+ [:selected {:optional true} :boolean]
+ [:focused {:optional true} :boolean]
+ [:focused {:optional true} :boolean]])
+
+(mf/defc token-option*
+ {::mf/schema schema:token-option}
+ [{:keys [id name on-click selected ref focused resolved] :rest props}]
+ (let [internal-id (mf/use-id)
+ id (d/nilv id internal-id)]
+ [:li {:value id
+ :class (stl/css-case :token-option true
+ :option-with-pill true
+ :option-selected-token selected
+ :option-current focused)
+ :aria-selected selected
+ :ref ref
+ :role "option"
+ :id id
+ :on-click on-click
+ :data-id id
+ :data-testid "dropdown-option"}
+
+ (if selected
+ [:> icon*
+ {:icon-id i/tick
+ :size "s"
+ :class (stl/css :option-check)
+ :aria-hidden (when name true)}]
+ [:span {:class (stl/css :icon-placeholder)}])
+ [:> tooltip* {:content name
+ :id (dm/str id "-name")
+ :class (stl/css :option-text)}
+ ;; Add ellipsis
+ [:span {:aria-labelledby (dm/str id "-name")}
+ name]]
+ (when resolved
+ [:> :span {:class (stl/css :option-pill)}
+ resolved])]))
diff --git a/frontend/src/app/main/ui/ds/controls/shared/token_option.scss b/frontend/src/app/main/ui/ds/controls/shared/token_option.scss
new file mode 100644
index 0000000000..e168da53a5
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/shared/token_option.scss
@@ -0,0 +1,77 @@
+// 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 "../../_borders.scss" as *;
+@use "../../_sizes.scss" as *;
+@use "../../typography.scss" as *;
+
+.token-option {
+ --token-options-fg-color: var(--color-foreground-primary);
+ --token-options-bg-color: unset;
+ --token-options-empty: var(--color-canvas);
+ @include use-typography("body-small");
+ display: grid;
+ align-items: center;
+ justify-items: start;
+ grid-template-columns: 1fr auto;
+ gap: $sz-6;
+ width: 100%;
+ height: $sz-32;
+ padding: var(--sp-s);
+ border-radius: $br-8;
+ outline: $b-1 solid var(--token-options-outline-color);
+ outline-offset: calc(-1 * $b-1);
+ background-color: var(--token-options-bg-color);
+ color: var(--token-options-fg-color);
+ overflow: hidden;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ &:hover,
+ &[aria-selected="true"] {
+ --token-options-bg-color: var(--color-background-quaternary);
+ }
+}
+
+.option-with-pill {
+ grid-template-columns: auto 1fr auto;
+}
+
+.option-text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ width: 100%;
+ padding-inline-start: var(--sp-xxs);
+}
+
+.option-current {
+ --token-options-outline-color: var(--color-accent-primary);
+ outline: $b-1 solid var(--token-options-outline-color);
+}
+
+.option-pill {
+ @include use-typography("code-font");
+ color: var(--color-foreground-secondary);
+ background-color: var(--color-background-primary);
+ border-radius: $br-6;
+ padding: var(--sp-xxs) var(--sp-xs);
+}
+
+.option-selected-token {
+ --token-options-fg-color: var(--color-foreground-primary);
+ --token-options-icon-fg-color: var(--color-accent-primary);
+}
+
+.icon-placeholder {
+ inline-size: var(--sp-l);
+}
+
+.option-check {
+ color: var(--token-options-icon-fg-color);
+ min-width: var(--sp-l);
+}
diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs b/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs
index 65913d9e09..4f546ade66 100644
--- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs
+++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs
@@ -32,7 +32,10 @@
(mf/defc input-field*
{::mf/forward-ref true
::mf/schema schema:input-field}
- [{:keys [id icon has-hint hint-type class type max-length variant slot-start slot-end] :rest props} ref]
+ [{:keys [id icon class type
+ has-hint hint-type
+ max-length variant
+ slot-start slot-end] :rest props} ref]
(let [input-ref (mf/use-ref)
type (d/nilv type "text")
variant (d/nilv variant "dense")
diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss
index ca2eb27785..f58e1aaf85 100644
--- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss
+++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss
@@ -25,7 +25,7 @@
background: var(--input-bg-color);
border-radius: $br-8;
- padding: 0 var(--sp-s);
+ padding: 0 var(--sp-xs);
outline: $b-1 solid var(--input-outline-color);
&:hover {
diff --git a/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs b/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs
new file mode 100644
index 0000000000..5b02996bc2
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs
@@ -0,0 +1,85 @@
+;; 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.ds.controls.utilities.token-field
+ (:require-macros [app.main.style :as stl])
+ (:require
+ [app.common.data :as d]
+ [app.common.data.macros :as dm]
+ [app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
+ [app.main.ui.ds.tooltip :refer [tooltip*]]
+ [app.util.dom :as dom]
+ [app.util.i18n :refer [tr]]
+ [rumext.v2 :as mf]))
+
+
+(def ^:private schema:token-field
+ [:map
+ [:id {:optional true} [:maybe :string]]
+ [:label {:optional true} [:maybe :string]]
+ [:value :any]
+ [:disabled {:optional true} :boolean]
+ [:slot-start {:optional true} [:maybe some?]]
+ [:on-click {:optional true} fn?]
+ [:on-token-key-down fn?]
+ [:on-blur {:optional true} fn?]
+ [:detach-token fn?]])
+
+(mf/defc token-field*
+ {::mf/schema schema:token-field}
+ [{:keys [id label value slot-start disabled
+ on-click on-token-key-down on-blur detach-token
+ token-wrapper-ref token-detach-btn-ref]}]
+ (let [set-active? (some? id)
+ content (if set-active?
+ label
+ (tr "ds.inputs.token-field.no-active-token-option"))
+ default-id (mf/use-id)
+ id (d/nilv id default-id)
+
+ focus-wrapper
+ (mf/use-fn
+ (mf/deps disabled)
+ (fn [event]
+ (when-not ^boolean disabled
+ (dom/prevent-default event)
+ (dom/focus! (mf/ref-val token-wrapper-ref)))))
+
+ class
+ (stl/css-case :token-field true
+ :with-icon (some? slot-start)
+ :token-field-disabled disabled)]
+
+ [:div {:class class
+ :on-click focus-wrapper
+ :disabled disabled
+ :on-key-down on-token-key-down
+ :ref token-wrapper-ref
+ :on-blur on-blur
+ :tab-index (if disabled -1 0)}
+
+ (when (some? slot-start) slot-start)
+
+ [:> tooltip* {:content content
+ :id (dm/str id "-pill")}
+ [:button {:on-click on-click
+ :class (stl/css-case :pill true
+ :no-set-pill (not set-active?)
+ :pill-disabled disabled)
+ :disabled disabled
+ :aria-labelledby (dm/str id "-pill")
+ :on-key-down on-token-key-down}
+ value
+ (when-not set-active?
+ [:div {:class (stl/css :pill-dot)}])]]
+
+ (when-not ^boolean disabled
+ [:> icon-button* {:variant "action"
+ :class (stl/css :invisible-button)
+ :icon "broken-link"
+ :ref token-detach-btn-ref
+ :aria-label (tr "ds.inputs.token-field.detach-token")
+ :on-click detach-token}])]))
diff --git a/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss b/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss
new file mode 100644
index 0000000000..36f358eaca
--- /dev/null
+++ b/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss
@@ -0,0 +1,126 @@
+// 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 "../../_borders.scss" as *;
+@use "../../_sizes.scss" as *;
+@use "../../typography.scss" as t;
+@use "../../colors.scss" as *;
+
+.token-field {
+ --token-field-bg-color: var(--color-background-tertiary);
+ --token-field-fg-color: var(--color-foreground-primary);
+ --token-field-icon-color: var(--color-foreground-secondary);
+ --token-field-outline-color: none;
+ --token-field-height: var(--sp-xxxl);
+ --token-field-margin: unset;
+
+ display: grid;
+ grid-template-columns: 1fr auto;
+ column-gap: var(--sp-xs);
+ align-items: center;
+ position: relative;
+ inline-size: 100%;
+ background: var(--token-field-bg-color);
+ border-radius: $br-8;
+ padding: var(--sp-xs);
+ outline: $b-1 solid var(--token-field-outline-color);
+
+ &:hover {
+ --token-field-bg-color: var(--color-background-quaternary);
+ }
+
+ &:focus {
+ --token-field-bg-color: var(--color-background-primary);
+ --token-field-outline-color: var(--color-accent-primary);
+ }
+}
+
+.with-icon {
+ grid-template-columns: auto 1fr auto;
+}
+
+.token-field-disabled {
+ user-select: none;
+ --token-field-bg-color: var(--color-background-primary);
+ --token-field-outline-color: var(--color-background-quaternary);
+ &:hover {
+ --token-field-bg-color: var(--color-background-primary);
+ --token-field-outline-color: var(--color-background-quaternary);
+ }
+}
+
+.pill {
+ --pill-border-color: var(--color-token-border);
+ --pill-bg-color: var(--color-background-tertiary);
+ --pill-fg-color: var(--color-token-foreground);
+ @include t.use-typography("code-font");
+ height: var(--sp-xxl);
+ width: fit-content;
+ background: var(--pill-bg-color);
+ cursor: pointer;
+ border: $b-1 solid var(--pill-border-color);
+ color: var(--pill-fg-color);
+ border-radius: $br-6;
+ padding-inline: $sz-6;
+ &:hover {
+ --pill-bg-color: var(--color-token-background);
+ --pill-fg-color: var(--color-foreground-primary);
+ --pill-border-color: var(--color-token-foreground);
+ }
+ &:focus-visible {
+ --pill-bg-color: var(--color-token-background);
+ --pill-fg-color: var(--color-foreground-primary);
+ --pill-border-color: var(--color-accent-primary);
+ outline: none;
+ }
+}
+
+.pill-disabled {
+ user-select: none;
+ --pill-bg-color: none;
+ --pill-fg-color: var(--color-foreground-secondary);
+ --pill-border-color: var(--color-token-border);
+ &:hover {
+ --pill-bg-color: none;
+ --pill-fg-color: var(--color-foreground-secondary);
+ --pill-border-color: var(--color-token-border);
+ }
+}
+
+.no-set-pill {
+ --pill-bg-color: none;
+ --pill-fg-color: var(--color-foreground-secondary);
+ --pill-border-color: var(--color-token-border);
+ position: relative;
+ &:hover {
+ --pill-bg-color: none;
+ --pill-fg-color: var(--color-foreground-secondary);
+ --pill-border-color: var(--color-token-border);
+ }
+}
+
+.pill-dot {
+ width: $sz-6;
+ height: $sz-6;
+ outline: var(--sp-xxs) solid var(--color-background-primary);
+ border-radius: 50%;
+ background-color: var(--color-foreground-error);
+ margin-left: var(--sp-xs);
+ position: absolute;
+ right: 0;
+ top: 0;
+}
+
+.invisible-button {
+ opacity: var(--opacity-button);
+
+ &:hover {
+ --opacity-button: 1;
+ }
+ &:focus {
+ --opacity-button: 1;
+ }
+}
diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
index bbdad30a86..f4b0474c1f 100644
--- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
+++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
@@ -280,6 +280,7 @@
(def ^:icon-id text-uppercase "text-uppercase")
(def ^:icon-id thumbnail "thumbnail")
(def ^:icon-id tick "tick")
+(def ^:icon-id tokens "tokens")
(def ^:icon-id to-corner "to-corner")
(def ^:icon-id to-curve "to-curve")
(def ^:icon-id tree "tree")
diff --git a/frontend/src/app/util/keyboard.cljs b/frontend/src/app/util/keyboard.cljs
index 27ff1493a8..5ed595a97c 100644
--- a/frontend/src/app/util/keyboard.cljs
+++ b/frontend/src/app/util/keyboard.cljs
@@ -38,9 +38,11 @@
(true? (.-editing event)))
(defn is-key?
- [^string key]
- (fn [^KeyboardEvent e]
- (= (.-key e) key)))
+ ([^string key]
+ (fn [^KeyboardEvent event]
+ (= (.-key event) key)))
+ ([^KeyboardEvent event ^string key]
+ (= (.-key event) key)))
(defn is-key-ignore-case?
[^string key]
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index aff5753774..e371d5a12c 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -3610,6 +3610,9 @@ msgstr "Detach"
msgid "settings.multiple"
msgstr "Mixed"
+msgid "labels.mixed-values"
+msgstr "Mixed"
+
#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:274
msgid "settings.remove-color"
msgstr "Remove color"
@@ -7641,6 +7644,26 @@ msgstr "Invalid value: % is not allowed."
msgid "workspace.tokens.warning-name-change"
msgstr "Renaming this token will break any reference to its old name."
+#: src/app/main/ui/ds/controls/numeric_input.cljs
+msgid "ds.inputs.token-field.no-active-token-option"
+msgstr "This token is not available in any active set or theme."
+
+#: src/app/main/ui/ds/controls/numeric_input.cljs
+msgid "ds.inputs.token-field.detach-token"
+msgstr "Detach token"
+
+#: src/app/main/ui/ds/controls/numeric_input.cljs
+msgid "ds.inputs.numeric-input.no-applicable-tokens"
+msgstr "No applicable tokens in active sets or themes."
+
+#: src/app/main/ui/ds/controls/numeric_input.cljs
+msgid "ds.inputs.numeric-input.no-matches"
+msgstr "No matches found."
+
+#: src/app/main/ui/ds/controls/numeric_input.cljs
+msgid "ds.inputs.numeric-input.open-token-list-dropdown"
+msgstr "Open token list"
+
#: src/app/main/ui/workspace/sidebar.cljs:137, src/app/main/ui/workspace/sidebar.cljs:146
msgid "workspace.toolbar.assets"
msgstr "Assets"
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index ec5c47a6d8..6161352f70 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -3613,6 +3613,9 @@ msgstr "Desvincular"
msgid "settings.multiple"
msgstr "Varios"
+msgid "labels.mixed-values"
+msgstr "Varios"
+
#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:274
msgid "settings.remove-color"
msgstr "Eliminar color"
@@ -7530,6 +7533,26 @@ msgstr "Valor no válido: No se permiten unidades."
msgid "workspace.tokens.warning-name-change"
msgstr "Al renombrar este token se romperán las referencias al nombre anterior"
+#: src/app/main/ui/ds/controls/numeric_input.cljs
+msgid "ds.inputs.token-field.no-active-token-option"
+msgstr "Este token no está disponible en ningún set ni tema activo."
+
+#: src/app/main/ui/ds/controls/numeric_input.cljs
+msgid "ds.inputs.token-field.detach-token"
+msgstr "Desvincular token"
+
+#: src/app/main/ui/ds/controls/numeric_input.cljs
+msgid "ds.inputs.numeric-input.no-applicable-tokens"
+msgstr "No hay tokens aplicables en sets o temas activos."
+
+#: src/app/main/ui/ds/controls/numeric_input.cljs
+msgid "ds.inputs.numeric-input.no-matches"
+msgstr "No hay coincidencias"
+
+#: src/app/main/ui/ds/controls/numeric_input.cljs
+msgid "ds.inputs.numeric-input.open-token-list-dropdown"
+msgstr "Abrir lista de tokens"
+
#: src/app/main/ui/workspace/sidebar.cljs:137, src/app/main/ui/workspace/sidebar.cljs:146
msgid "workspace.toolbar.assets"
msgstr "Recursos"