madvas / cljs-react-material-ui

Clojurescript library for using material-ui.com
Eclipse Public License 1.0
205 stars 32 forks source link

Caret moves to the end when editing a TextArea in reagent #17

Open euccastro opened 7 years ago

euccastro commented 7 years ago

When I render this component:

(defn simple-text-field [text]
  (let [text-state (r/atom text)]
    (fn []
      [rui/text-field
       {:id "example"
        :value @text-state
        :on-change (fn [e] (reset! text-state (.. e -target -value)))}])))

It seems to work well until I place the caret in the middle of the text and type again. Invariably, the caret jumps to the end after the insertion. The root cause seems to be that reagent re-renders components asynchronously. See http://stackoverflow.com/questions/28922275/in-reactjs-why-does-setstate-behave-differently-when-called-synchronously/28922465#28922465

madvas commented 7 years ago

Try it out like this

(defn simple-text-field [text]
  (let [text-state (r/atom text)]
    (fn []
      [rui/text-field
       {:id "example"
        :default-value @text-state
        :on-change (fn [e] (reset! text-state (.. e -target -value)))}])))
euccastro commented 7 years ago

:default-value fixes it! I have no idea why.

I'll close this since I imagine this is an upstream issue and the workaround is simple and painless. But unless this is already documented in a prominent place I've missed, I think this deserves a big warning in the README. The workaround isn't obvious at all to me, and without that the issue is really crippling.

Thank you very much!

edannenberg commented 7 years ago

This is really just a work around, and one that will sooner or later lead to problems as the input becomes uncontrolled. For any serious work controlled inputs are a must IMHO. I remember Reagent had the same issue before this fix. By the looks of it this should also work with Material-UI inputs as the fix just checks for the type property which is correctly set. Did you already investigate why this is not the case?

How do you solve the simple use case of form fields rendering data that arrives over the network, i.e. shortly after the view is already rendered for the first time? Seems like a lot of hoops with uncontrolled inputs.

madvas commented 7 years ago

I haven't really investigated what's situation like in latest reagent with this. But you're right, it's a workaround. I remember having some issues with this back in a day, probably in a scenario you described (when wanted to pre-fill form with delayed data from network). Honestly, right now, I pretty much have no clue what this library could do in order to solve this in any reasonable way (no crazy workarounds).

edannenberg commented 7 years ago

From what I gathered a clean fix is not possible due to the async rendering used by Clojure React wrappers like Om or Reagent. Reagent (after much discussion) resorted to manual caret repositioning. No idea though if this is doable from your library. Maybe some thin wrapper around input components that takes care of this?

Also agreeing with @euccastro that this should be mentioned in the docs.

madvas commented 7 years ago

Okay, thanks for a info. I updated readme. I'll try to look at this in more depth when I find more time.

superstructor commented 7 years ago

I think the problem is that a Material UI textfield is a not a reagent.impl.template/input-component? so it bypasses all of the special handling in reagent-input, input-spec, input-handle-change, input-set-value (which does manual caret repositioning), input-render-setup etc. The condition is based on the name of the component, which in the case of Material UI is not input or textarea.

superstructor commented 7 years ago

This (extremely ugly) workaround works:

(defn textfield []
  (reagent/create-class
    {:display-name   "textfield"
     :reagent-render (fn [props]
                       [rui/text-field props])}))
madvas commented 7 years ago

Oh, really? Thank you a lot for this!!

madvas commented 7 years ago

@superstructor have you actually tried that, because it doesn't really work that way for me. Could you provide example with atom? I assume you made a typo in :display-name and meant to write there either input or textarea.

Anyways here's my piece of code which doesn't work:

(defn text-field []
  (r/create-class
    {:display-name "input"
     :reagent-render (fn [props]
                       [rui/text-field props])}))

(defn simple-text-field [text]
  (let [text-state (r/atom text)]
    (fn []
      [text-field
       {:display-name "input"
        :id "my-textfield"
        :value @text-state
        :on-change (fn [e] (reset! text-state (.. e -target -value)))}])))

But it works when I override checking function in following way:

(set! reagent.impl.template/input-component?
      (fn [x]
        (or (= x "input")
            (= x "textarea")
            (= (reagent.interop/$ x :name) "TextField"))))
superstructor commented 7 years ago

Yes sorry meant to type textarea. Overriding the checking function is probably a better solution ?

madvas commented 7 years ago

Ok pushed changes, this should be resolved now

edannenberg commented 7 years ago

