diff --git a/src/reagent/dom.cljs b/src/reagent/dom.cljs index 6827be8..08e215f 100644 --- a/src/reagent/dom.cljs +++ b/src/reagent/dom.cljs @@ -2,6 +2,7 @@ (:require [react-dom :as react-dom] [reagent.impl.util :as util] [reagent.impl.template :as tmpl] + [reagent.impl.input :as input] [reagent.impl.batching :as batch] [reagent.ratom :as ratom])) @@ -53,7 +54,7 @@ [this] (react-dom/findDOMNode this)) -(set! tmpl/find-dom-node dom-node) +(set! input/find-dom-node dom-node) (defn force-update-all "Force re-rendering of all mounted Reagent components. This is diff --git a/src/reagent/impl/input.cljs b/src/reagent/impl/input.cljs new file mode 100644 index 0000000..40bf373 --- /dev/null +++ b/src/reagent/impl/input.cljs @@ -0,0 +1,138 @@ +(ns reagent.impl.input + (:require [reagent.impl.component :as comp] + [reagent.impl.batching :as batch])) + +;; This gets set from reagent.dom +;; No direct reference to reagent.dom as we don't want to load react-dom +;; for non dom targets. +(defonce find-dom-node nil) + +;; Workaround circular dependency +(defonce make-element nil) + +;; +;; The properites 'selectionStart' and 'selectionEnd' only exist on some inputs +;; See: https://html.spec.whatwg.org/multipage/forms.html#do-not-apply +(def these-inputs-have-selection-api #{"text" "textarea" "password" "search" + "tel" "url"}) + +(defn ^boolean has-selection-api? + [input-type] + (contains? these-inputs-have-selection-api input-type)) + +(declare input-component-set-value) + +(defn input-node-set-value + [node rendered-value dom-value ^clj component {:keys [on-write]}] + (if-not (and (identical? node (.-activeElement js/document)) + (has-selection-api? (.-type node)) + (string? rendered-value) + (string? dom-value)) + ;; just set the value, no need to worry about a cursor + (do + (set! (.-cljsDOMValue component) rendered-value) + (set! (.-value node) rendered-value) + (when (fn? on-write) + (on-write 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 (.-value node)] + (if (not= node-value dom-value) + ;; IE has not notified us of the change yet, so check again later + (batch/do-after-render #(input-component-set-value component)) + (let [existing-offset-from-end (- (count node-value) + (.-selectionStart node)) + new-cursor-offset (- (count rendered-value) + existing-offset-from-end)] + (set! (.-cljsDOMValue component) rendered-value) + (set! (.-value node) rendered-value) + (when (fn? on-write) + (on-write rendered-value)) + (set! (.-selectionStart node) new-cursor-offset) + (set! (.-selectionEnd node) new-cursor-offset)))))) + +(defn input-component-set-value [^clj this] + (when (.-cljsInputLive this) + (set! (.-cljsInputDirty this) false) + (let [rendered-value (.-cljsRenderedValue this) + dom-value (.-cljsDOMValue this) + ;; Default to the root node within this component + node (find-dom-node this)] + (when (not= rendered-value dom-value) + (input-node-set-value node rendered-value dom-value this {}))))) + +(defn input-handle-change [^clj this on-change e] + (set! (.-cljsDOMValue this) (-> e .-target .-value)) + ;; Make sure the input is re-rendered, in case on-change + ;; wants to keep the value unchanged + (when-not (.-cljsInputDirty this) + (set! (.-cljsInputDirty this) true) + (batch/do-after-render #(input-component-set-value this))) + (on-change e)) + +(defn input-render-setup + [^clj this ^js 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")) + (assert find-dom-node + "reagent.dom needs to be loaded for controlled input to work") + (let [v (.-value jsprops) + value (if (nil? v) "" v) + on-change (.-onChange jsprops)] + (when-not (.-cljsInputLive this) + ;; set initial value + (set! (.-cljsInputLive this) true) + (set! (.-cljsDOMValue this) value)) + (set! (.-cljsRenderedValue this) value) + (js-delete jsprops "value") + (set! (.-defaultValue jsprops) value) + (set! (.-onChange jsprops) #(input-handle-change this on-change %))))) + +(defn input-unmount [^clj this] + (set! (.-cljsInputLive this) nil)) + +(defn ^boolean input-component? [x] + (case x + ("input" "textarea") true + false)) + +(def reagent-input-class nil) + +(def input-spec + {:display-name "ReagentInput" + :component-did-update input-component-set-value + :component-will-unmount input-unmount + :reagent-render + (fn [argv component jsprops first-child opts] + (let [this comp/*current-component*] + (input-render-setup this jsprops) + (make-element argv component jsprops first-child opts)))}) + +(defn reagent-input + [] + (when (nil? reagent-input-class) + (set! reagent-input-class (comp/create-class input-spec))) + reagent-input-class) diff --git a/src/reagent/impl/template.cljs b/src/reagent/impl/template.cljs index 7509e9d..fba3447 100644 --- a/src/reagent/impl/template.cljs +++ b/src/reagent/impl/template.cljs @@ -5,11 +5,12 @@ [reagent.impl.util :as util :refer [named?]] [reagent.impl.component :as comp] [reagent.impl.batching :as batch] + [reagent.impl.input :as input] [reagent.ratom :as ratom] [reagent.debug :refer-macros [dev? warn]] [goog.object :as gobj])) -(declare as-element) +(declare as-element make-element) ;; From Weavejester's Hiccup, via pump: (def ^{:doc "Regular expression that parses a CSS-style id and class @@ -120,141 +121,6 @@ (convert-custom-prop-value props) (convert-prop-value props)))) -;;; Specialization for input components - -;; This gets set from reagent.dom -(defonce find-dom-node nil) - -;; -;; The properites 'selectionStart' and 'selectionEnd' only exist on some inputs -;; See: https://html.spec.whatwg.org/multipage/forms.html#do-not-apply -(def these-inputs-have-selection-api #{"text" "textarea" "password" "search" - "tel" "url"}) - -(defn ^boolean has-selection-api? - [input-type] - (contains? these-inputs-have-selection-api input-type)) - -(declare input-component-set-value) - -(defn input-node-set-value - [node rendered-value dom-value ^clj component {:keys [on-write]}] - (if-not (and (identical? node (.-activeElement js/document)) - (has-selection-api? (.-type node)) - (string? rendered-value) - (string? dom-value)) - ;; just set the value, no need to worry about a cursor - (do - (set! (.-cljsDOMValue component) rendered-value) - (set! (.-value node) rendered-value) - (when (fn? on-write) - (on-write 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 (.-value node)] - (if (not= node-value dom-value) - ;; IE has not notified us of the change yet, so check again later - (batch/do-after-render #(input-component-set-value component)) - (let [existing-offset-from-end (- (count node-value) - (.-selectionStart node)) - new-cursor-offset (- (count rendered-value) - existing-offset-from-end)] - (set! (.-cljsDOMValue component) rendered-value) - (set! (.-value node) rendered-value) - (when (fn? on-write) - (on-write rendered-value)) - (set! (.-selectionStart node) new-cursor-offset) - (set! (.-selectionEnd node) new-cursor-offset)))))) - -(defn input-component-set-value [^clj this] - (when (.-cljsInputLive this) - (set! (.-cljsInputDirty this) false) - (let [rendered-value (.-cljsRenderedValue this) - dom-value (.-cljsDOMValue this) - ;; Default to the root node within this component - node (find-dom-node this)] - (when (not= rendered-value dom-value) - (input-node-set-value node rendered-value dom-value this {}))))) - -(defn input-handle-change [^clj this on-change e] - (set! (.-cljsDOMValue this) (-> e .-target .-value)) - ;; Make sure the input is re-rendered, in case on-change - ;; wants to keep the value unchanged - (when-not (.-cljsInputDirty this) - (set! (.-cljsInputDirty this) true) - (batch/do-after-render #(input-component-set-value this))) - (on-change e)) - -(defn input-render-setup - [^clj this ^js 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")) - (assert find-dom-node - "reagent.dom needs to be loaded for controlled input to work") - (let [v (.-value jsprops) - value (if (nil? v) "" v) - on-change (.-onChange jsprops)] - (when-not (.-cljsInputLive this) - ;; set initial value - (set! (.-cljsInputLive this) true) - (set! (.-cljsDOMValue this) value)) - (set! (.-cljsRenderedValue this) value) - (js-delete jsprops "value") - (set! (.-defaultValue jsprops) value) - (set! (.-onChange jsprops) #(input-handle-change this on-change %))))) - -(defn input-unmount [^clj this] - (set! (.-cljsInputLive this) nil)) - -(defn ^boolean input-component? [x] - (case x - ("input" "textarea") true - false)) - -(def reagent-input-class nil) - -(declare make-element) - -(def input-spec - {:display-name "ReagentInput" - :component-did-update input-component-set-value - :component-will-unmount input-unmount - :reagent-render - (fn [argv component jsprops first-child opts] - (let [this comp/*current-component*] - (input-render-setup this jsprops) - (make-element argv component jsprops first-child opts)))}) - -(defn reagent-input - [] - (when (nil? reagent-input-class) - (set! reagent-input-class (comp/create-class input-spec))) - reagent-input-class) - - ;;; Conversion from Hiccup forms (deftype HiccupTag [tag id className custom]) @@ -329,8 +195,8 @@ jsprops (or (convert-props (if hasprops props) parsed) #js {}) first-child (+ first (if hasprops 1 0))] - (if (input-component? component) - (-> [(reagent-input) argv component jsprops first-child opts] + (if (input/input-component? component) + (-> [(input/reagent-input) argv component jsprops first-child opts] (with-meta (meta argv)) (as-element opts)) (do @@ -435,3 +301,5 @@ (.push a (as-element v opts))) a) #js[component jsprops] argv)))) + +(set! input/make-element make-element)