From 79786dde16aa7c7c63746b524b0ee7ec4d24dbcf Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 25 Aug 2025 14:18:32 +0200 Subject: [PATCH 1/2] :tada: Add helpers for work with weak references and weak data structs --- common/src/app/common/types/fills/impl.cljc | 4 +- common/src/app/common/types/path/impl.cljc | 8 +- common/src/app/common/weak.cljs | 59 ++++++++ common/src/app/common/weak/impl_weak_map.js | 130 ++++++++++++++++++ .../app/common/weak/impl_weak_value_map.js | 54 ++++++++ common/src/app/common/weak_map.cljs | 29 ---- 6 files changed, 249 insertions(+), 35 deletions(-) create mode 100644 common/src/app/common/weak.cljs create mode 100644 common/src/app/common/weak/impl_weak_map.js create mode 100644 common/src/app/common/weak/impl_weak_value_map.js delete mode 100644 common/src/app/common/weak_map.cljs diff --git a/common/src/app/common/types/fills/impl.cljc b/common/src/app/common/types/fills/impl.cljc index eca9d65156..30228a5a1c 100644 --- a/common/src/app/common/types/fills/impl.cljc +++ b/common/src/app/common/types/fills/impl.cljc @@ -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? diff --git a/common/src/app/common/types/path/impl.cljc b/common/src/app/common/types/path/impl.cljc index 0134e9abb1..1dfdf31593 100644 --- a/common/src/app/common/types/path/impl.cljc +++ b/common/src/app/common/types/path/impl.cljc @@ -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)) diff --git a/common/src/app/common/weak.cljs b/common/src/app/common/weak.cljs new file mode 100644 index 0000000000..58ae9aad72 --- /dev/null +++ b/common/src/app/common/weak.cljs @@ -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))))) + + diff --git a/common/src/app/common/weak/impl_weak_map.js b/common/src/app/common/weak/impl_weak_map.js new file mode 100644 index 0000000000..2379ea7e14 --- /dev/null +++ b/common/src/app/common/weak/impl_weak_map.js @@ -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> + 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 GC’d + 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; + } +} diff --git a/common/src/app/common/weak/impl_weak_value_map.js b/common/src/app/common/weak/impl_weak_value_map.js new file mode 100644 index 0000000000..85497839d1 --- /dev/null +++ b/common/src/app/common/weak/impl_weak_value_map.js @@ -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 GC’d, 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); + } +} diff --git a/common/src/app/common/weak_map.cljs b/common/src/app/common/weak_map.cljs deleted file mode 100644 index ef204974e1..0000000000 --- a/common/src/app/common/weak_map.cljs +++ /dev/null @@ -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))) From 33fc578f96672386555c105839c7f7b87814db7d Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 25 Aug 2025 12:09:14 +0200 Subject: [PATCH 2/2] :tada: Add numeric-input component to DS A new numeric-input impl compatible with tokens --- frontend/resources/images/icons/tokens.svg | 3 + .../data/workspace/tokens/library_edit.cljs | 3 +- frontend/src/app/main/ui/ds.cljs | 2 + frontend/src/app/main/ui/ds/_borders.scss | 1 + frontend/src/app/main/ui/ds/_sizes.scss | 1 + .../main/ui/ds/controls/combobox.stories.jsx | 29 +- .../src/app/main/ui/ds/controls/input.cljs | 6 +- .../main/ui/ds/controls/numeric_input.cljs | 677 ++++++++++++++++++ .../app/main/ui/ds/controls/numeric_input.mdx | 11 + .../main/ui/ds/controls/numeric_input.scss | 42 ++ .../ui/ds/controls/numeric_input.stories.jsx | 121 ++++ .../src/app/main/ui/ds/controls/select.cljs | 1 - .../main/ui/ds/controls/shared/option.cljs | 69 ++ .../main/ui/ds/controls/shared/option.scss | 65 ++ .../ds/controls/shared/options_dropdown.cljs | 145 ++-- .../ds/controls/shared/options_dropdown.scss | 72 +- .../ui/ds/controls/shared/token_option.cljs | 61 ++ .../ui/ds/controls/shared/token_option.scss | 77 ++ .../ui/ds/controls/utilities/input_field.cljs | 5 +- .../ui/ds/controls/utilities/input_field.scss | 2 +- .../ui/ds/controls/utilities/token_field.cljs | 85 +++ .../ui/ds/controls/utilities/token_field.scss | 126 ++++ .../main/ui/ds/foundations/assets/icon.cljs | 1 + frontend/src/app/util/keyboard.cljs | 8 +- frontend/translations/en.po | 23 + frontend/translations/es.po | 23 + 26 files changed, 1510 insertions(+), 149 deletions(-) create mode 100644 frontend/resources/images/icons/tokens.svg create mode 100644 frontend/src/app/main/ui/ds/controls/numeric_input.cljs create mode 100644 frontend/src/app/main/ui/ds/controls/numeric_input.mdx create mode 100644 frontend/src/app/main/ui/ds/controls/numeric_input.scss create mode 100644 frontend/src/app/main/ui/ds/controls/numeric_input.stories.jsx create mode 100644 frontend/src/app/main/ui/ds/controls/shared/option.cljs create mode 100644 frontend/src/app/main/ui/ds/controls/shared/option.scss create mode 100644 frontend/src/app/main/ui/ds/controls/shared/token_option.cljs create mode 100644 frontend/src/app/main/ui/ds/controls/shared/token_option.scss create mode 100644 frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs create mode 100644 frontend/src/app/main/ui/ds/controls/utilities/token_field.scss 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"