mirror of
https://github.com/penpot/penpot.git
synced 2026-04-28 04:38:14 +00:00
Merge pull request #6635 from penpot/eva-add-numeric-input
✨ Add numeric input component
This commit is contained in:
commit
9563d1b1f6
@ -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?
|
||||
|
||||
@ -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))
|
||||
|
||||
59
common/src/app/common/weak.cljs
Normal file
59
common/src/app/common/weak.cljs
Normal 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)))))
|
||||
|
||||
|
||||
130
common/src/app/common/weak/impl_weak_map.js
Normal file
130
common/src/app/common/weak/impl_weak_map.js
Normal 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 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;
|
||||
}
|
||||
}
|
||||
54
common/src/app/common/weak/impl_weak_value_map.js
Normal file
54
common/src/app/common/weak/impl_weak_value_map.js
Normal 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 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);
|
||||
}
|
||||
}
|
||||
@ -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)))
|
||||
3
frontend/resources/images/icons/tokens.svg
Normal file
3
frontend/resources/images/icons/tokens.svg
Normal 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 |
@ -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})))
|
||||
|
||||
|
||||
@ -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*
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"]]]
|
||||
|
||||
677
frontend/src/app/main/ui/ds/controls/numeric_input.cljs
Normal file
677
frontend/src/app/main/ui/ds/controls/numeric_input.cljs
Normal 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}])]))
|
||||
11
frontend/src/app/main/ui/ds/controls/numeric_input.mdx
Normal file
11
frontend/src/app/main/ui/ds/controls/numeric_input.mdx
Normal 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
|
||||
42
frontend/src/app/main/ui/ds/controls/numeric_input.scss
Normal file
42
frontend/src/app/main/ui/ds/controls/numeric_input.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
121
frontend/src/app/main/ui/ds/controls/numeric_input.stories.jsx
Normal file
121
frontend/src/app/main/ui/ds/controls/numeric_input.stories.jsx
Normal 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} />,
|
||||
};
|
||||
@ -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]
|
||||
|
||||
69
frontend/src/app/main/ui/ds/controls/shared/option.cljs
Normal file
69
frontend/src/app/main/ui/ds/controls/shared/option.cljs
Normal 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)}])]))
|
||||
65
frontend/src/app/main/ui/ds/controls/shared/option.scss
Normal file
65
frontend/src/app/main/ui/ds/controls/shared/option.scss
Normal 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);
|
||||
}
|
||||
@ -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))])]))
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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])]))
|
||||
@ -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);
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}])]))
|
||||
126
frontend/src/app/main/ui/ds/controls/utilities/token_field.scss
Normal file
126
frontend/src/app/main/ui/ds/controls/utilities/token_field.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user