Fix works, but seems to have some side effects. After switching to 0.2.23 text-fields don't update anymore unless I'm actually typing in the field:

[:input {:value (:foo @state)}]
[rui/text-field {:value (:foo @state)}]

With 0.2.22 both fields update if state changes by external means after mounting, with 0.2.23 this is only true for [:input].

superstructor commented 7 years ago

This fix usually works, except when (reagent.interop/$ x :name) is, for example, t instead of TextField with :simple optimisations. Not 100% verified, will comment further when I have verified.

madvas commented 7 years ago

Okay guys, you were both right. @superstructor I resolved issue with optimisations in recently pushed 0.2.24 Hoveever, @edannenberg your issue is far more serious. Yes value can't be changed once set. So basically we're back where we were except we can set :value now instead of :default-value.

I tried to hack around reagent a bit, but couldn't get around this. So I opened issue at reagent here explaining issue and asking for a help.

superstructor commented 7 years ago

Thanks for the fix with optimisations! Looks like the latest release on clojars is still 0.2.23 ?

radhikalism commented 7 years ago

So there are 3 aspects to this bug.

  1. The text-field component isn't detected by Reagent as an input component. (The input-component? patch solves this.)
  2. When there are updates, Reagent targets the wrong DOM node to set :value.
  3. Both of these are caused by the Material text-field's DOM structure being wrapped in a div, whereas Reagent expects find-dom-node to return an input element.

If we were to hack Reagent to workaround 1 and 2 directly, it might look like this:

diff --git a/src/reagent/impl/template.cljs b/src/reagent/impl/template.cljs
index 17e3a9f..1040acc 100644
--- a/src/reagent/impl/template.cljs
+++ b/src/reagent/impl/template.cljs
@@ -116,7 +116,10 @@
     ($! this :cljsInputDirty false)
     (let [rendered-value ($ this :cljsRenderedValue)
           dom-value ($ this :cljsDOMValue)
-          node (find-dom-node this)]
+          node (find-dom-node this)
+          node (if-not (= "INPUT" ($ node :tagName)) ;; Is this a wrapped component?
+                 (.querySelector node "input") ;; Locate a child input inside the wrapper
+                 node)]
       (when (not= rendered-value dom-value)
         (if-not (and (identical? node ($ js/document :activeElement))
                      (has-selection-api? ($ node :type))
@@ -195,9 +198,10 @@
   ($! this :cljsInputLive nil))

 (defn ^boolean input-component? [x]
-  (case x
-    ("input" "textarea") true
-    false))
+  (boolean (or (= x "input")
+               (= x "textarea")
+               (when-let [prop-types ($ x :propTypes)] ;; Is this a wrapped Material input?
+                 (aget prop-types "inputStyle")))))

 (def reagent-input-class nil)

However, this exact patch is probably not the right way to structure a generic library.

What if Reagent provided opportunities through adapt-react-class for users to override how input components are detected and located?

madvas commented 7 years ago

@arbscht thank you very much for your reply. Your patch is against master version of reagent, how would it look like against version 0.6.0? couldn't figure it out somehow

radhikalism commented 7 years ago

@madvas Reagent master has b65afde4 which uses find-dom-node. Not sure if targeting 0.6.0 without find-dom-node will be buggy. Nevertheless, something like this might work:

diff --git a/src/reagent/impl/template.cljs b/src/reagent/impl/template.cljs
index fc6b0c2..a9c13b7 100644
--- a/src/reagent/impl/template.cljs
+++ b/src/reagent/impl/template.cljs
@@ -108,8 +108,16 @@
   [input-type]
   (contains? these-inputs-have-selection-api input-type))

