Merge pull request #6635 from penpot/eva-add-numeric-input

 Add numeric input component
This commit is contained in:
Andrey Antukh 2025-08-25 19:42:47 +02:00 committed by GitHub
commit 9563d1b1f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1759 additions and 184 deletions

View File

@ -7,7 +7,7 @@
(ns app.common.types.fills.impl
(:require
#?(:clj [clojure.data.json :as json])
#?(:cljs [app.common.weak-map :as weak-map])
#?(:cljs [app.common.weak :as weak])
[app.common.buffer :as buf]
[app.common.data :as d]
[app.common.data.macros :as dm]
@ -443,7 +443,7 @@
:code :invalid-fill
:hint "found invalid fill on encoding fills to binary format")))))
#?(:cljs (Fills. total dbuffer mbuffer image-ids (weak-map/create) nil)
#?(:cljs (Fills. total dbuffer mbuffer image-ids (weak/create-weak-value-map) nil)
:clj (Fills. total dbuffer mbuffer nil))))))
(defn fills?

View File

@ -12,7 +12,7 @@
(:require
#?(:clj [app.common.fressian :as fres])
#?(:clj [clojure.data.json :as json])
#?(:cljs [app.common.weak-map :as weak-map])
#?(:cljs [app.common.weak :as weak])
[app.common.buffer :as buf]
[app.common.data :as d]
[app.common.data.macros :as dm]
@ -379,7 +379,7 @@
(-transform [this m]
(let [buffer (buf/clone buffer)]
(impl-transform buffer m size)
(PathData. size buffer (weak-map/create) nil)))
(PathData. size buffer (weak/create-weak-value-map) nil)))
(-walk [_ f initial]
(impl-walk buffer f initial size))
@ -600,14 +600,14 @@
count (long (/ size SEGMENT-U8-SIZE))]
(PathData. count
(js/DataView. buffer)
(weak-map/create)
(weak/create-weak-value-map)
nil))
(instance? js/DataView buffer)
(let [buffer' (.-buffer ^js/DataView buffer)
size (.-byteLength ^js/ArrayBuffer buffer')
count (long (/ size SEGMENT-U8-SIZE))]
(PathData. count buffer (weak-map/create) nil))
(PathData. count buffer (weak/create-weak-value-map) nil))
(instance? js/Uint8Array buffer)
(from-bytes (.-buffer buffer))

View File

