0

(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)))})}))))
dsg
  • 12,924
  • 21
  • 67
  • 111

1 Answers1

0

I think I was getting tripped up because changes to the local state and changes to the global state both triggered a re-render. I ended up using a somewhat hacky workaround using an atom in the local state to avoid retriggering a rerender.

The solution performs well on a desktop, but due to its reliance on the on-click or on-key-down events, I'm unsure whether it will work well on mobile.

In any case, it suits my needs. Critiques are welcome!

(defn save-position! [owner event]
  (let [found-node (om/get-node owner "validated-input")
        idx-atom (om/get-state owner :idx-atom)
        new-idx (if (nil? found-node) @idx-atom (.-selectionStart found-node))]
    (reset! idx-atom new-idx)))

(defcomponentk formatted-input
  "Formats the text using the provided formatter before rendering it."
  [data owner [:opts formatter :as opts]]
  (init-state [_]
    {:idx-atom (atom 0)})

  (did-update [_ prev-props prev-state]
    (let [dom-node (om/get-node owner "validated-input")

          old-text (:text prev-props)
          new-text (:text data)

          old-len (count old-text)
          new-len (count new-text)

          delta-len (- new-len old-len)
          old-idx @(:idx-atom prev-state)

          new-idx (max 0 (+ delta-len old-idx))]
      (set! (.-selectionStart dom-node) new-idx)
      (set! (.-selectionEnd dom-node) new-idx)))

  (render [_]
    (dom/input
     (merge
      opts
      {:value (formatter (str (:text data)))
       :type "text"
       :ref "validated-input"
       :on-click (fn [dom-node] (save-position! owner dom-node))
       :on-key-down (fn [dom-node] (save-position! owner dom-node))
       :on-change (fn [dom-node]
                    (let [new-val (formatter (str (.. dom-node -target -value)))
                          old-idx @(om/get-state owner :idx-atom)
                          found-node (om/get-node owner "validated-input")]
                      (when (not= new-val (.-value found-node))
                        (set! (.-value found-node) new-val)
                        (set! (.-selectionStart found-node) old-idx)
                        (set! (.-selectionEnd found-node) old-idx))
                      (om/update! data :text new-val)))}))))
dsg
  • 12,924
  • 21
  • 67
  • 111