;; 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] :as i] [app.main.ui.formats :as fmt] [app.main.ui.hooks :as h] [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] [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] [app.util.timers :as ts] [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] (let [options (if (delay? options) (deref options) options)] (d/seek #(and (= :token (get % :type)) (= name (get % :name))) options))) (defn- get-token-op [tokens name] (let [tokens (if (delay? tokens) @tokens tokens) xform (filter #(= (:name %) name))] (reduce-kv (fn [result _ tokens] (into result xform tokens)) [] tokens))) (defn- clean-token-name [s] (some-> s (str/replace #"^\{" "") (str/replace #"\}$" ""))) (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 [options (if (delay? options) @options options) 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))) (defn- find-token-by-name [data name] (some (fn [tokens-data] (some #(when (= (:name %) name) %) tokens-data)) (vals data))) (def ^:private schema:icon [:and :string [:fn #(contains? icon-list %)]]) (def ^:private schema:numeric-input [:map [:id {:optional true} :string] [:class {:optional true} :string] [:inner-class {:optional true} :string] [:value {:optional true} [:maybe [:or :int :float :string [:= :multiple]]]] [:text-icon {:optional true} :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 [:or :int :float]]] [:max {:optional true} [:maybe [:or :int :float]]] [:max-length {:optional true} :int] [:step {:optional true} [:maybe [:or :int :float]]] [: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-change-start {:optional true} fn?] [:on-change-end {:optional true} fn?] [:on-blur {:optional true} fn?] [:on-focus {:optional true} fn?] [:on-detach {:optional true} fn?] [:property {:optional true} :string] [:tooltip-placement {:optional true} [:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]] [:align {:optional true} [:maybe [:enum :left :right]]]]) (mf/defc numeric-input* {::mf/schema schema:numeric-input} [{:keys [id class value default placeholder icon disabled inner-class min max max-length step is-selected-on-focus nillable tokens applied-token-name empty-to-end on-change on-change-start on-change-end on-blur on-focus on-detach property align ref name tooltip-placement text-icon] :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-name) :multiple value) token-applied (mf/with-memo [tokens applied-token-name] (find-token-by-name tokens applied-token-name)) token-has-errors? (-> token-applied :errors seq boolean) 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-name* (mf/use-state applied-token-name) token-applied-name (deref token-applied-name*) is-token-applied? (and (some? token-applied-name) (not= :multiple token-applied-name)) 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) ;; Flag to prevent effect from overwriting token during selection ;; This prevents race condition between blur and token selection token-selection-in-progress* (mf/use-ref false) ;; 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) ;; Drag scrubbing state drag-state* (mf/use-ref :idle) drag-start-x* (mf/use-ref 0) drag-start-val* (mf/use-ref 0) dropdown-options (mf/with-memo [tokens filter-id] (csu/get-token-dropdown-options tokens filter-id)) selected-id* (mf/use-state (fn [] (if applied-token-name (:id (get-option-by-name dropdown-options applied-token-name)) 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-name* nil) (when (fn? on-change) (on-change parsed)) (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-name* 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-name* 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) (mf/set-ref-val! dirty-ref true) (reset! filter-id* text)))) on-token-apply (mf/use-fn (mf/deps apply-token) (fn [id value name] (mf/set-ref-val! token-selection-in-progress* true) (reset! selected-id* id) (reset! focused-id* nil) (reset! is-open* false) (reset! token-applied-name* name) (apply-token value name) (ts/schedule-on-idle (fn [] (mf/set-ref-val! token-selection-in-progress* false) (when token-wrapper-ref (dom/focus! (mf/ref-val token-wrapper-ref))))))) on-option-click (mf/use-fn (mf/deps on-token-apply) (fn [event] (let [node (dom/get-current-target event) id (dom/get-data node "id") options (mf/ref-val options-ref) options (if (delay? options) @options options) option (get-option 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 focused-id on-token-apply) (fn [_] (let [options (mf/ref-val options-ref) options (if (delay? options) @options options) option (get-option options focused-id) value (get option :resolved-value) name (get option :name)] (on-token-apply focused-id value name) (reset! filter-id* "")))) handle-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*)) (mf/set-ref-val! dirty-ref false)) (when (fn? on-blur) (on-blur event)) (dom/blur! (mf/ref-val ref)))) commit-pending-on-unmount (mf/use-fn (mf/deps apply-value) (fn [] (when (mf/ref-val dirty-ref) (apply-value (mf/ref-val raw-value*)) (mf/set-ref-val! dirty-ref false)))) handle-unmount (h/use-ref-callback commit-pending-on-unmount) on-key-down (mf/use-fn (mf/deps 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) options (if (delay? options) @options options)] (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*)))) (reset! is-open* false)) enter? (if is-open (do (dom/prevent-default event) (if focused-id (on-option-enter event) (let [option-id (first-focusable-id options) option (get-option options option-id) value (get option :resolved-value) name (get option :name)] (on-token-apply option-id value name) (reset! filter-id* "") (handle-blur event)))) (handle-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)))))))) on-focus (mf/use-fn (mf/deps on-focus select-on-focus) (fn [event] (when-not (= :dragging (mf/ref-val drag-state*)) (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})))))) on-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))))))) on-scrub-pointer-down (mf/use-fn (mf/deps disabled is-open is-multiple? ref min max nillable default is-token-applied?) (fn [event] (when-not (or disabled is-open is-multiple? is-token-applied?) (let [node (mf/ref-val ref) is-focused (and (some? node) (dom/active? node)) has-token (some? (deref token-applied-name*))] (when-not (or is-focused has-token) (let [client-x (.-clientX event) parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable) start-val (or parsed default 0)] (mf/set-ref-val! drag-state* :maybe-dragging) (mf/set-ref-val! drag-start-x* client-x) (mf/set-ref-val! drag-start-val* start-val) (dom/capture-pointer event))))))) on-scrub-pointer-move (mf/use-fn (mf/deps apply-value update-input step min max on-change-start is-token-applied?) (fn [event] (when-not is-token-applied? (let [state (mf/ref-val drag-state*)] (when (or (= state :maybe-dragging) (= state :dragging)) (let [client-x (.-clientX event) start-x (mf/ref-val drag-start-x*) delta-x (- client-x start-x)] (when (and (= state :maybe-dragging) (>= (js/Math.abs delta-x) 3)) (mf/set-ref-val! drag-state* :dragging) (when (fn? on-change-start) (on-change-start))) (when (= (mf/ref-val drag-state*) :dragging) (let [effective-step (cond (.-shiftKey event) (* step 10) (.-ctrlKey event) (* step 0.1) :else step) steps (js/Math.round (/ delta-x 1)) new-val (mth/clamp (+ (mf/ref-val drag-start-val*) (* steps effective-step)) min max)] (update-input (fmt/format-number new-val)) (apply-value (dm/str new-val)))))))))) on-scrub-pointer-up (mf/use-fn (mf/deps ref on-change-end is-token-applied?) (fn [event] (when-not is-token-applied? (let [state (mf/ref-val drag-state*)] (when (= state :maybe-dragging) (mf/set-ref-val! drag-state* :idle) (dom/release-pointer event) (when-let [node (mf/ref-val ref)] (dom/focus! node))) (when (= state :dragging) (mf/set-ref-val! drag-state* :idle) (dom/release-pointer event) (when (fn? on-change-end) (on-change-end))))))) on-scrub-lost-pointer-capture (mf/use-fn (mf/deps on-change-end is-token-applied?) (fn [_event] (when-not is-token-applied? (let [was-dragging (= :dragging (mf/ref-val drag-state*))] (mf/set-ref-val! drag-state* :idle) (when (and was-dragging (fn? on-change-end)) (on-change-end)))))) 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-name) (fn [event] (when-not disabled (dom/prevent-default event) (dom/stop-propagation event) (reset! token-applied-name* nil) (reset! selected-id* nil) (reset! focused-id* nil) (when on-detach (on-detach token-applied-name)) (ts/schedule-on-idle (fn [] (dom/focus! (mf/ref-val ref))))))) 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) options (if (delay? options) @options options) 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 :class inner-class :placeholder (if is-multiple? (tr "labels.mixed-values") placeholder) :default-value (or (mf/ref-val last-value*) (fmt/format-number value)) :on-blur handle-blur :on-key-down on-key-down :on-focus on-focus :on-change store-raw-value :variant "comfortable" :disabled disabled :icon icon :aria-label property :slot-start (when text-icon (mf/html [:div {:class (stl/css :text-icon)} text-icon])) :slot-end (when-not disabled (when (some? tokens) (mf/html [:> icon-button* {:variant "ghost" :icon i/tokens :tooltip-class (stl/css :button-tooltip) :class (stl/css :invisible-button) :aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown") :ref open-dropdown-ref :tooltip-placement tooltip-placement :on-click open-dropdown}]))) :max-length max-length}) token-props (when (and token-applied-name (not= :multiple token-applied-name)) (let [token (get-option-by-name dropdown-options token-applied-name) id (or (get token :id) (some-> (get token-applied :id) (dm/str))) label (or (get token :name) applied-token-name) token-value (or (get token :resolved-value) (or (mf/ref-val last-value*) (fmt/format-number value))) token-value (if (and (some? id) (= name :opacity)) (* 100 token-value) token-value)] (mf/spread-props props {:id id :label label :value token-value :on-click open-dropdown-token :on-focus on-focus :on-token-key-down on-token-key-down :disabled disabled :on-blur handle-blur :token-has-errors token-has-errors? :class inner-class :property property :is-open is-open :tooltip-placement tooltip-placement :slot-start (when (or icon text-icon) (mf/html (cond icon [:> icon* {:icon-id icon :size "s" :class (stl/css :icon)}] text-icon [:div {:class (stl/css :text-icon)} text-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-name] (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') ;; Only sync token state if not in the middle of a selection ;; This prevents race condition between blur and token selection (when-not (mf/ref-val token-selection-in-progress*) (reset! token-applied-name* applied-token-name) (if applied-token-name (let [token-id (:id (get-option-by-name dropdown-options applied-token-name))] (reset! selected-id* token-id)) (reset! selected-id* nil))) (when-let [node (mf/ref-val ref)] (dom/set-value! node value')))) (mf/with-effect [applied-token-name] (when (nil? applied-token-name) ;; Only clear if not in the middle of a selection (when-not (mf/ref-val token-selection-in-progress*) (reset! token-applied-name* nil) (reset! selected-id* nil)))) (mf/with-layout-effect [on-mouse-wheel] (when-let [node (mf/ref-val ref)] (let [key (events/listen node "wheel" on-mouse-wheel #js {:passive false})] #(events/unlistenByKey key)))) (mf/with-effect [dropdown-options] (mf/set-ref-val! options-ref dropdown-options)) (mf/with-effect [handle-unmount] handle-unmount) [:div {:class [class (stl/css-case :input-wrapper true :resizable (not is-token-applied?))] :ref wrapper-ref :on-pointer-down on-scrub-pointer-down :on-pointer-move on-scrub-pointer-move :on-pointer-up on-scrub-pointer-up :on-lost-pointer-capture on-scrub-lost-pointer-capture} (if is-token-applied? [:> token-field* token-props] [:> input-field* input-props]) (when ^boolean is-open (let [options (if (delay? dropdown-options) @dropdown-options dropdown-options)] [:> options-dropdown* {:on-click on-option-click :id listbox-id :options options :selected selected-id :focused focused-id :align align :empty-to-end empty-to-end :ref set-option-ref}]))]))