Merge pull request #282 from arbscht/synthetic-input

Support synthetic text input components
This commit is contained in:
Juho Teperi 2017-10-20 10:20:42 +03:00 committed by GitHub
commit 35908d142d
2 changed files with 163 additions and 78 deletions

View File

@ -50,9 +50,11 @@
(defn adapt-react-class (defn adapt-react-class
"Returns an adapter for a native React class, that may be used "Returns an adapter for a native React class, that may be used
just like a Reagent component function or class in Hiccup forms." just like a Reagent component function or class in Hiccup forms."
[c] ([c opts]
(assert-some c "Component") (assert-some c "Component")
(tmpl/adapt-react-class c)) (tmpl/adapt-react-class c opts))
([c]
(adapt-react-class c {})))
(defn reactify-component (defn reactify-component
"Returns an adapter for a Reagent component, that may be used from "Returns an adapter for a Reagent component, that may be used from

View File

@ -120,55 +120,68 @@
[input-type] [input-type]
(contains? these-inputs-have-selection-api input-type)) (contains? these-inputs-have-selection-api input-type))
(defn input-set-value [this] (declare input-component-set-value)
(defn input-node-set-value
[node rendered-value dom-value component {:keys [on-write]}]
(if-not (and (identical? node ($ js/document :activeElement))
(has-selection-api? ($ node :type))
(string? rendered-value)
(string? dom-value))
;; just set the value, no need to worry about a cursor
(do
($! component :cljsDOMValue rendered-value)
($! node :value 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 ($ 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 #(input-component-set-value component))
(let [existing-offset-from-end (- (count node-value)
($ node :selectionStart))
new-cursor-offset (- (count rendered-value)
existing-offset-from-end)]
($! component :cljsDOMValue rendered-value)
($! node :value rendered-value)
(when (fn? on-write)
(on-write rendered-value))
($! node :selectionStart new-cursor-offset)
($! node :selectionEnd new-cursor-offset))))))
(defn input-component-set-value [this]
(when ($ this :cljsInputLive) (when ($ this :cljsInputLive)
($! this :cljsInputDirty false) ($! this :cljsInputDirty false)
(let [rendered-value ($ this :cljsRenderedValue) (let [rendered-value ($ this :cljsRenderedValue)
dom-value ($ this :cljsDOMValue) dom-value ($ this :cljsDOMValue)
node (find-dom-node this)] node (find-dom-node this) ;; Default to the root node within this component
synthetic-on-update ($ this :cljsSyntheticOnUpdate)]
(when (not= rendered-value dom-value) (when (not= rendered-value dom-value)
(if-not (and (identical? node ($ js/document :activeElement)) (if (fn? synthetic-on-update)
(has-selection-api? ($ node :type)) (synthetic-on-update input-node-set-value node rendered-value dom-value this)
(string? rendered-value) (input-node-set-value node rendered-value dom-value this {}))))))
(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 #(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)))))))))
(defn input-handle-change [this on-change e] (defn input-handle-change [this on-change e]
($! this :cljsDOMValue (-> e .-target .-value)) ($! this :cljsDOMValue (-> e .-target .-value))
@ -176,29 +189,38 @@
;; wants to keep the value unchanged ;; wants to keep the value unchanged
(when-not ($ this :cljsInputDirty) (when-not ($ this :cljsInputDirty)
($! this :cljsInputDirty true) ($! this :cljsInputDirty true)
(batch/do-after-render #(input-set-value this))) (batch/do-after-render #(input-component-set-value this)))
(on-change e)) (on-change e))
(defn input-render-setup [this jsprops] (defn input-render-setup
;; Don't rely on React for updating "controlled inputs", since it ([this jsprops {:keys [synthetic-on-update synthetic-on-change]}]
;; doesn't play well with async rendering (misses keystrokes). ;; Don't rely on React for updating "controlled inputs", since it
(when (and (some? jsprops) ;; doesn't play well with async rendering (misses keystrokes).
(.hasOwnProperty jsprops "onChange") (when (and (some? jsprops)
(.hasOwnProperty jsprops "value")) (.hasOwnProperty jsprops "onChange")
(assert find-dom-node (.hasOwnProperty jsprops "value"))
"reagent.dom needs to be loaded for controlled input to work") (assert find-dom-node
(let [v ($ jsprops :value) "reagent.dom needs to be loaded for controlled input to work")
value (if (nil? v) "" v) (when synthetic-on-update
on-change ($ jsprops :onChange)] ;; Pass along any synthetic input setter given
(when-not ($ this :cljsInputLive) ($! this :cljsSyntheticOnUpdate synthetic-on-update))
;; set initial value (let [v ($ jsprops :value)
($! this :cljsInputLive true) value (if (nil? v) "" v)
($! this :cljsDOMValue value)) on-change ($ jsprops :onChange)
($! this :cljsRenderedValue value) on-change (if synthetic-on-change
(js-delete jsprops "value") (partial synthetic-on-change on-change)
(doto jsprops on-change)]
($! :defaultValue value) (when-not ($ this :cljsInputLive)
($! :onChange #(input-handle-change this on-change %)))))) ;; set initial value
($! this :cljsInputLive true)
($! this :cljsDOMValue value))
($! this :cljsRenderedValue value)
(js-delete jsprops "value")
(doto jsprops
($! :defaultValue value)
($! :onChange #(input-handle-change this on-change %))))))
([this jsprops]
(input-render-setup this jsprops {})))
(defn input-unmount [this] (defn input-unmount [this]
($! this :cljsInputLive nil)) ($! this :cljsInputLive nil))
@ -210,11 +232,13 @@
(def reagent-input-class nil) (def reagent-input-class nil)
(def reagent-synthetic-input-class nil)
(declare make-element) (declare make-element)
(def input-spec (def input-spec
{:display-name "ReagentInput" {:display-name "ReagentInput"
:component-did-update input-set-value :component-did-update input-component-set-value
:component-will-unmount input-unmount :component-will-unmount input-unmount
:reagent-render :reagent-render
(fn [argv comp jsprops first-child] (fn [argv comp jsprops first-child]
@ -222,11 +246,31 @@
(input-render-setup this jsprops) (input-render-setup this jsprops)
(make-element argv comp jsprops first-child)))}) (make-element argv comp jsprops first-child)))})
(defn reagent-input [] (def synthetic-input-spec
;; Same as `input-spec` except it takes another argument for `input-setter`
{:display-name "ReagentSyntheticInput"
:component-did-update input-component-set-value
:component-will-unmount input-unmount
:reagent-render
(fn [on-update on-change argv comp jsprops first-child]
(let [this comp/*current-component*]
(input-render-setup this jsprops {:synthetic-on-update on-update
:synthetic-on-change on-change})
(make-element argv comp jsprops first-child)))})
(defn reagent-input
[]
(when (nil? reagent-input-class) (when (nil? reagent-input-class)
(set! reagent-input-class (comp/create-class input-spec))) (set! reagent-input-class (comp/create-class input-spec)))
reagent-input-class) reagent-input-class)
(defn reagent-synthetic-input
[]
(when (nil? reagent-synthetic-input-class)
(set! reagent-synthetic-input-class (comp/create-class synthetic-input-spec)))
reagent-synthetic-input-class)
;;; Conversion from Hiccup forms ;;; Conversion from Hiccup forms
@ -262,11 +306,39 @@
($! jsprops :key key)) ($! jsprops :key key))
(react/createElement c jsprops))) (react/createElement c jsprops)))
(defn adapt-react-class [c] (defn adapt-react-class
(doto (->NativeWrapper) ([c {:keys [synthetic-input]}]
($! :name c) (let [on-update (:on-update synthetic-input)
($! :id nil) on-change (:on-change synthetic-input)]
($! :class nil))) (when synthetic-input
(assert (fn? on-update))
(assert (fn? on-change)))
(let [wrapped (doto (->NativeWrapper)
($! :name c)
($! :id nil)
($! :class nil))
wrapped (if synthetic-input
(doto wrapped
($! :syntheticInput true))
wrapped)
wrapped (if synthetic-input
(doto wrapped
($! :syntheticOnChange on-change))
wrapped)
wrapped (if synthetic-input
;; This is a synthetic input component, i.e. it has a complex
;; nesting of elements such that the root node is not necessarily
;; the <input> tag we need to control, and/or it needs to execute
;; custom code when updated values are written so we provide an affordance
;; to configure a setter fn that can choose a different DOM node
;; than the root node if it wants, and can supply a function hooked
;; to value updates so it can maintain its own component state as needed.
(doto wrapped
($! :syntheticOnUpdate on-update))
wrapped)]
wrapped)))
([c]
(adapt-react-class c {})))
(def tag-name-cache #js{}) (def tag-name-cache #js{})
@ -278,13 +350,24 @@
(declare as-element) (declare as-element)
(defn native-element [parsed argv first] (defn native-element [parsed argv first]
(let [comp ($ parsed :name)] (let [comp ($ parsed :name)
synthetic-input ($ parsed :syntheticInput)]
(let [props (nth argv first nil) (let [props (nth argv first nil)
hasprops (or (nil? props) (map? props)) hasprops (or (nil? props) (map? props))
jsprops (convert-props (if hasprops props) parsed) jsprops (convert-props (if hasprops props) parsed)
first-child (+ first (if hasprops 1 0))] first-child (+ first (if hasprops 1 0))]
(if (input-component? comp) (if (or synthetic-input (input-component? comp))
(-> [(reagent-input) argv comp jsprops first-child] (-> (if synthetic-input
;; If we are dealing with a synthetic input, use the synthetic-input-spec form:
[(reagent-synthetic-input)
($ parsed :syntheticOnUpdate)
($ parsed :syntheticOnChange)
argv
comp
jsprops
first-child]
;; Else use the regular input-spec form:
[(reagent-input) argv comp jsprops first-child])
(with-meta (meta argv)) (with-meta (meta argv))
as-element) as-element)
(let [key (-> (meta argv) get-key) (let [key (-> (meta argv) get-key)