(I'm a little new to OM and React, so apologies if this is documented somewhere that I haven't seen yet.)
I'm trying to make a textbox that validates and formats its input during editing similar to the autoNumeric JS library.
My application is for validating USD currencies (e.g. strings of the form $1,234.56
). Specifically, the textbox should prevent the user from typing invalid characters (like letters) into the textbox and should format its contents on-the-fly to include the dollar-sign ($
) and the commas marking the thousands positions. The component should prevent deleting these formatting characters. The formatting is already handled nicely by the JS toLocaleString
function, but I'm having trouble with the UI and the position of the cursor jumping after input.
I found a relevant react github issue that describes how to prevent the cursor from jumping around after the app-state is updated. The solution from that thread is captured is a JS Bin, however it is written in javascript using React.
I'm trying to implement a version using ClojureScript and Om, but am having trouble. If I implement the component using only component-local state, and no global app-state, then the cursor does the intended behavior and does not jump around (similar to the autoNumeric library). Ultimately I want to propagate the value to the global app-state. However, when I have the component update the global app-state, then the cursor jumps after each change to the input field.
I've tried many variants including using no local state (only global app-state) and a mix between global and local. Anytime I include global app-state, the cursor jumps.
Here is my current implementation, which does not yet update the global app-state, but does not have a jumping cursor. (Note, I'm using om-tools, so the syntax may be a little different from vanilla om.)
(defn raw-number [s]
(let [s (if (empty? s "0" (str s)))]
(js/parseFloat (str/replace s #"[^-0-9.]" ""))))
(defn format-currency [s]
(.toLocaleString (raw-number s) "en-US" #js {:style "currency" :currency "USD"}))
(defcomponentk validated-input
"Component that prevents the cursor from jumping after updates."
[data owner opts]
(will-receive-props [_ new-state]
(when-let [dom-node (om/get-node owner "validated-input")]
(let [old-length (-> dom-node .-value count)
old-idx (.-selectionStart dom-node)
_ (set! (.-value dom-node) new-state)
new-len (count new-state)
new-idx (max 0 (-> new-len (- old-length) (+ old-idx)))]
(om/update! data :cursor-pos new-idx))))
(did-update [_ _ prev-state]
(let [new-idx (:cursor-pos data)
dom-node (om/get-node owner "validated-input")]
(set! (.-selectionStart dom-node) new-idx)
(set! (.-selectionEnd dom-node) new-idx)))
(render [_]
(dom/input
(merge
opts
{:type "text"
:ref "validated-input"}))))
(defcomponentk formatted-input
"Formats the text using the provided formatter before rendering it."
[data owner [:opts formatter :as opts]]
(init-state [_]
{:text (formatter "")})
(render [_]
(let [text (om/get-state owner :text)]
(validated-input
text ;; << I recognize this to be shady, passing local state down. but it works to prevent the cursor jumping.
{:opts
(merge
opts
{:value text
:on-change (fn [e]
(let [new-val (formatter (str (.. e -target -value)))]
(om/set-state! owner :text new-val)))})}))))