+(defn get-input-node [this]
+  (let [outer-node ($ this :cljsInputElement)
+        inner-node ($ outer-node :input)]
+    (if (and inner-node
+             (= "INPUT" ($ inner-node :tagName)))
+      inner-node
+      outer-node)))
+
 (defn input-set-value [this]
-  (when-some [node ($ this :cljsInputElement)]
+  (when-some [node (get-input-node this)]
     ($! this :cljsInputDirty false)
     (let [rendered-value ($ this :cljsRenderedValue)
           dom-value ($ this :cljsDOMValue)]
@@ -186,9 +194,10 @@
         ($! :ref #($! this :cljsInputElement %1))))))

 (defn ^boolean input-component? [x]
-  (case x
-    ("input" "textarea") true
-    false))
+  (boolean (or (= x "input")
+               (= x "textarea")
+               (when-let [prop-types ($ x :propTypes)] ;; Is this a wrapped Material input?
+                 (aget prop-types "inputStyle")))))

 (def reagent-input-class nil)

I'm not actually sure where :input comes from, but it's there in 0.6.0 and apparently not in master.

madvas commented 7 years ago

Thanks! But it seems like it's not complete solution. I've tried to patch reagent like this:

(set! reagent.impl.template/input-component?
      (fn [x]
        (boolean (or (= x "input")
                     (= x "textarea")
                     (when-let [prop-types ($ x :propTypes)] ;; Is this a wrapped Material input?
                       (aget prop-types "inputStyle"))))))

(defn get-input-node [this]
  (let [outer-node ($ this :cljsInputElement)
        inner-node ($ outer-node :input)]
    (if (and inner-node
             (= "INPUT" ($ inner-node :tagName)))
      inner-node
      outer-node)))

(set! reagent.impl.template/input-set-value
      (fn [this]
        (when-some [node (get-input-node this)]
          ($! this :cljsInputDirty false)
          (let [rendered-value ($ this :cljsRenderedValue)
                dom-value ($ this :cljsDOMValue)]
            (when (not= rendered-value dom-value)
              (if-not (and (identical? node ($ js/document :activeElement))
                           (reagent.impl.template/has-selection-api? ($ node :type))
                           (string? rendered-value)
                           (string? dom-value))
                ;; just set the value, no need to worry about a cursor
                (do
                  ($! this :cljsDOMValue rendered-value)
                  ($! node :value rendered-value))

                ;; Setting "value" (below) moves the cursor position to the
                ;; end which gives the user a jarring experience.
                ;;
                ;; But repositioning the cursor within the text, turns out to
                ;; be quite a challenge because changes in the text can be
                ;; triggered by various events like:
                ;; - a validation function rejecting a user inputted char
                ;; - the user enters a lower case char, but is transformed to
                ;;   upper.
                ;; - the user selects multiple chars and deletes text
                ;; - the user pastes in multiple chars, and some of them are
                ;;   rejected by a validator.
                ;; - the user selects multiple chars and then types in a
                ;;   single new char to repalce them all.
                ;; Coming up with a sane cursor repositioning strategy hasn't
                ;; been easy ALTHOUGH in the end, it kinda fell out nicely,
                ;; and it appears to sanely handle all the cases we could
                ;; think of.
                ;; So this is just a warning. The code below is simple
                ;; enough, but if you are tempted to change it, be aware of
                ;; all the scenarios you have handle.
                (let [node-value ($ node :value)]
                  (if (not= node-value dom-value)
                    ;; IE has not notified us of the change yet, so check again later
                    (batch/do-after-render #(reagent.impl.template/input-set-value this))
                    (let [existing-offset-from-end (- (count node-value)
                                                      ($ node :selectionStart))
                          new-cursor-offset (- (count rendered-value)
                                               existing-offset-from-end)]
                      ($! this :cljsDOMValue rendered-value)
                      ($! node :value rendered-value)
                      ($! node :selectionStart new-cursor-offset)
                      ($! node :selectionEnd new-cursor-offset))))))))))

And when you try 2 inputs like this:

(defn simple-text-field [text]
  (let [text-state (r/atom text)]
    (js/setInterval (fn []
                      (reset! text-state (str (rand-int 99999))))
                    1000)
    (fn []
      [:div
       [rui/text-field
        {:id "my-textfield"
         :value @text-state
         :on-change (fn [e] (reset! text-state (.. e -target -value)))}]
       [:input
        {:id "my-input"
         :value @text-state
         :on-change (fn [e] (reset! text-state (.. e -target -value)))}]])))

you'll see :input value is being correctly changed in intervals, but rui/text-field value doesn't change

radhikalism commented 7 years ago

Be careful with set! — I think it doesn't update all references to the function from callers in closures that were already defined (as the old version of the bound fn appears to be captured/cached/inlined?). To solve, either:

Otherwise it will misbehave as you see.

(Indeed there are other incomplete aspects to this patch. It incorrectly detects radio/check inputs in input-component?. And get-input-node isn't sufficiently nil-safe. But this should be enough to get your test project to work.)

radhikalism commented 7 years ago

@madvas Try this, paste and invoke set-overrides!:

;; A better but idiosyncratic way for reagent to locate the real
;; input element of a component. It may be that the component's
;; top-level (outer) element is an <input> already. Or it may be that
;; the outer element is a wrapper, and the real <input> is somewhere
;; within it (as is the case with Material TextFields).
;; Somehow the `:input` property seems to be pre-populated (in version
;; 0.6.0 of Reagent) with a reference to the real <input>, so we use
;; that if available.
;; At the time of writing, future versions of Reagent will likely
;; change this behavior, and a totally different patch will be
;; required for identifying the real <input> element.
(defn get-input-node [this]
  (let [outer-node ($ this :cljsInputElement)
        inner-node (when (and outer-node (.hasOwnProperty outer-node "input"))
                     ($ outer-node :input))]
    (if (and inner-node
             (= "INPUT" ($ inner-node :tagName)))
      inner-node
      outer-node)))

;; This is the same as reagent.impl.template/input-set-value except
;; that the `node` binding uses our `get-input-node` function. Even
;; the original comments are reproduced below.
(defn input-set-value
  [this]
  (when-some [node (get-input-node this)]
    ($! this :cljsInputDirty false)
    (let [rendered-value ($ this :cljsRenderedValue)
          dom-value      ($ this :cljsDOMValue)]
      (when (not= rendered-value dom-value)
        (if-not (and (identical? node ($ js/document :activeElement))
                  (reagent.impl.template/has-selection-api? ($ node :type))
                  (string? rendered-value)
                  (string? dom-value))
          ;; just set the value, no need to worry about a cursor
          (do
            ($! this :cljsDOMValue rendered-value)
            ($! node :value rendered-value))

          ;; Setting "value" (below) moves the cursor position to the
          ;; end which gives the user a jarring experience.
          ;;
          ;; But repositioning the cursor within the text, turns out to
          ;; be quite a challenge because changes in the text can be
          ;; triggered by various events like:
          ;; - a validation function rejecting a user inputted char
          ;; - the user enters a lower case char, but is transformed to
          ;;   upper.
          ;; - the user selects multiple chars and deletes text
          ;; - the user pastes in multiple chars, and some of them are
          ;;   rejected by a validator.
          ;; - the user selects multiple chars and then types in a
          ;;   single new char to repalce them all.
          ;; Coming up with a sane cursor repositioning strategy hasn't
          ;; been easy ALTHOUGH in the end, it kinda fell out nicely,
          ;; and it appears to sanely handle all the cases we could
          ;; think of.
          ;; So this is just a warning. The code below is simple
          ;; enough, but if you are tempted to change it, be aware of
          ;; all the scenarios you have handle.
          (let [node-value ($ node :value)]
            (if (not= node-value dom-value)
              ;; IE has not notified us of the change yet, so check again later
              (reagent.impl.batching/do-after-render #(input-set-value this))
              (let [existing-offset-from-end (- (count node-value)
                                               ($ node :selectionStart))
                    new-cursor-offset        (- (count rendered-value)
                                               existing-offset-from-end)]
                ($! this :cljsDOMValue rendered-value)
                ($! node :value rendered-value)
                ($! node :selectionStart new-cursor-offset)
                ($! node :selectionEnd new-cursor-offset)))))))))

;; This is the same as `reagent.impl.template/input-handle-change`
;; except that the reference to `input-set-value` points to our fn.
(defn input-handle-change
  [this on-change e]
  ($! this :cljsDOMValue (-> e .-target .-value))
  ;; Make sure the input is re-rendered, in case on-change
  ;; wants to keep the value unchanged
  (when-not ($ this :cljsInputDirty)
    ($! this :cljsInputDirty true)
    (reagent.impl.batching/do-after-render #(input-set-value this)))
  (on-change e))

;; This is the same as `reagent.impl.template/input-render-setup`
;; except that the reference to `input-handle-change` points to our fn.
(defn input-render-setup
  [this jsprops]
  ;; Don't rely on React for updating "controlled inputs", since it
  ;; doesn't play well with async rendering (misses keystrokes).
  (when (and (some? jsprops)
          (.hasOwnProperty jsprops "onChange")
          (.hasOwnProperty jsprops "value"))
    (let [v         ($ jsprops :value)
          value     (if (nil? v) "" v)
          on-change ($ jsprops :onChange)]
      (when (nil? ($ this :cljsInputElement))
        ;; set initial value
        ($! this :cljsDOMValue value))
      ($! this :cljsRenderedValue value)
      (js-delete jsprops "value")
      (doto jsprops
        ($! :defaultValue value)
        ($! :onChange #(input-handle-change this on-change %))
        ($! :ref #($! this :cljsInputElement %1))))))

;; This version of `reagent.impl.template/input-component?` is
;; effectively the same as before except that it also detects Material's
;; wrapped components as input components. It does this by looking for
;; a property called "inputStyle" as an indicator. (Perhaps not a
;; robust test...)
;;
;; By identifying input components more liberally, Material textfields
;; are permitted into the code path that manages caret positioning
;; and selection state awareness, in reaction to updates. This alone
;; is necessary but insufficient.
(defn input-component?
  [x]
  (or (= x "input")
    (= x "textarea")
    (when-let [prop-types ($ x :propTypes)]
      ;; Material inputs all have "inputStyle" prop
      (and (aget prop-types "inputStyle")
        ;; But we only want text-fields, so let's exclude radio/check inputs
        (not (aget prop-types "checked"))
        ;; TODO: ... and other non-text-field inputs?
        ))))

;; This is the same as `reagent.impl.template/input-spec` except that
;; the reference to `input-render-setup` points to our fn.
(def input-spec
  {:display-name "ReagentInput"
   :component-did-update input-set-value
   :reagent-render
   (fn [argv comp jsprops first-child]
     (let [this reagent.impl.component/*current-component*]
       (input-render-setup this jsprops)
       (reagent.impl.template/make-element argv comp jsprops first-child)))})

;; This is the same as `reagent.impl.template/reagent-input` except
;; that the reference to `input-spec` points to our definition.
(defn reagent-input []
  (when (nil? reagent.impl.template/reagent-input-class)
    (set! reagent.impl.template/reagent-input-class (reagent.impl.component/create-class input-spec)))
  reagent.impl.template/reagent-input-class)

;; Now we override the existing functions in `reagent.impl.template`
;; with our own definitions.
(defn set-overrides!
  []
  (set! reagent.impl.template/input-component? input-component?)
  (set! reagent.impl.template/input-handle-change input-handle-change)
  (set! reagent.impl.template/input-set-value input-set-value)
  (set! reagent.impl.template/input-render-setup input-render-setup)
  (set! reagent.impl.template/input-spec input-spec)
  (set! reagent.impl.template/reagent-input reagent-input))
madvas commented 7 years ago

This works great, thanks! But I'm not sure if we can include it like this in library. If people will use different versions of reagent, it will easily break.

radhikalism commented 7 years ago

@madvas I agree, this snippet is just a rough proof of the bug.

I don't think cljs-react-material-ui should patch Reagent. But I also don't think Reagent should be specifically aware of Material quirks for cljs-react-material-ui.

My current view is that Reagent should allow adapt-react-class to take some extra parameters that tag the class with a flag or predicate that would let the component pass the input-component? check, and also associate an optional fn that "locates" the node for a component in input-set-value (default being to just find-dom-node (> 0.6.0) or get :cljsInputElement (0.6.0)).

Then cljs-react-material-ui can modify its text-field definition to set those optional parameters when it invokes r/adapt-react-class.

What do you think? I'm open to other approaches.

Also, how would you like to co-ordinate with Reagent releases? Target 0.6.0 and re-visit with the next release, or just target the next release?

madvas commented 7 years ago

Great, I think extra params to adapt-react-class is fairly elegant approach. We'll see what reagent guys will think. If you feel like doing pull request to reagent, go for it, since you seem to understand problem very well. If not, I can do it. I think just targeting next release is good enough. We'll leave it as it is for 0.6.0.

radhikalism commented 7 years ago

Quick update: still working on a more correct solution. My earlier snippet does update <input> element values, but then input state does not always match the TextField component's state. In particular, Material-UI TextFields keep a state flag hasValue to determine if the value is empty/null/undefined (and show hint text etc). If it was previously :hasValue false, and we directly write to the inner <input> element, it will render overlapping hint text and value text. To solve this, we can call setState from within input-set-value to sync the TextField component, so this will be another component-specific function for cljs-react-material-ui to supply to Reagent.

euccastro commented 7 years ago

I tried @arbscht's set-overrides! snippet with reagent 0.6.0, and the caret works fine when I type in text. Alas, I can't set the value of my TextField programmatically (ie., in a reaction). Is this expected?

madvas commented 7 years ago

@arbscht What's status on this, have you made any progress?

radhikalism commented 7 years ago

Nope, only got as far as a very specific monkey-patch that suits the application I'm working on, which would probably break Reagent for non-crmui users if factored out.

Haven't made a working proof-of-concept of my proposed enhanced adapt-react-class yet. It's a little tricky to properly expose the API I sketched out and pass the params all the way down the chain in reagent.impl.template. Either involves mutable js values or more significant code changes (and regression risk) than I'd like...

I can't think of a better solution, just needs some care and time to test Reagent thoroughly, which is beyond me for the moment! Might have a go at it over the holidays.

radhikalism commented 7 years ago

FYI something else I was looking at last time: maybe we can reshape the component being exposed to Reagent so that its <input> is findable by the existing code that tries to locate it ((find-dom-node this)). I don't know how feasible this is but it would have the benefit of leaving Reagent unchanged and containing any custom glue hacks to crmui only, which may be appropriate.

madvas commented 7 years ago

Yea, I recently needed to solve this, so I came with such workaround:

(defn text-field* [{:keys [:default-value]}]
  (let [prev-value (r/atom default-value)]
    (fn [{:keys [:rows :on-change :default-value] :as props}]
      (if (= default-value @prev-value)
        [ui/text-field
         (merge
           props
           {:default-value default-value
            :on-change (fn [e val]
                         (reset! prev-value val)
                         (when on-change
                           (on-change e val)))})]
        (do
          (reset! prev-value default-value)
          [:div {:style {:min-height (+ 72 (* (dec (or rows 1)) 24))}}])))))

In which, by storing previous value in local state I can detect if it was changed programatically or by user. If it was programatically it renders empty div of the same height, and then immediately it renders text-field back, so it makes 1 little flickering when value is changed programatically. Not ideal, but better than nothing when you desperately need something.

radhikalism commented 7 years ago

@madvas See if this patched Reagent works better for you: https://github.com/reagent-project/reagent/compare/master...arbscht:synthetic-input

You'll have to configure TextField in crmui itself like this:

(def text-field (r/adapt-react-class (aget js/MaterialUI "TextField")
                  {:synthetic-input? true
                   :input-selector "input"})) ;; default selector, use querySelector syntax

It has a crude implementation of an API that allows locating the correct <input> element in a synthetic input component (one whose root node is not necessarily <input>). Still doesn't support Material's quirk with hasValue etc that I mentioned in https://github.com/madvas/cljs-react-material-ui/issues/17#issuecomment-258142378 but that can be added next. I'm reasonably satisfied it won't impact performance of any existing code paths so far.

Let me refine this patch a little before posting for discussion at reagent-project. The pitch is that Reagent should support synthetic inputs for any use case involving a React component that handles input without a literal <input> tag at its root — not just crmui.

radhikalism commented 7 years ago

In which, by storing previous value in local state I can detect if it was changed programatically or by user. If it was programatically it renders empty div of the same height, and then immediately it renders text-field back, so it makes 1 little flickering when value is changed programatically. Not ideal, but better than nothing when you desperately need something.

Sidenote: one of my earlier workarounds was like this too. As a slight improvement, instead of a zero-height div, all you need is to uniquely refresh :key for the text-field on non-interactive programmatic updates. Makes the hiccup cleaner. But of course, it has drawbacks like losing cursor position and focus on such updates and may even reanimate (if I remember correctly).

radhikalism commented 7 years ago

Update: now fleshed out the proposed Reagent synthetic input API so that it takes a couple of optional functions to influence how it behaves when setting values (programmatic updates). Also configuring a component class to have such a function implicitly makes it detectable as an input component (interactive updates). This helps with maintaining Material-UI component state as well as handling selections/cursor correctly.

https://github.com/reagent-project/reagent/compare/master...arbscht:synthetic-input

Use from crmui like so:

(def text-field (r/adapt-react-class (aget js/MaterialUI "TextField")
                  ;; Optional...
                  {:synthetic-input-setter
                   ;; A fn value for `synthetic-input-setter` does two things:
                   ;; 1) It implicitly marks this component class as an input type so that interactive
                   ;;    updates will work without cursor jumping.
                   ;; 2) Reagent defers to this fn when it goes to set a value for the input component,
                   ;;    providing enough data for us to decide which DOM node is our input node and
                   ;;    continue processing with that (or any arbitrary behaviour...). We can also
                   ;;    supply an extra hook `on-write` to execute more custom behaviour when Reagent
                   ;;    actually writes a new value to the input node.
                   (fn [input-node-set-value root-node rendered-value dom-value component]
                     (when-let [input-node (.querySelector root-node "input")] ;; Choose our specific inner input node
                       ;; Call Reagent's input node value setter fn (extracted from input-set-value)
                       ;; which handles updating of a given <input> element, now that we have targeted
                       ;; the correct <input> within our component...
                       (input-node-set-value input-node rendered-value dom-value component
                         ;; Also hook into the actual value-writing step, since `input-node-set-value` doesn't
                         ;; necessarily update values (i.e. not dirty).
                         {:on-write
                          (fn [new-value]
                            ;; `blank?` is effectively the same conditional as Material-UI uses to update its
                            ;; `hasValue` and `isClean` properties, which are required for correct
                            ;; rendering of hint text etc.
                            (if (clojure.string/blank? new-value)
                              (.setState component #js {:hasValue false :isClean false})
                              (.setState component #js {:hasValue true :isClean false})))})))}))

Thoughts @madvas?

madvas commented 7 years ago

Simply amazing! Elegantly solved all pain points on my project. Can't be more thankful! 👏👏👏

madvas commented 7 years ago

There's one tiny detail, new value is not passed to :on-change as a second argument, must be gotten from first argument as event.target.value

madvas commented 7 years ago

Also I've noticed it doesn't work for textarea. I thought I can solve it by adding

(or (.querySelector root-node "input")
    (.querySelector root-node "textarea"))

but it didn't help

radhikalism commented 7 years ago

Also I've noticed it doesn't work for textarea.

Yeah, multi-line/textarea is implemented quite differently. You can select the "right" node with (->> "textarea" (.querySelectorAll root-node) (nth 2)) or something like that. That will fix cursor jumping but won't help with programmatic updates, since there seems to be a shadow textarea node to take care of. I haven't explored this yet but it should be possible with a more sophisticated setter function.

There's one tiny detail, new value is not passed to :on-change as a second argument, must be gotten from first argument as event.target.value

Just checking, this comment in reference to the snippet you pasted, not a regression, right?

radhikalism commented 7 years ago

I think this works with :multi-line true TextFields:

(def text-field (r/adapt-react-class (aget js/MaterialUI "TextField")
                  ;; Optional...
                  {:synthetic-input-setter
                   ;; A fn value for `synthetic-input-setter` does two things:
                   ;; 1) It implicitly marks this component class as an input type so that interactive
                   ;;    updates will work without cursor jumping.
                   ;; 2) Reagent defers to this fn when it goes to set a value for the input component,
                   ;;    providing enough data for us to decide which DOM node is our input node and
                   ;;    continue processing with that (or any arbitrary behaviour...). We can also
                   ;;    supply an extra hook `on-write` to execute more custom behaviour when Reagent
                   ;;    actually writes a new value to the input node.
                   (fn [input-node-set-value root-node rendered-value dom-value component]
                     (let [input-node (.querySelector root-node "input")
                           textarea-nodes (array-seq (.querySelectorAll root-node "textarea"))
                           textarea-node (when (= 2 (count textarea-nodes))
                                           ;; We are dealing with EnhancedTextarea (i.e. multi-line TextField)
                                           ;; so our target node is the second <textarea>...
                                           (second textarea-nodes))
                           target-node (or input-node textarea-node)]
                       (when target-node
                         ;; Call Reagent's input node value setter fn (extracted from input-set-value)
                         ;; which handles updating of a given <input> element, now that we have targeted
                         ;; the correct <input> within our component...
                         (input-node-set-value target-node rendered-value dom-value component
                           ;; Also hook into the actual value-writing step, since `input-node-set-value`
                           ;; doesn't necessarily update values (i.e. not dirty).
                           {:on-write
                            (fn [new-value]
                              ;; `blank?` is effectively the same conditional as Material-UI uses to update
                              ;; its `hasValue` and `isClean` properties, which are required for correct
                              ;; rendering of hint text etc.
                              (if (clojure.string/blank? new-value)
                                (.setState component #js {:hasValue false :isClean false})
                                (.setState component #js {:hasValue true :isClean false})))}))))}))
madvas commented 7 years ago

heh, I was just typing comment that this works for me

(or (.querySelector root-node "input")
    (.item (.querySelectorAll root-node "textarea") 1))
madvas commented 7 years ago

Just checking, this comment in reference to the snippet you pasted, not a regression, right?

I don't mean my snippet. I mean when I applied your patch :on-change stopped getting second argument

radhikalism commented 7 years ago

I don't mean my snippet. I mean when I applied your patch :on-change stopped getting second argument

Interesting, looking at Reagent's code I can't see where a second argument comes from. I guess it's a pre-existing difference with Material-UI, which seems to be revealed now that Reagent is controlling the component state rather than React?

Reagent:

(defn input-handle-change [this on-change e]
  ($! this :cljsDOMValue (-> e .-target .-value))
  ;; Make sure the input is re-rendered, in case on-change
  ;; wants to keep the value unchanged
  (when-not ($ this :cljsInputDirty)
    ($! this :cljsInputDirty true)
    (batch/do-after-render #(input-component-set-value this)))
  (on-change e))

Material-UI:

  handleInputChange = (event) => {
    this.setState({hasValue: isValid(event.target.value)});
    if (this.props.onChange) {
      this.props.onChange(event, event.target.value);
    }
  };

(Reagent overwrites onChange with its own handler...)

radhikalism commented 7 years ago

Added support for overriding on-change as well, now... https://github.com/reagent-project/reagent/compare/master...arbscht:synthetic-input

(def text-field (r/adapt-react-class (aget js/MaterialUI "TextField")
                  ;; Optional...
                  {:synthetic-input
                   ;; A valid map value for `synthetic-input` does two things:
                   ;; 1) It implicitly marks this component class as an input type so that interactive
                   ;;    updates will work without cursor jumping.
                   ;; 2) Reagent defers to its functions when it goes to set a value for the input component,
                   ;;    or signal a change, providing enough data for us to decide which DOM node is our input
                   ;;    node to target and continue processing with that (or any arbitrary behaviour...); and
                   ;;    to handle onChange events arbitrarily.
                   ;;
                   ;;    Note: We can also use an extra hook `on-write` to execute more custom behaviour
                   ;;    when Reagent actually writes a new value to the input node, from within `on-update`.
                   ;;
                   ;;    Note: Both functions receive a `next` argument which represents the next fn to
                   ;;    execute in Reagent's processing chain.
                   {:on-update (fn [next root-node rendered-value dom-value component]
                                 (let [input-node (.querySelector root-node "input")
                                       textarea-nodes (array-seq (.querySelectorAll root-node "textarea"))
                                       textarea-node (when (= 2 (count textarea-nodes))
                                                       ;; We are dealing with EnhancedTextarea (i.e.
                                                       ;; multi-line TextField)
                                                       ;; so our target node is the second <textarea>...
                                                       (second textarea-nodes))
                                       target-node (or input-node textarea-node)]
                                   (when target-node
                                     ;; Call Reagent's input node value setter fn (extracted from input-set-value)
                                     ;; which handles updating of a given <input> element,
                                     ;; now that we have targeted the correct <input> within our component...
                                     (next target-node rendered-value dom-value component
                                       ;; Also hook into the actual value-writing step,
                                       ;; since `input-node-set-value doesn't necessarily update values
                                       ;; (i.e. not dirty).
                                       {:on-write
                                        (fn [new-value]
                                          ;; `blank?` is effectively the same conditional as Material-UI uses
                                          ;; to update its `hasValue` and `isClean` properties, which are
                                          ;; required for correct rendering of hint text etc.
                                          (if (clojure.string/blank? new-value)
                                            (.setState component #js {:hasValue false :isClean false})
                                            (.setState component #js {:hasValue true :isClean false})))}))))
                    :on-change (fn [next event]
                                 ;; All we do here is continue processing but with the event target value
                                 ;; extracted into a second argument, to match Material-UI's existing API.
                                 (next event (-> event .-target .-value)))}}))
madvas commented 7 years ago

lovely, thanks many times!

metametadata commented 7 years ago

I stumbled upon the very similar problem while using cljsjs.react-bootstrap and managed to fix it by implementing the wrapper component which just needs to intercept on-change and value props in order to sync DOM with the virtual DOM at the right time. Theoretically, this approach should work for any text-input-like React components.

Unfortunately, I didn't have time to test it with cljs-react-material-ui. So I'm just throwing it in here in hope that maybe it can help someone solve the issue without patching Reagent:

https://gist.github.com/metametadata/3b4e9d5d767dfdfe85ad7f3773696a60

skuteli commented 6 years ago

@metametadata thanks a lot, this idea to use normal atom instead of r/atom was what I couldn't figure out myself. Do you guys know why they postponed the PR from @arbscht to 0.8? This is important as more and more people use material-ui with reagent, either via this project or not.

alvatar commented 6 years ago

@madvas is this fixed currently in the latest release? I'm using it with Rum and I keep experiencing the same issue.

alvatar commented 6 years ago

Ping @madvas :)

madvas commented 6 years ago

As far as I see it's not. It's still pending PR at reagent: https://github.com/reagent-project/reagent/pull/282 . Don't know what's Rum's status on this

alvatar commented 6 years ago

Thanks @madvas. I thought this could be solved here.