0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-03-12 15:51:37 -05:00
penpot/frontend/src/app/main/ui/components/forms.cljs
2024-06-21 09:14:09 +02:00

590 lines
21 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-testid 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)
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"))
placeholder (when is-text? (or placeholder label))
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
(and (= input-type "password")
(= @type' "text"))
i/hide
: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? :auto-focus? :label)
(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/map->obj obj/prop-key-fn))
checked? (and is-checkbox? (= value true))
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-form (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 checked?)} (when checked? i/status-tick)])
(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])
(when show-invalid?
[:span {:class (stl/css :invalid-icon)}
i/close])])]
(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-testid (clojure.string/join [data-testid "-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/map->obj obj/prop-key-fn))]
[:div {:class (dm/str klass " " (stl/css :textarea-wrapper))}
[:label {:class (stl/css :textarea-label)} label]
[:> :textarea props]
(cond
(and touched? (:message error))
[:span {:class (stl/css :error)} (tr (:message error))]
(string? hint)
[:span {:class (stl/css :hint)} hint])]))
(mf/defc select
[{:keys [options disabled form default dropdown-class select-class] :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
:class select-class
:dropdown-class dropdown-class
: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")
image (unchecked-get props "image")
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")
class (unchecked-get props "class")
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 (if image
class
(dm/str class " " (stl/css :custom-radio)))}
(for [{:keys [image icon value label area]} options]
(let [image? (some? image)
icon? (some? icon)
value' (encode-fn value)
checked? (= value current-value)
key (str/ffmt "%-%" (d/name name) (d/name value'))]
[:label {:for key
:key key
:style {:grid-area area}
:class (stl/css-case :radio-label true
:global/checked checked?
:with-image (or image? icon?))}
(cond
image?
[:span {:style {:background-image (str/ffmt "url(%)" image)}
:class (stl/css :image-inside)}]
icon?
[:span {:class (stl/css :icon-inside)} icon]
:else
[:span {:class (stl/css-case :radio-icon true
:global/checked checked?)}
(when checked? [:span {:class (stl/css :radio-dot)}])])
label
[:input {:on-change on-change'
:type "radio"
:class (stl/css :radio-input)
:id key
:name name
:value value'
:checked checked?}]]))]))
(mf/defc image-radio-buttons
{::mf/wrap-props false}
[props]
(let [form (or (unchecked-get props "form")
(mf/use-ctx form-ctx))
name (unchecked-get props "name")
image (unchecked-get props "image")
img-height (unchecked-get props "img-height")
img-width (unchecked-get props "img-width")
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")
class (unchecked-get props "class")
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 (if image
class
(dm/str class " " (stl/css :custom-radio)))}
(for [{:keys [image icon value label area]} options]
(let [icon? (some? icon)
value' (encode-fn value)
checked? (= value current-value)
key (str/ffmt "%-%" (d/name name) (d/name value'))]
[:label {:for key
:key key
:style {:grid-area area}
:class (stl/css-case :radio-label-image true
:global/checked checked?)}
(cond
icon?
[:span {:class (stl/css :icon-inside)
:style {:height img-height
:width img-width}} icon]
:else
[:span {:style {:background-image (str/ffmt "url(%)" image)
:height img-height
:width img-width}
:class (stl/css :image-inside)}])
[:span {:class (stl/css :image-text)} label]
[:input {:on-change on-change'
:type "radio"
:class (stl/css :radio-input)
:id key
:name name
:value value'
:checked checked?}]]))]))
(mf/defc submit-button*
{::mf/wrap-props false}
[{:keys [on-click children label form class name disabled] :as props}]
(let [form (or form (mf/use-ctx form-ctx))
disabled? (or (and (some? form) (not (:valid @form)))
(true? disabled))
class (d/nilv class (stl/css :button-submit))
name (d/nilv name "submit")
on-key-down
(mf/use-fn
(mf/deps on-click)
(fn [event]
(when (and (kbd/enter? event) (fn? on-click))
(on-click event))))
props
(mf/spread-props props {:children mf/undefined
:disabled disabled?
:on-key-down on-key-down
:name name
:labek mf/undefined
:class class
: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
(mf/deps on-submit)
(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))
:invalid (and (some? valid-item-fn)
touched?
(not (str/empty? @value))
(not (valid-item-fn @value)))))
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]
(let [val (cond-> @value trim str/trim)]
(cond
(or (kbd/enter? event) (kbd/comma? event) (kbd/space? event))
(do
(dom/prevent-default event)
(dom/stop-propagation event)
;; Once enter/comma is pressed we mark it as touched
(swap! form assoc-in [:touched input-name] true)
;; Empty values means "submit" the form (whent some items have been added
(when (and (kbd/enter? event) (str/empty? @value) (not-empty @items))
(on-submit form))
;; If we have a string in the input we add it only if valid
(when (and (valid-item-fn val) (not (str/empty? @value)))
(reset! value "")
;; Once added the form is back as "untouched"
(swap! form assoc-in [:touched input-name] false)
;; This split will allow users to copy comma/space separated values of emails
(doseq [val (str/split val #",|\s+")]
(swap! items conj-dedup {:text (str/trim 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]]])])]))
;; --- 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})))))