mirror of
https://github.com/penpot/penpot.git
synced 2025-06-06 23:41:40 +02:00
501 lines
17 KiB
Clojure
501 lines
17 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC
|
|
|
|
(ns app.main.ui.components.forms
|
|
(:require-macros [app.main.style :as stl])
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.data.macros :as dm]
|
|
[app.main.ui.components.select :as cs]
|
|
[app.main.ui.hooks :as hooks]
|
|
[app.main.ui.icons :as i]
|
|
[app.util.dom :as dom]
|
|
[app.util.forms :as fm]
|
|
[app.util.i18n :as i18n :refer [tr]]
|
|
[app.util.keyboard :as kbd]
|
|
[app.util.object :as obj]
|
|
[cljs.core :as c]
|
|
[clojure.string]
|
|
[cuerdas.core :as str]
|
|
[rumext.v2 :as mf]))
|
|
|
|
(def form-ctx (mf/create-context nil))
|
|
(def use-form fm/use-form)
|
|
|
|
(mf/defc input
|
|
[{:keys [label help-icon disabled form hint trim children data-test on-change-value placeholder show-success?] :as props}]
|
|
(let [input-type (get props :type "text")
|
|
input-name (get props :name)
|
|
more-classes (get props :class)
|
|
auto-focus? (get props :auto-focus? false)
|
|
placeholder (or placeholder label)
|
|
|
|
form (or form (mf/use-ctx form-ctx))
|
|
|
|
type' (mf/use-state input-type)
|
|
focus? (mf/use-state false)
|
|
|
|
is-checkbox? (= @type' "checkbox")
|
|
is-radio? (= @type' "radio")
|
|
is-text? (or (= @type' "password")
|
|
(= @type' "text")
|
|
(= @type' "email"))
|
|
|
|
touched? (get-in @form [:touched input-name])
|
|
error (get-in @form [:errors input-name])
|
|
|
|
value (get-in @form [:data input-name] "")
|
|
|
|
help-icon' (cond
|
|
(and (= input-type "password")
|
|
(= @type' "password"))
|
|
i/shown-refactor
|
|
|
|
(and (= input-type "password")
|
|
(= @type' "text"))
|
|
i/hide-refactor
|
|
|
|
:else
|
|
help-icon)
|
|
|
|
on-change-value (or on-change-value (constantly nil))
|
|
|
|
swap-text-password
|
|
(fn []
|
|
(swap! type' (fn [input-type]
|
|
(if (= "password" input-type)
|
|
"text"
|
|
"password"))))
|
|
|
|
on-focus #(reset! focus? true)
|
|
on-change (fn [event]
|
|
(let [value (-> event dom/get-target dom/get-input-value)]
|
|
(swap! form assoc-in [:touched input-name] true)
|
|
(fm/on-input-change form input-name value trim)
|
|
(on-change-value name value)))
|
|
|
|
on-blur
|
|
(fn [_]
|
|
(reset! focus? false))
|
|
|
|
on-click
|
|
(fn [_]
|
|
(when-not (get-in @form [:touched input-name])
|
|
(swap! form assoc-in [:touched input-name] true)))
|
|
|
|
props (-> props
|
|
(dissoc :help-icon :form :trim :children :show-success?)
|
|
(assoc :id (name input-name)
|
|
:value value
|
|
:auto-focus auto-focus?
|
|
:on-click (when (or is-radio? is-checkbox?) on-click)
|
|
:on-focus on-focus
|
|
:on-blur on-blur
|
|
:placeholder placeholder
|
|
:on-change on-change
|
|
:type @type'
|
|
:tab-index "0")
|
|
(cond-> (and value is-checkbox?) (assoc :default-checked value))
|
|
(cond-> (and touched? (:message error)) (assoc "aria-invalid" "true"
|
|
"aria-describedby" (dm/str "error-" input-name)))
|
|
(obj/clj->props))
|
|
|
|
show-valid? (and show-success? touched? (not error))
|
|
show-invalid? (and touched? error)]
|
|
|
|
[:div {:class (dm/str more-classes " "
|
|
(stl/css-case
|
|
:input-wrapper true
|
|
:valid show-valid?
|
|
:invalid show-invalid?
|
|
:checkbox is-checkbox?
|
|
:disabled disabled))}
|
|
[:*
|
|
(cond
|
|
(some? label)
|
|
[:label {:class (stl/css-case :input-with-label (not is-checkbox?)
|
|
:input-label is-text?
|
|
:radio-label is-radio?
|
|
:checkbox-label is-checkbox?)
|
|
:tab-index "-1"
|
|
:for (name input-name)} label
|
|
|
|
(when is-checkbox?
|
|
[:span {:class (stl/css-case :global/checked value)} i/status-tick-refactor])
|
|
|
|
(if is-checkbox?
|
|
[:> :input props]
|
|
|
|
[:div {:class (stl/css :input-and-icon)}
|
|
[:> :input props]
|
|
(when help-icon'
|
|
[:span {:class (stl/css :help-icon)
|
|
:on-click (when (= "password" input-type)
|
|
swap-text-password)}
|
|
help-icon'])
|
|
|
|
(when show-valid?
|
|
[:span {:class (stl/css :valid-icon)}
|
|
i/tick-refactor])
|
|
|
|
(when show-invalid?
|
|
[:span {:class (stl/css :invalid-icon)}
|
|
i/close-refactor])])]
|
|
|
|
(some? children)
|
|
[:label {:for (name input-name)}
|
|
[:> :input props]
|
|
children])
|
|
|
|
(cond
|
|
(and touched? (:message error))
|
|
[:div {:id (dm/str "error-" input-name)
|
|
:class (stl/css :error)
|
|
:data-test (clojure.string/join [data-test "-error"])}
|
|
(tr (:message error))]
|
|
|
|
(string? hint)
|
|
[:div {:class (stl/css :hint)} hint])]]))
|
|
|
|
(mf/defc textarea
|
|
[{:keys [label disabled form hint trim] :as props}]
|
|
(let [input-name (get props :name)
|
|
|
|
form (or form (mf/use-ctx form-ctx))
|
|
|
|
focus? (mf/use-state false)
|
|
|
|
touched? (get-in @form [:touched input-name])
|
|
error (get-in @form [:errors input-name])
|
|
|
|
value (get-in @form [:data input-name] "")
|
|
|
|
klass (dom/classnames
|
|
:focus @focus?
|
|
:valid (and touched? (not error))
|
|
:invalid (and touched? error)
|
|
:disabled disabled)
|
|
;; :empty (str/empty? value)
|
|
|
|
|
|
on-focus #(reset! focus? true)
|
|
on-change (fn [event]
|
|
(let [target (dom/get-target event)
|
|
value (dom/get-value target)]
|
|
(fm/on-input-change form input-name value trim)))
|
|
|
|
on-blur
|
|
(fn [_]
|
|
(reset! focus? false)
|
|
(when-not (get-in @form [:touched input-name])
|
|
(swap! form assoc-in [:touched input-name] true)))
|
|
|
|
props (-> props
|
|
(dissoc :help-icon :form :trim)
|
|
(assoc :value value
|
|
:on-focus on-focus
|
|
:on-blur on-blur
|
|
;; :placeholder label
|
|
:on-change on-change)
|
|
(obj/clj->props))]
|
|
|
|
[:div.custom-input
|
|
{:class klass}
|
|
[:*
|
|
[:label label]
|
|
[:> :textarea props]
|
|
(cond
|
|
(and touched? (:message error))
|
|
[:span.error (tr (:message error))]
|
|
|
|
(string? hint)
|
|
[:span.hint hint])]]))
|
|
|
|
(mf/defc select
|
|
[{:keys [options disabled form default] :as props
|
|
:or {default ""}}]
|
|
(let [input-name (get props :name)
|
|
form (or form (mf/use-ctx form-ctx))
|
|
value (or (get-in @form [:data input-name]) default)
|
|
|
|
handle-change
|
|
(fn [event]
|
|
(let [value (if (string? event) event (dom/get-target-val event))]
|
|
(fm/on-input-change form input-name value)))]
|
|
|
|
[:div {:class (stl/css :select-wrapper)}
|
|
[:& cs/select
|
|
{:default-value value
|
|
:disabled disabled
|
|
:options options
|
|
:on-change handle-change}]]))
|
|
|
|
(mf/defc radio-buttons
|
|
{::mf/wrap-props false}
|
|
[props]
|
|
(let [form (or (unchecked-get props "form")
|
|
(mf/use-ctx form-ctx))
|
|
name (unchecked-get props "name")
|
|
|
|
current-value (or (dm/get-in @form [:data name] "")
|
|
(unchecked-get props "value"))
|
|
on-change (unchecked-get props "on-change")
|
|
options (unchecked-get props "options")
|
|
trim? (unchecked-get props "trim")
|
|
encode-fn (d/nilv (unchecked-get props "encode-fn") identity)
|
|
decode-fn (d/nilv (unchecked-get props "decode-fn") identity)
|
|
|
|
on-change'
|
|
(mf/use-fn
|
|
(mf/deps on-change form name)
|
|
(fn [event]
|
|
(let [value (-> event dom/get-target dom/get-value decode-fn)]
|
|
(when (some? form)
|
|
(swap! form assoc-in [:touched name] true)
|
|
(fm/on-input-change form name value trim?))
|
|
|
|
(when (fn? on-change)
|
|
(on-change name value)))))]
|
|
[:div {:class (stl/css :custom-radio)}
|
|
(for [{:keys [image value label]} options]
|
|
(let [image? (some? image)
|
|
value' (encode-fn value)
|
|
checked? (= value current-value)
|
|
key (str/ffmt "%-%" (d/name name) (d/name value'))]
|
|
[:label {:for key
|
|
:key key
|
|
:style {:background-image (when image? (str/ffmt "url(%)" image))}
|
|
:class (stl/css-case :radio-label true
|
|
:global/checked checked?
|
|
:with-image image?)}
|
|
[:input {:on-change on-change'
|
|
:type "radio"
|
|
:class (stl/css :radio-input)
|
|
:id key
|
|
:name name
|
|
:value value'
|
|
:checked checked?}]
|
|
(when (not image?)
|
|
[:span {:class (stl/css-case :radio-icon true
|
|
:global/checked checked?)}
|
|
(when checked? [:span {:class (stl/css :radio-dot)}])])
|
|
|
|
label]))]))
|
|
|
|
(mf/defc submit-button*
|
|
{::mf/wrap-props false}
|
|
[props]
|
|
(let [form (or (unchecked-get props "form")
|
|
(mf/use-ctx form-ctx))
|
|
|
|
label (unchecked-get props "label")
|
|
on-click (unchecked-get props "onClick")
|
|
children (unchecked-get props "children")
|
|
|
|
class (d/nilv (unchecked-get props "className") "btn-primary btn-large")
|
|
name (d/nilv (unchecked-get props "name") "submit")
|
|
|
|
disabled? (or (and (some? form) (not (:valid @form)))
|
|
(true? (unchecked-get props "disabled")))
|
|
new-klass (dm/str class " " (if disabled? (stl/css :btn-disabled) ""))
|
|
|
|
on-key-down
|
|
(mf/use-fn
|
|
(mf/deps on-click)
|
|
(fn [event]
|
|
(when (and (kbd/enter? event) (fn? on-click))
|
|
(on-click event))))
|
|
|
|
props (-> (obj/clone props)
|
|
(obj/unset! "children")
|
|
(obj/set! "disabled" disabled?)
|
|
(obj/set! "onKeyDown" on-key-down)
|
|
(obj/set! "name" name)
|
|
(obj/set! "label" mf/undefined)
|
|
(obj/set! "className" new-klass)
|
|
(obj/set! "type" "submit"))]
|
|
|
|
[:> "button" props
|
|
(if (some? children)
|
|
children
|
|
[:span label])]))
|
|
|
|
(mf/defc form
|
|
{::mf/wrap-props false}
|
|
[{:keys [on-submit form children class]}]
|
|
(let [on-submit' (mf/use-fn
|
|
(fn [event]
|
|
(dom/prevent-default event)
|
|
(when (fn? on-submit)
|
|
(on-submit form event))))]
|
|
[:& (mf/provider form-ctx) {:value form}
|
|
[:form {:class class :on-submit on-submit'} children]]))
|
|
|
|
(defn- conj-dedup
|
|
"A helper that adds item into a vector and removes possible
|
|
duplicates. This is not very efficient implementation but is ok for
|
|
handling form input that will have a small number of items."
|
|
[coll item]
|
|
(into [] (distinct) (conj coll item)))
|
|
|
|
(mf/defc multi-input
|
|
[{:keys [form label class name trim valid-item-fn caution-item-fn on-submit] :as props}]
|
|
(let [form (or form (mf/use-ctx form-ctx))
|
|
input-name (get props :name)
|
|
touched? (get-in @form [:touched input-name])
|
|
error (get-in @form [:errors input-name])
|
|
focus? (mf/use-state false)
|
|
|
|
items (mf/use-state [])
|
|
value (mf/use-state "")
|
|
result (hooks/use-equal-memo @items)
|
|
|
|
empty? (and (str/empty? @value)
|
|
(zero? (count @items)))
|
|
|
|
klass (str (get props :class) " "
|
|
(stl/css-case
|
|
:focus @focus?
|
|
:valid (and touched? (not error))
|
|
:invalid (and touched? error)
|
|
:empty empty?
|
|
:custom-multi-input true))
|
|
|
|
in-klass (str class " "
|
|
(stl/css-case
|
|
:inside-input true
|
|
:no-padding (pos? (count @items))))
|
|
|
|
on-focus
|
|
(mf/use-fn #(reset! focus? true))
|
|
|
|
on-change
|
|
(mf/use-fn
|
|
(fn [event]
|
|
(let [content (-> event dom/get-target dom/get-input-value)]
|
|
(reset! value content))))
|
|
|
|
update-form!
|
|
(mf/use-fn
|
|
(mf/deps form)
|
|
(fn [items]
|
|
(let [value (str/join " " (map :text items))]
|
|
(fm/update-input-value! form input-name value))))
|
|
|
|
on-key-down
|
|
(mf/use-fn
|
|
(mf/deps @value)
|
|
(fn [event]
|
|
(cond
|
|
(or (kbd/enter? event)
|
|
(kbd/comma? event))
|
|
(do
|
|
(dom/prevent-default event)
|
|
(dom/stop-propagation event)
|
|
(let [val (cond-> @value trim str/trim)]
|
|
(when (and (kbd/enter? event) (str/empty? @value) (not-empty @items))
|
|
(on-submit form))
|
|
(when (not (str/empty? @value))
|
|
(reset! value "")
|
|
(swap! items conj-dedup {:text val
|
|
:valid (valid-item-fn val)
|
|
:caution (caution-item-fn val)}))))
|
|
|
|
(and (kbd/backspace? event)
|
|
(str/empty? @value))
|
|
(do
|
|
(dom/prevent-default event)
|
|
(dom/stop-propagation event)
|
|
(swap! items (fn [items] (if (c/empty? items) items (pop items))))))))
|
|
|
|
on-blur
|
|
(mf/use-fn
|
|
(fn [_]
|
|
(reset! focus? false)
|
|
(when-not (get-in @form [:touched input-name])
|
|
(swap! form assoc-in [:touched input-name] true))))
|
|
|
|
remove-item!
|
|
(mf/use-fn
|
|
(fn [item]
|
|
(swap! items #(into [] (remove (fn [x] (= x item))) %))))
|
|
|
|
manage-key-down
|
|
(mf/use-fn
|
|
(fn [item event]
|
|
(when (kbd/enter? event)
|
|
(remove-item! item))))]
|
|
|
|
(mf/with-effect [result @value]
|
|
(let [val (cond-> @value trim str/trim)
|
|
values (conj-dedup result {:text val :valid (valid-item-fn val)})
|
|
values (filterv #(:valid %) values)]
|
|
(update-form! values)))
|
|
|
|
[:div {:class klass}
|
|
[:input {:id (name input-name)
|
|
:class in-klass
|
|
:type "text"
|
|
:auto-focus true
|
|
:on-focus on-focus
|
|
:on-blur on-blur
|
|
:on-key-down on-key-down
|
|
:value @value
|
|
:on-change on-change
|
|
:placeholder (when empty? label)}]
|
|
[:label {:for (name input-name)} label]
|
|
|
|
(when-let [items (seq @items)]
|
|
[:div {:class (stl/css :selected-items)}
|
|
(for [item items]
|
|
[:div {:class (stl/css :selected-item)
|
|
:key (:text item)
|
|
:tab-index "0"
|
|
:on-key-down (partial manage-key-down item)}
|
|
[:span {:class (stl/css-case :around true
|
|
:invalid (not (:valid item))
|
|
:caution (:caution item))}
|
|
[:span {:class (stl/css :text)} (:text item)]
|
|
[:button {:class (stl/css :icon)
|
|
:on-click #(remove-item! item)} i/close-refactor]]])])]))
|
|
|
|
;; --- Validators
|
|
|
|
(defn all-spaces?
|
|
[value]
|
|
(let [trimmed (str/trim value)]
|
|
(str/empty? trimmed)))
|
|
|
|
(def max-length-allowed 250)
|
|
(def max-uri-length-allowed 2048)
|
|
|
|
(defn max-length?
|
|
[value length]
|
|
(> (count value) length))
|
|
|
|
(defn validate-length
|
|
[field length errors-msg]
|
|
(fn [errors data]
|
|
(cond-> errors
|
|
(max-length? (get data field) length)
|
|
(assoc field {:message errors-msg}))))
|
|
|
|
(defn validate-not-empty
|
|
[field error-msg]
|
|
(fn [errors data]
|
|
(cond-> errors
|
|
(all-spaces? (get data field))
|
|
(assoc field {:message error-msg}))))
|
|
|
|
(defn validate-not-all-spaces
|
|
[field error-msg]
|
|
(fn [errors data]
|
|
(let [value (get data field)]
|
|
(cond-> errors
|
|
(and
|
|
(all-spaces? value)
|
|
(> (count value) 0))
|
|
(assoc field {:message error-msg})))))
|