@ -0,0 +1,59 @@
;; 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.common.weak
"A collection of helpers for work with weak references and weak
data structures on JS runtime."
(:refer-clojure :exclude [memoize])
(:require
["./weak/impl_weak_map.js" :as wm]
["./weak/impl_weak_value_map.js" :as wvm]))
(defn create-weak-value-map
[]
(new wvm/WeakValueMap.))
(defn create-weak-map
[]
(new wm/WeakEqMap #js {:hash hash :equals =}))
(def ^:private state (new js/WeakMap))
(def ^:private global-counter 0)
(defn weak-key
"A simple helper that returns a stable key string for an object
while that object remains in memory and is not collected by the GC.
Mainly used for assign temporal IDs/keys for react children
elements when the element has no specific id."
[o]
(let [key (.get ^js/WeakMap state o)]
(if (some? key)
key
(let [key (str "weak-key" (js* "~{}++" global-counter))]
(.set ^js/WeakMap state o key)
key))))
(defn memoize
"Returns a memoized version of a referentially transparent function. The
memoized version of the function keeps a cache of the mapping from arguments
to results and, when calls with the same arguments are repeated often, has
higher performance at the expense of higher memory use.
The main difference with clojure.core/memoize, is that this function
uses weak-map, so cache is cleared once GC is passed and cached keys
are collected"
[f]
(let [mem (create-weak-map)]
(fn [& args]
(let [v (.get mem args)]
(if (undefined? v)
(let [ret (apply f args)]
(.set ^js mem args ret)
ret)
v)))))

View File

@ -0,0 +1,130 @@
/**
* 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 strict";
export class WeakEqMap {
constructor({ equals, hash }) {
this._equals = equals;
this._hash = hash;
// buckets: Map<hash, Array<Entry>>
this._buckets = new Map();
// Token -> (hash) so the FR cleanup can find & remove dead entries
// We store {hash, token} as heldValue for FinalizationRegistry
this._fr = new FinalizationRegistry(({ hash, token }) => {
const bucket = this._buckets.get(hash);
if (!bucket) return;
// Remove the entry whose token matches or whose key has been collected
let i = 0;
while (i < bucket.length) {
const e = bucket[i];
const dead = e.keyRef.deref() === undefined;
if (dead || e.token === token) {
// swap-remove for O(1)
bucket[i] = bucket[bucket.length - 1];
bucket.pop();
continue;
}
i++;
}
if (bucket.length === 0) this._buckets.delete(hash);
});
}
_getBucket(hash) {
let b = this._buckets.get(hash);
if (!b) {
b = [];
this._buckets.set(hash, b);
}
return b;
}
_findEntry(bucket, key) {
// Sweep dead entries opportunistically
let i = 0;
let found = null;
while (i < bucket.length) {
const e = bucket[i];
const k = e.keyRef.deref();
if (k === undefined) {
bucket[i] = bucket[bucket.length - 1];
bucket.pop();
continue;
}
if (found === null && this._equals(k, key)) {
found = e;
}
i++;
}
return found;
}
set(key, value) {
if (key === null || (typeof key !== 'object' && typeof key !== 'function')) {
throw new TypeError('WeakEqMap keys must be objects (like WeakMap).');
}
const hash = this._hash(key);
const bucket = this._getBucket(hash);
const existing = this._findEntry(bucket, key);
if (existing) {
existing.value = value;
return this;
}
const token = Object.create(null); // unique identity
const entry = { keyRef: new WeakRef(key), value, token };
bucket.push(entry);
// Register for cleanup when key is GCd
this._fr.register(key, { hash, token }, entry);
return this;
}
get(key) {
const hash = this._hash(key);
const bucket = this._buckets.get(hash);
if (!bucket) return undefined;
const e = this._findEntry(bucket, key);
return e ? e.value : undefined;
}
has(key) {
const hash = this._hash(key);
const bucket = this._buckets.get(hash);
if (!bucket) return false;
return !!this._findEntry(bucket, key);
}
delete(key) {
const hash = this._hash(key);
const bucket = this._buckets.get(hash);
if (!bucket) return false;
let i = 0;
while (i < bucket.length) {
const e = bucket[i];
const k = e.keyRef.deref();
if (k === undefined) {
// clean dead
bucket[i] = bucket[bucket.length - 1];
bucket.pop();
continue;
}
if (this._equals(k, key)) {
// Unregister and remove
this._fr.unregister(e); // unregister via the registration "unregisterToken" = entry
bucket[i] = bucket[bucket.length - 1];
bucket.pop();
if (bucket.length === 0) this._buckets.delete(hash);
return true;
}
i++;
}
if (bucket.length === 0) this._buckets.delete(hash);
return false;
}
}

View File

@ -0,0 +1,54 @@
/**
* 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 strict";
export class WeakValueMap {
constructor() {
this._map = new Map(); // key -> {ref, token}
this._registry = new FinalizationRegistry((token) => {
this._map.delete(token.key);
});
}
set(key, value) {
const ref = new WeakRef(value);
const token = { key };
this._map.set(key, { ref, token });
this._registry.register(value, token, token);
return this;
}
get(key) {
const entry = this._map.get(key);
if (!entry) return undefined;
const value = entry.ref.deref();
if (value === undefined) {
// Value was GCd, clean up
this._map.delete(key);
return undefined;
}
return value;
}
has(key) {
const entry = this._map.get(key);
if (!entry) return false;
if (entry.ref.deref() === undefined) {
this._map.delete(key);
return false;
}
return true;
}
delete(key) {
const entry = this._map.get(key);
if (!entry) return false;
this._registry.unregister(entry.token);
return this._map.delete(key);
}
}

View File

@ -1,29 +0,0 @@
;; 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.common.weak-map
"A value based weak-map implementation (CLJS/JS)")
(deftype ValueWeakMap [^js/Map data ^js/FinalizationRegistry registry]
Object
(clear [_]
(.clear data))
(delete [_ key]
(.delete data key))
(get [_ key]
(if-let [ref (.get data key)]
(.deref ^WeakRef ref)
nil))
(set [_ key val]
(.set data key (js/WeakRef. val))
(.register registry val key)
nil))
(defn create
[]
(let [data (js/Map.)
registry (js/FinalizationRegistry. #(.delete data %))]
(ValueWeakMap. data registry)))

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.994 5.422v5.157a.334.334 0 0 1-.172.29l-4.66 2.59a.333.333 0 0 1-.323 0l-4.661-2.59a.334.334 0 0 1-.172-.29V5.421c0-.121.066-.233.172-.292l4.66-2.588a.333.333 0 0 1 .323 0l4.661 2.588a.335.335 0 0 1 .172.293ZM8 8.5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Z" />
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

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

View File

@ -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*

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

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

View File

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

View File

@ -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";
<Meta title="Controls/Numeric Input" />
# Numeric Input

View File

@ -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;
}
}

View File

@ -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 }) => <NumericInput {...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 }) => <NumericInput {...args} />,
};

View File

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

View File

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

View File

@ -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);
}

View File

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

View File

@ -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;
}

View File

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

View File

@ -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);
}

View File

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

View File

@ -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 {

View File

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

View File

@ -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;
}
}

View File

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

View File

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

View File

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

View File

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