(ns auto-ap.ssr.components.inputs (:require [auto-ap.ssr.components.tags :as tags] [auto-ap.ssr.hiccup-helper :as hh] [auto-ap.ssr.hx :as hx :refer [js-fn]] [auto-ap.ssr.svg :as svg] [auto-ap.time :as atime] [clojure.string :as str] [hiccup2.core :as hiccup])) (def default-input-classes ["bg-gray-50" "border" "text-sm" "rounded-lg" "" "block" "p-2.5" "border-gray-300" "text-gray-900" "focus:ring-blue-500" "focus:border-blue-500" "dark:bg-gray-700" "dark:border-gray-600" "dark:placeholder-gray-400" "dark:text-white" "dark:focus:ring-blue-500" "dark:focus:border-blue-500" "group-[.has-error]:bg-red-50" "group-[.has-error]:border-red-500" "group-[.has-error]:text-red-900" "group-[.has-error]:placeholder-red-700" "group-[.has-error]:focus:ring-red-500" "group-[.has-error]:dark:bg-gray-700" "group-[.has-error]:focus:border-red-500" "group-[.has-error]:dark:text-red-500" "group-[.has-error]:dark:placeholder-red-500" "group-[.has-error]:dark:border-red-500"]) (def default-checkbox-classes "w-4 h-4 bg-gray-100 indeterminate:bg-gray-300 border-gray-300 rounded text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600") (defn select- [params & children] (into [:select (-> params (dissoc :allow-blank? :value :options) (update :class (fnil hh/add-class "") default-input-classes)) (cond->> (map (fn [[k v]] [:option {:value k :selected (= k (:value params))} v]) (:options params)) (:allow-blank? params) (conj [:option {:value "" :selected (not (:value params))} ""]))] children)) (defn checkbox- [params & rest] (if (seq rest) [:label {:class "text-sm text-gray-800 dark:text-gray-300 "} [:input (merge (dissoc params :indeterminate?) {:type "checkbox" :class (hh/add-class default-checkbox-classes (:class params ""))} (when (:indeterminate? params) {:x-init "$el.indeterminate = true"}))] [:span.ml-2 rest]] [:input (merge (dissoc params :indeterminate params) {:type "checkbox" :class (hh/add-class default-checkbox-classes (:class params ""))} (when (:indeterminate? params) {:x-init "$el.indeterminate = true"})) ])) (defn typeahead- [params] [:div.relative {:x-data (hx/json { :baseUrl (if (str/includes? (:url params) "?") (str (:url params) "&q=") (str (:url params) "?q=")) :value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))} :tippy nil :search "" :active -1 :elements (if ((:value-fn params identity) (:value params)) [{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}] []) }) :x-modelable "value.value" :x-model (:x-model params)} (if (:disabled params) [:span {:x-text "value.label"}] [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) (hh/add-class "cursor-pointer")) "x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" "@keydown.down.prevent.stop" "tippy.show();" "@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }" :tabindex 0 :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-ref "input"} [:input (-> params (dissoc :class) (dissoc :value-fn) (dissoc :content-fn) (dissoc :placeholder) (dissoc :x-model) (assoc "x-ref" "hidden" :type "hidden" ":value" "value.value" :x-init (hiccup/raw (str "$watch('value', v => $dispatch('change')); "))))] [:div.flex.w-full.justify-items-stretch [:span.flex-grow.text-left {"x-text" "value.label"}] [:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"} svg/drop-down] [:div {:x-show "value.warning" } (tags/badge- {:class "peer" :x-tooltip "value.warning"} "!") ]]]) [:template {:x-ref "dropdown"} [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1" "@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; " :x-destroy "if ($refs.input) {$refs.input.focus();}"} [:input {:type "text" :autofocus true :class (-> (:class params) (or "") (hh/add-class default-input-classes) (hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full")) "x-model" "search" "placeholder" (:placeholder params) "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" "@keydown.up.prevent" "active --; active = active < 0 ? 0 : active" "@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active] : {'value': '', label: ''}; $refs.input.focus()" "x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(baseUrl + s).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}] [:div.dropdown-options {:class "rounded-b-lg overflow-hidden"} [:template {:x-for "(element, index) in elements"} [:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100" :href "#" ":class" "active == index ? 'active' : ''" "@mouseover" "active = index" "@mouseout" "active = -1" "@click.prevent" "value = element; tippy.hide(); $refs.input.focus()" "x-html" "element.label"}]]] [:template {:x-if "elements.length == 0"} [:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "} "No results found"]]]]]]) (defn multi-typeahead-dropdown- [params] [:template {:x-ref "dropdown"} [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4" "@keydown.escape.prevent" "tippy.hide();" :x-destroy "if ($refs.input) {$refs.input.focus();}"} [:div {:class (-> "relative" #_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))} [:div {:class "absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none"} [:svg {:class "w-4 h-4 text-gray-500 dark:text-gray-400", :aria-hidden "true", :xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 20 20"} [:path {:stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "2", :d "m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"}]]] [:input {:type "text" :class (-> (:class params) (or "") (hh/add-class "block w-full p-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500") (hh/add-class default-input-classes)) "x-model" "search" "placeholder" (:placeholder params) "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" "@keydown.up.prevent" "active --; active = active < 0 ? 0 : active" "@keydown.enter.prevent.stop" "if ($data.elements[active]) { if (value.has($data.elements[active].value)) { value.delete($data.elements[active].value) } else {value.add($data.elements[active].value); lookup[$data.elements[active].value] = $data.elements[active].label} } " "x-init" " $el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(baseUrl + s).then(data=>data.json()).then(data => reset_elements(data)) }})"}]] [:div.dropdown-options {:class "overflow-hidden divide-y divide-gray-200 "} [:template {:x-for "(element, index) in elements"} [:li {":style" "index == 0 && 'border: 0 !important;'"} [:label {:class "p-3 group rounded flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-300 [&.active]:dark:bg-primary-700 [&.implied]:text-gray-500 text-gray-800 dark:text-gray-100 cursor-pointer" :href "#" ":class" (hx/json {"active" (hx/js-fn "active==index") "implied" (hx/js-fn "all_selected && index != 0") } ) "@mouseover" "active = index" "@mouseout" "active = -1" "@click.prevent" "toggle(element)"} (checkbox- {":checked" "value.has(element.value) || all_selected" :class "group-[&.implied]:bg-green-200" }) #_[:input {:type "checkbox" }] [:span {"x-html" "element.label"}]]]] [:template {:x-if "elements.length == 0"} [:li {:class "px-4 pt-4 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs " "style" "border: 0 !important"} "No results found"]]]]]) (defn multi-typeahead-selected-pill- [params] [:div.flex-grow.flex [:template {:x-if "value.size > 0"} [:a.bg-blue-100.rounded-full.px-3 [:span.text-left [:span {"x-text" "value.has('all') ? 'All' : value.size"}] " selected"] ]] [:template {:x-if "value.size == 0"} [:span.text-left.text-gray-400 "None selected"]] [:div {:class "w-4 h-4 ml-2 inline text-gray-500 self-center rounded-full bg-gray-100 text-gray-500" "@click.prevent.stop" "value = new Set([]); all_selected=false;" :x-show "value.size > 0"} svg/x]]) (defn multi-typeahead- [params] [:div.relative {:x-data (doto (hx/json {:baseUrl (if (str/includes? (:url params) "?") (str (:url params) "&q=") (str (:url params) "?q=")) :reset_elements (js-fn "function(e) { this.elements = [{value: 'all', label:'All'}].concat(e); this.active = -1 }") :toggle (js-fn "function(e) { if (e.value == 'all') { if (this.value.size > 0) { this.value = new Set([]); this.all_selected = false; } else { this.value = new Set(['all']); this.all_selected = true; } } else { if (this.all_selected) { this.value.delete('all') this.all_selected = false; } if (this.value.has(e.value)) { this.value.delete(e.value) } else { this.value.add(e.value); this.lookup[e.value] = e.label } } }") :all_selected (boolean (= (:value params) :all)), :value (cond (= :all (:value params)) ["all"] (sequential? (:value params)) (map (fn [v] ((:value-fn params identity) v)) (:value params)) :else []) :tippy nil :lookup (into {} (when (sequential? (:value params)) (map (fn [v] [((:value-fn params identity) v) ((:content-fn params identity) v)]) (:value params)))) :x-init (str "$watch('value', v => $dispatch('change')); ") :search "" :active -1 :elements (cond-> [{:value "all" :label "All"}] (sequential? (:value params)) (into (map (fn [v] {:value ((:value-fn params identity) v) :label ((:content-fn params identity) v)}) (:value params)))) :x-ref "r"}) println) ;; :x-modelable "value.value" TODO ;; :x-model (:x-model params) TODO :x-init "value=new Set(value || []); "} (if (:disabled params) [:span {:x-text "value.label"}] [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) (hh/add-class "cursor-pointer")) "x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" "@keydown.down.prevent.stop" "tippy.show();" "@keydown.backspace" "tippy.hide(); value=new Set( []);" :tabindex 0 :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-ref "input"} [:template {:x-for "v in Array.from(value.values())"} [:input (-> params (dissoc :class :value-fn :content-fn :placeholder :x-model) (assoc :type "hidden" "x-bind:value" "v"))]] [:template {:x-if "value.size == 0"} [:input (-> params (dissoc :class :value-fn :content-fn :placeholder :x-model) (assoc :type "hidden" :value "" ))]] [:div.flex.w-full.justify-items-stretch (multi-typeahead-selected-pill- params) [:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"} svg/drop-down] [:div {:x-show "value.warning" :x-ref "warning_badge" :x-effect "if (value.warning) { $nextTick(()=> warning_badge.update()) }"} (tags/badge- {:class "peer"} "!") [:div {:x-show "value.warning" :x-ref "warning_pop" :class "hidden peer-hover:block bg-red-50 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 p-4" :x-text "value.warning"}]]] (multi-typeahead-dropdown- params) ])]) (defn use-size [size] (if (= :small size) (str " " "text-xs p-2") (str " " "text-sm p-2.25"))) (defn text-input- [{:keys [size error?] :as params}] [:input (-> params (dissoc :error?) (assoc :type "text" :autocomplete "off") (update :class #(-> "" (hh/add-class default-input-classes) (hh/add-class %))) (update :class #(str % (use-size size))))]) (defn text-area- [{:keys [] :as params}] [:textarea (-> params (update :class #(-> "" (hh/add-class default-input-classes) (hh/add-class %)))) ] ) (defn money-input- [{:keys [size] :as params}] [:input (-> params (update :class (fnil hh/add-class "") default-input-classes) (update :class hh/add-class "appearance-none text-right") (update :class #(str % (use-size size))) (assoc :type "number" :step "0.01") (dissoc :size))]) (defn int-input- [{:keys [size] :as params}] [:input (-> params (update :class (fnil hh/add-class "") default-input-classes) (update :class hh/add-class "appearance-none text-right") (update :class #(str % (use-size size))) (assoc :type "number" :step "1") (dissoc :size))]) (defn date-input- [{:keys [size] :as params}] [:div.shrink {:x-data (hx/json {:value (:value params)})} [:input (-> params (update :class (fnil hh/add-class "") default-input-classes) (assoc :x-modelable "value") (assoc :type "text") (assoc :x-data (hx/json {:dp nil}) ) (assoc :x-init " dp = initDatepicker($el);") (assoc "@htmx:before-history-save" "destroyDatepicker(dp)" ) (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)" ) (assoc "@change" "value = $event.target.value;") (assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") ")) (update :class #(str % (use-size size) " w-full")) (dissoc :size))]]) (defn multi-date-input- [{:keys [size] :as params}] (let [value (str/join ", " (for [v (:value params) :when v] (some-> v (atime/unparse-local atime/normal-date))))] (println "MY VALUE IS" value) [:div.shrink {:x-data (hx/json {:value value})} [:input (-> params (update :class (fnil hh/add-class "") default-input-classes) (assoc :x-modelable "value") (assoc :type "text") (assoc :x-data (hx/json {:dp nil}) ) (assoc :value value) (assoc :x-init " dp = initMultiDatepicker($el);") (assoc "@htmx:before-history-save" "destroyDatepicker(dp)" ) (assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)" ) (assoc "@change" "value = $event.target.value;") (assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") ")) (update :class #(str % (use-size size) " w-full")) (dissoc :size))]])) (defn field-errors- [{:keys [source key]} & rest] (let [errors (:errors (cond-> (meta source) key (get key)))] [:p.mt-2.text-xs.text-red-600.dark:text-red-500.h-4 (str/join ", " errors)])) (defn field- [params & rest] [:div (-> params (update :class #(hh/add-class (or % "") "group" ))) (when (:label params) [:label {:class "block mb-2 text-sm font-medium text-gray-900 dark:text-white"} (:label params)]) rest (when (:error-source params) (field-errors- {:source (:error-source params) :key (:error-key params)}))]) (defn inline-field- [params & rest] [:div (-> params (update :class #(hh/add-class (or % "") "group flex items-baseline gap-2" ))) (when (:label params) [:label {:class "mb-2 text-sm font-medium text-gray-900 dark:text-white"} (:label params)]) rest (when (:error-source params) (field-errors- {:source (:error-source params) :key (:error-key params)}))]) (defn errors- [{:keys [errors]}] [:p.mt-2.text-xs.text-red-600.dark:text-red-500.h-4 (when (sequential? errors) (str/join ", " (filter string? errors)))]) (defn form-errors- [{:keys [errors]}] [:div#form-errors (when errors [:span.error-content (errors- {:errors errors})])]) (defn validated-field- [params & rest] (field- (cond-> params true (dissoc :errors) (sequential? (:errors params)) (update :class #(hh/add-class (or % "") "has-error"))) rest (errors- {:errors (:errors params)}))) (defn validated-inline-field- [params & rest] (inline-field- (cond-> params true (dissoc :errors) (sequential? (:errors params)) (update :class #(hh/add-class (or % "") "has-error"))) rest (errors- {:errors (:errors params)}))) (defn hidden- [{:keys [name value] :as params}] [:input (merge {:type "hidden" :value value :name name} params)]) (defn toggle- [params & children] [:label {:class "inline-flex items-center cursor-pointer"} [:input (merge {:type "checkbox", :class "sr-only peer"} params)] [:div {:class "relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"}] [:span {:class "ms-3 text-sm font-medium text-gray-900 dark:text-gray-300"} children]])