feat(button): add link variant and polymorphic <a> rendering
When :href is provided, the button renders as <a> instead of <button>, enabling navigation links with full button styling. New :link variant renders as a minimal text link (underlined, accent color, no background/padding) — works as both <button> and <a>. CSS includes reset styles for a.btn (removes default link decoration) and preserves underline for a.btn-link.
This commit is contained in:
2
bb.edn
2
bb.edn
@@ -30,6 +30,7 @@
|
||||
[ui.tooltip-test]
|
||||
[ui.breadcrumb-test]
|
||||
[ui.pagination-test]
|
||||
[ui.form-test]
|
||||
[ui.theme-test])
|
||||
:task (let [{:keys [fail error]} (t/run-tests
|
||||
'ui.button-test
|
||||
@@ -46,6 +47,7 @@
|
||||
'ui.tooltip-test
|
||||
'ui.breadcrumb-test
|
||||
'ui.pagination-test
|
||||
'ui.form-test
|
||||
'ui.theme-test)]
|
||||
(when (pos? (+ fail error))
|
||||
(System/exit 1)))}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
[ui.switch :as switch]
|
||||
[ui.tooltip :as tooltip]
|
||||
[ui.breadcrumb :as breadcrumb]
|
||||
[ui.pagination :as pagination]))
|
||||
[ui.pagination :as pagination]
|
||||
[ui.form :as form]))
|
||||
|
||||
(defn section [title & children]
|
||||
[:section {:style "margin-bottom: 2.5rem;"}
|
||||
@@ -35,7 +36,12 @@
|
||||
(button/button {:variant :primary :size s} (str "size " (name s))))]
|
||||
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
|
||||
(for [v button-variants]
|
||||
(button/button {:variant v :disabled true} (str (name v) " disabled")))]))
|
||||
(button/button {:variant v :disabled true} (str (name v) " disabled")))]
|
||||
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
|
||||
(button/button {:variant :primary :href "#"} "Link primary")
|
||||
(button/button {:variant :secondary :href "#"} "Link secondary")
|
||||
(button/button {:variant :link} "Link button")
|
||||
(button/button {:variant :link :href "https://example.com"} "Link with href")]))
|
||||
|
||||
;; ── Alert ───────────────────────────────────────────────────────────
|
||||
(defn alert-demo []
|
||||
@@ -164,6 +170,53 @@
|
||||
(pagination/pagination {:current 3 :total 5
|
||||
:href-fn (fn [p] (str "#page-" p))})))
|
||||
|
||||
;; ── Form ────────────────────────────────────────────────────────
|
||||
(defn form-demo []
|
||||
(section "Form"
|
||||
[:form {:style "max-width: 480px;"}
|
||||
(form/form-field {:label "Name"}
|
||||
(form/form-input {:type :text :placeholder "Enter your name"}))
|
||||
(form/form-field {:label "Email"}
|
||||
(form/form-input {:type :email :placeholder "you@example.com"}))
|
||||
(form/form-field {:label "Password" :hint "At least 8 characters"}
|
||||
(form/form-input {:type :password :placeholder "Password"}))
|
||||
(form/form-field {:label "Select"}
|
||||
(form/form-select {:placeholder "Select an option"
|
||||
:options [{:value "a" :label "Option A"}
|
||||
{:value "b" :label "Option B"}
|
||||
{:value "c" :label "Option C"}]}))
|
||||
(form/form-field {:label "Message"}
|
||||
(form/form-textarea {:placeholder "Your message..."}))
|
||||
(form/form-field {:label "Disabled"}
|
||||
(form/form-input {:type :text :placeholder "Disabled" :disabled true}))
|
||||
(form/form-field {:label "File"}
|
||||
(form/form-file {}))
|
||||
(form/form-field {:label "Date and time"}
|
||||
(form/form-input {:type :datetime-local}))
|
||||
(form/form-field {:label "Date"}
|
||||
(form/form-input {:type :date}))
|
||||
(form/form-checkbox {:label "I agree to the terms"})
|
||||
(form/form-radio-group {:label "Preference"
|
||||
:radio-name "pref"
|
||||
:options [{:value "a" :label "Option A"}
|
||||
{:value "b" :label "Option B"}
|
||||
{:value "c" :label "Option C"}]})
|
||||
(form/form-field {:label "Volume"}
|
||||
(form/form-range {:min 0 :max 100 :value 50}))
|
||||
(button/button {:variant :primary :attrs {:type "submit"}} "Submit")]
|
||||
;; Input group
|
||||
[:div {:style "max-width: 480px; margin-top: 1.5rem;"}
|
||||
[:h4 {:style "margin-bottom: 0.75rem;"} "Input group"]
|
||||
(form/form-group {}
|
||||
(form/form-group-addon {} "https://")
|
||||
(form/form-input {:placeholder "subdomain"})
|
||||
(button/button {:variant :primary :size :sm} "Go"))]
|
||||
;; Validation error
|
||||
[:div {:style "max-width: 480px; margin-top: 1.5rem;"}
|
||||
[:h4 {:style "margin-bottom: 0.75rem;"} "Validation error"]
|
||||
(form/form-field {:label "Email" :error "Please enter a valid email address."}
|
||||
(form/form-input {:type :email :error true :value "invalid-email"}))]))
|
||||
|
||||
;; ── Page ────────────────────────────────────────────────────────────
|
||||
(defn page []
|
||||
(str
|
||||
@@ -195,7 +248,8 @@
|
||||
(switch-demo)
|
||||
(tooltip-demo)
|
||||
(breadcrumb-demo)
|
||||
(pagination-demo)]]])))
|
||||
(pagination-demo)
|
||||
(form-demo)]]])))
|
||||
|
||||
(defn handler [{:keys [uri]}]
|
||||
(case uri
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
[ui.switch :as switch]
|
||||
[ui.tooltip :as tooltip]
|
||||
[ui.breadcrumb :as breadcrumb]
|
||||
[ui.pagination :as pagination]))
|
||||
[ui.pagination :as pagination]
|
||||
[ui.form :as form]))
|
||||
|
||||
(defn section [title & children]
|
||||
[:section {:style {:margin-bottom "2.5rem"}}
|
||||
@@ -36,7 +37,12 @@
|
||||
(button/button {:variant :primary :size s} (str "size " (name s))))]
|
||||
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
|
||||
(for [v button-variants]
|
||||
(button/button {:variant v :disabled true} (str (name v) " disabled")))]))
|
||||
(button/button {:variant v :disabled true} (str (name v) " disabled")))]
|
||||
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
|
||||
(button/button {:variant :primary :href "#"} "Link primary")
|
||||
(button/button {:variant :secondary :href "#"} "Link secondary")
|
||||
(button/button {:variant :link} "Link button")
|
||||
(button/button {:variant :link :href "https://example.com"} "Link with href")]))
|
||||
|
||||
;; ── Alert ───────────────────────────────────────────────────────────
|
||||
(defn alert-demo []
|
||||
@@ -167,6 +173,53 @@
|
||||
(pagination/pagination {:current 3 :total 5
|
||||
:on-click (fn [p] (js/console.log (str "Page: " p)))})))
|
||||
|
||||
;; ── Form ────────────────────────────────────────────────────────────
|
||||
(defn form-demo []
|
||||
(section "Form"
|
||||
[:form {:style {:max-width "480px"}}
|
||||
(form/form-field {:label "Name"}
|
||||
(form/form-input {:type :text :placeholder "Enter your name"}))
|
||||
(form/form-field {:label "Email"}
|
||||
(form/form-input {:type :email :placeholder "you@example.com"}))
|
||||
(form/form-field {:label "Password" :hint "At least 8 characters"}
|
||||
(form/form-input {:type :password :placeholder "Password"}))
|
||||
(form/form-field {:label "Select"}
|
||||
(form/form-select {:placeholder "Select an option"
|
||||
:options [{:value "a" :label "Option A"}
|
||||
{:value "b" :label "Option B"}
|
||||
{:value "c" :label "Option C"}]}))
|
||||
(form/form-field {:label "Message"}
|
||||
(form/form-textarea {:placeholder "Your message..."}))
|
||||
(form/form-field {:label "Disabled"}
|
||||
(form/form-input {:type :text :placeholder "Disabled" :disabled true}))
|
||||
(form/form-field {:label "File"}
|
||||
(form/form-file {}))
|
||||
(form/form-field {:label "Date and time"}
|
||||
(form/form-input {:type :datetime-local}))
|
||||
(form/form-field {:label "Date"}
|
||||
(form/form-input {:type :date}))
|
||||
(form/form-checkbox {:label "I agree to the terms"})
|
||||
(form/form-radio-group {:label "Preference"
|
||||
:radio-name "pref"
|
||||
:options [{:value "a" :label "Option A"}
|
||||
{:value "b" :label "Option B"}
|
||||
{:value "c" :label "Option C"}]})
|
||||
(form/form-field {:label "Volume"}
|
||||
(form/form-range {:min 0 :max 100 :value 50}))
|
||||
(button/button {:variant :primary :attrs {:type "submit"}} "Submit")]
|
||||
;; Input group
|
||||
[:div {:style {:max-width "480px" :margin-top "1.5rem"}}
|
||||
[:h4 {:style {:margin-bottom "0.75rem"}} "Input group"]
|
||||
(form/form-group {}
|
||||
(form/form-group-addon {} "https://")
|
||||
(form/form-input {:placeholder "subdomain"})
|
||||
(button/button {:variant :primary :size :sm} "Go"))]
|
||||
;; Validation error
|
||||
[:div {:style {:max-width "480px" :margin-top "1.5rem"}}
|
||||
[:h4 {:style {:margin-bottom "0.75rem"}} "Validation error"]
|
||||
(form/form-field {:label "Email" :error "Please enter a valid email address."}
|
||||
(form/form-input {:type :email :error true :value "invalid-email"}))]))
|
||||
|
||||
;; ── Theme toggle ────────────────────────────────────────────────────
|
||||
(defn toggle-theme! [_e]
|
||||
(let [el (.-documentElement js/document)
|
||||
@@ -196,7 +249,8 @@
|
||||
(switch-demo)
|
||||
(tooltip-demo)
|
||||
(breadcrumb-demo)
|
||||
(pagination-demo)])
|
||||
(pagination-demo)
|
||||
(form-demo)])
|
||||
|
||||
(defn ^:export init! []
|
||||
(d/set-dispatch! (fn [_ _]))
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
[ui.switch :as switch]
|
||||
[ui.tooltip :as tooltip]
|
||||
[ui.breadcrumb :as breadcrumb]
|
||||
[ui.pagination :as pagination]))
|
||||
[ui.pagination :as pagination]
|
||||
[ui.form :as form]))
|
||||
|
||||
(defn toggle-theme! [_e]
|
||||
(let [el (.-documentElement js/document)
|
||||
@@ -44,7 +45,12 @@
|
||||
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
|
||||
(map (fn [v]
|
||||
(button/button {:variant v :disabled true} (str v " disabled")))
|
||||
button-variants))))
|
||||
button-variants))
|
||||
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
|
||||
[(button/button {:variant "primary" :href "#"} "Link primary")
|
||||
(button/button {:variant "secondary" :href "#"} "Link secondary")
|
||||
(button/button {:variant "link"} "Link button")
|
||||
(button/button {:variant "link" :href "https://example.com"} "Link with href")])))
|
||||
|
||||
;; ── Alert ───────────────────────────────────────────────────────────
|
||||
(defn alert-demo []
|
||||
@@ -175,6 +181,53 @@
|
||||
(pagination/pagination {:current 3 :total 5
|
||||
:on-click (fn [p] (js/console.log (str "Page: " p)))})))
|
||||
|
||||
;; ── Form ────────────────────────────────────────────────────────
|
||||
(defn form-demo []
|
||||
(section "Form"
|
||||
[:form {:style {"max-width" "480px"}}
|
||||
(form/form-field {:label "Name"}
|
||||
(form/form-input {:type "text" :placeholder "Enter your name"}))
|
||||
(form/form-field {:label "Email"}
|
||||
(form/form-input {:type "email" :placeholder "you@example.com"}))
|
||||
(form/form-field {:label "Password" :hint "At least 8 characters"}
|
||||
(form/form-input {:type "password" :placeholder "Password"}))
|
||||
(form/form-field {:label "Select"}
|
||||
(form/form-select {:placeholder "Select an option"
|
||||
:options [{:value "a" :label "Option A"}
|
||||
{:value "b" :label "Option B"}
|
||||
{:value "c" :label "Option C"}]}))
|
||||
(form/form-field {:label "Message"}
|
||||
(form/form-textarea {:placeholder "Your message..."}))
|
||||
(form/form-field {:label "Disabled"}
|
||||
(form/form-input {:type "text" :placeholder "Disabled" :disabled true}))
|
||||
(form/form-field {:label "File"}
|
||||
(form/form-file {}))
|
||||
(form/form-field {:label "Date and time"}
|
||||
(form/form-input {:type "datetime-local"}))
|
||||
(form/form-field {:label "Date"}
|
||||
(form/form-input {:type "date"}))
|
||||
(form/form-checkbox {:label "I agree to the terms"})
|
||||
(form/form-radio-group {:label "Preference"
|
||||
:radio-name "pref"
|
||||
:options [{:value "a" :label "Option A"}
|
||||
{:value "b" :label "Option B"}
|
||||
{:value "c" :label "Option C"}]})
|
||||
(form/form-field {:label "Volume"}
|
||||
(form/form-range {:min 0 :max 100 :value 50}))
|
||||
(button/button {:variant "primary" :attrs {:type "submit"}} "Submit")]
|
||||
;; Input group
|
||||
[:div {:style {"max-width" "480px" "margin-top" "1.5rem"}}
|
||||
[:h4 {:style {"margin-bottom" "0.75rem"}} "Input group"]
|
||||
(form/form-group {}
|
||||
(form/form-group-addon {} "https://")
|
||||
(form/form-input {:placeholder "subdomain"})
|
||||
(button/button {:variant "primary" :size "sm"} "Go"))]
|
||||
;; Validation error
|
||||
[:div {:style {"max-width" "480px" "margin-top" "1.5rem"}}
|
||||
[:h4 {:style {"margin-bottom" "0.75rem"}} "Validation error"]
|
||||
(form/form-field {:label "Email" :error "Please enter a valid email address."}
|
||||
(form/form-input {:type "email" :error true :value "invalid-email"}))]))
|
||||
|
||||
;; ── App ─────────────────────────────────────────────────────────────
|
||||
(defn app []
|
||||
[:div {:style {"max-width" "800px" "margin" "0 auto"}}
|
||||
@@ -197,7 +250,8 @@
|
||||
(switch-demo)
|
||||
(tooltip-demo)
|
||||
(breadcrumb-demo)
|
||||
(pagination-demo)])
|
||||
(pagination-demo)
|
||||
(form-demo)])
|
||||
|
||||
(defn init! []
|
||||
(eu/render (app) (js/document.getElementById "app")))
|
||||
|
||||
@@ -25,40 +25,45 @@
|
||||
|
||||
(defn button
|
||||
"Render a button element. Works across all targets via reader conditionals.
|
||||
When :href is provided, renders as <a> instead of <button>.
|
||||
|
||||
Props:
|
||||
:variant - :primary, :secondary, :ghost, :danger
|
||||
:variant - :primary, :secondary, :ghost, :danger, :link
|
||||
:size - :sm, :md, :lg
|
||||
:href - URL string; when set, renders as <a> tag
|
||||
:on-click - click handler (ignored in :clj target)
|
||||
:disabled - boolean
|
||||
:class - additional CSS classes (string or vector)
|
||||
:attrs - additional HTML attributes map"
|
||||
[{:keys [variant size on-click disabled class attrs] :as _props} & children]
|
||||
[{:keys [variant size href on-click disabled class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (button-classes {:variant variant :size size})
|
||||
(let [tag (if href :a :button)
|
||||
classes (cond-> (button-classes {:variant variant :size size})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes}
|
||||
(when disabled {:disabled true})
|
||||
attrs)]
|
||||
(into [:button (cond-> base-attrs
|
||||
on-click (assoc :on-click on-click))]
|
||||
base-attrs (cond-> (merge {:class classes} attrs)
|
||||
href (assoc :href href)
|
||||
disabled (assoc :disabled true))]
|
||||
(into [tag (cond-> base-attrs
|
||||
on-click (assoc :on-click on-click))]
|
||||
children))
|
||||
|
||||
:cljs
|
||||
(let [cls (button-class-list {:variant variant :size size})
|
||||
(let [tag (if href :a :button)
|
||||
cls (button-class-list {:variant variant :size size})
|
||||
classes (cond-> cls
|
||||
class (conj class))
|
||||
base-attrs (merge {:class classes}
|
||||
(when disabled {:disabled true})
|
||||
attrs)]
|
||||
(into [:button (cond-> base-attrs
|
||||
on-click (assoc-in [:on :click] on-click))]
|
||||
base-attrs (cond-> (merge {:class classes} attrs)
|
||||
href (assoc :href href)
|
||||
disabled (assoc :disabled true))]
|
||||
(into [tag (cond-> base-attrs
|
||||
on-click (assoc-in [:on :click] on-click))]
|
||||
children))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (button-classes {:variant variant :size size})
|
||||
(let [tag (if href :a :button)
|
||||
classes (cond-> (button-classes {:variant variant :size size})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes}
|
||||
(when disabled {:disabled true})
|
||||
attrs)]
|
||||
(into [:button base-attrs] children))))
|
||||
base-attrs (cond-> (merge {:class classes} attrs)
|
||||
href (assoc :href href)
|
||||
disabled (assoc :disabled true))]
|
||||
(into [tag base-attrs] children))))
|
||||
|
||||
@@ -46,6 +46,28 @@
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: var(--size-1);
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
.btn-link:hover:not(:disabled) {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
/* Link buttons rendered as <a> need reset styles */
|
||||
a.btn {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
a.btn-link {
|
||||
text-decoration: underline;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: var(--size-1) var(--size-2);
|
||||
font-size: var(--font-xs);
|
||||
|
||||
458
src/ui/form.cljc
Normal file
458
src/ui/form.cljc
Normal file
@@ -0,0 +1,458 @@
|
||||
(ns ui.form
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
;; In squint, keywords are strings — name is identity
|
||||
#?(:squint (defn- kw-name [s] s)
|
||||
:cljs (defn- kw-name [s] (name s))
|
||||
:clj (defn- kw-name [s] (name s)))
|
||||
|
||||
;; ── Form field wrapper ──────────────────────────────────────────────
|
||||
|
||||
(defn form-field-class-list
|
||||
"Returns a vector of CSS class strings for a form field wrapper."
|
||||
[{:keys [error]}]
|
||||
(cond-> ["form-field"]
|
||||
error (conj "form-field--error")))
|
||||
|
||||
(defn form-field-classes
|
||||
"Returns a space-joined class string for a form field wrapper."
|
||||
[opts]
|
||||
(str/join " " (form-field-class-list opts)))
|
||||
|
||||
(defn form-field
|
||||
"Render a form field wrapper with label, hint, and error support.
|
||||
|
||||
Props:
|
||||
:label - label text
|
||||
:hint - hint text shown below input
|
||||
:error - error text shown below input (also sets error state)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [label hint error class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (form-field-classes {:error error})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:div base-attrs]
|
||||
(cond-> (if label
|
||||
[[:label {:class "form-label"} label]]
|
||||
[])
|
||||
true (into children)
|
||||
hint (conj [:small {:class "form-hint"} hint])
|
||||
error (conj [:small {:class "form-error"} error]))))
|
||||
|
||||
:cljs
|
||||
(let [cls (form-field-class-list {:error error})
|
||||
classes (cond-> cls class (conj class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:div base-attrs]
|
||||
(cond-> (if label
|
||||
[[:label {:class ["form-label"]} label]]
|
||||
[])
|
||||
true (into children)
|
||||
hint (conj [:small {:class ["form-hint"]} hint])
|
||||
error (conj [:small {:class ["form-error"]} error]))))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (form-field-classes {:error error})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:div base-attrs]
|
||||
(cond-> (if label
|
||||
[[:label {:class "form-label"} label]]
|
||||
[])
|
||||
true (into children)
|
||||
hint (conj [:small {:class "form-hint"} hint])
|
||||
error (conj [:small {:class "form-error"} error]))))))
|
||||
|
||||
;; ── Text input ──────────────────────────────────────────────────────
|
||||
|
||||
(defn form-input-class-list
|
||||
"Returns a vector of CSS class strings for a form input."
|
||||
[{:keys [error]}]
|
||||
(cond-> ["form-input"]
|
||||
error (conj "form-input--error")))
|
||||
|
||||
(defn form-input-classes
|
||||
"Returns a space-joined class string for a form input."
|
||||
[opts]
|
||||
(str/join " " (form-input-class-list opts)))
|
||||
|
||||
(defn form-input
|
||||
"Render a text input element.
|
||||
|
||||
Props:
|
||||
:type - :text, :email, :password, :date, :datetime-local, etc.
|
||||
:placeholder - placeholder text
|
||||
:value - input value
|
||||
:disabled - boolean
|
||||
:error - boolean, adds error styling
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [type placeholder value disabled error on-change class attrs] :as _props}]
|
||||
(let [input-type (or (some-> type kw-name) "text")]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (form-input-classes {:error error})
|
||||
class (str " " class))]
|
||||
[:input (cond-> (merge {:class classes :type input-type} attrs)
|
||||
placeholder (assoc :placeholder placeholder)
|
||||
value (assoc :value value)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))])
|
||||
|
||||
:cljs
|
||||
(let [cls (form-input-class-list {:error error})
|
||||
classes (cond-> cls class (conj class))]
|
||||
[:input (cond-> (merge {:class classes :type input-type} attrs)
|
||||
placeholder (assoc :placeholder placeholder)
|
||||
value (assoc :value value)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (form-input-classes {:error error})
|
||||
class (str " " class))]
|
||||
[:input (cond-> (merge {:class classes :type input-type} attrs)
|
||||
placeholder (assoc :placeholder placeholder)
|
||||
value (assoc :value value)
|
||||
disabled (assoc :disabled true))]))))
|
||||
|
||||
;; ── Textarea ────────────────────────────────────────────────────────
|
||||
|
||||
(defn form-textarea-class-list
|
||||
"Returns a vector of CSS class strings for a form textarea."
|
||||
[{:keys [error]}]
|
||||
(cond-> ["form-textarea"]
|
||||
error (conj "form-textarea--error")))
|
||||
|
||||
(defn form-textarea-classes
|
||||
"Returns a space-joined class string for a form textarea."
|
||||
[opts]
|
||||
(str/join " " (form-textarea-class-list opts)))
|
||||
|
||||
(defn form-textarea
|
||||
"Render a textarea element.
|
||||
|
||||
Props:
|
||||
:placeholder - placeholder text
|
||||
:value - textarea value
|
||||
:disabled - boolean
|
||||
:error - boolean, adds error styling
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [placeholder value disabled error on-change class attrs] :as _props}]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (form-textarea-classes {:error error})
|
||||
class (str " " class))]
|
||||
[:textarea (cond-> (merge {:class classes} attrs)
|
||||
placeholder (assoc :placeholder placeholder)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))
|
||||
(or value "")])
|
||||
|
||||
:cljs
|
||||
(let [cls (form-textarea-class-list {:error error})
|
||||
classes (cond-> cls class (conj class))]
|
||||
[:textarea (cond-> (merge {:class classes} attrs)
|
||||
placeholder (assoc :placeholder placeholder)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))
|
||||
(or value "")])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (form-textarea-classes {:error error})
|
||||
class (str " " class))]
|
||||
[:textarea (cond-> (merge {:class classes} attrs)
|
||||
placeholder (assoc :placeholder placeholder)
|
||||
disabled (assoc :disabled true))
|
||||
(or value "")])))
|
||||
|
||||
;; ── Select ──────────────────────────────────────────────────────────
|
||||
|
||||
(defn form-select
|
||||
"Render a select dropdown element.
|
||||
|
||||
Props:
|
||||
:options - vector of {:value \"v\" :label \"Label\"} or strings
|
||||
:placeholder - placeholder option text
|
||||
:value - currently selected value
|
||||
:disabled - boolean
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [options placeholder value disabled on-change class attrs] :as _props}]
|
||||
(let [opts (mapv (fn [o]
|
||||
(if (string? o)
|
||||
{:value o :label o}
|
||||
o))
|
||||
options)]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-select"
|
||||
class (str " " class))]
|
||||
(into [:select (cond-> (merge {:class classes} attrs)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))]
|
||||
(cond-> (mapv (fn [o]
|
||||
[:option {:value (:value o)} (:label o)])
|
||||
opts)
|
||||
placeholder (into [[:option {:value "" :disabled true :selected (nil? value)} placeholder]]))))
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-select"] class (conj class))]
|
||||
(into [:select (cond-> (merge {:class cls} attrs)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))]
|
||||
(cond-> (mapv (fn [o]
|
||||
[:option {:value (:value o)} (:label o)])
|
||||
opts)
|
||||
placeholder (into [[:option {:value "" :disabled true :selected (nil? value)} placeholder]]))))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-select"
|
||||
class (str " " class))]
|
||||
(into [:select (cond-> (merge {:class classes} attrs)
|
||||
disabled (assoc :disabled true))]
|
||||
(cond-> (mapv (fn [o]
|
||||
[:option {:value (:value o)} (:label o)])
|
||||
opts)
|
||||
placeholder (into [[:option {:value "" :disabled true :selected (nil? value)} placeholder]])))))))
|
||||
|
||||
;; ── Checkbox ────────────────────────────────────────────────────────
|
||||
|
||||
(defn form-checkbox
|
||||
"Render a checkbox with label.
|
||||
|
||||
Props:
|
||||
:label - label text
|
||||
:checked - boolean
|
||||
:disabled - boolean
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [label checked disabled on-change class attrs] :as _props}]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-field form-field--inline"
|
||||
class (str " " class))]
|
||||
[:label (merge {:class classes} attrs)
|
||||
[:input (cond-> {:class "form-checkbox" :type "checkbox"}
|
||||
checked (assoc :checked true)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))]
|
||||
(when label [:span label])])
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-field" "form-field--inline"] class (conj class))]
|
||||
[:label (merge {:class cls} attrs)
|
||||
[:input (cond-> {:class ["form-checkbox"] :type "checkbox"}
|
||||
checked (assoc :checked true)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))]
|
||||
(when label [:span label])])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-field form-field--inline"
|
||||
class (str " " class))]
|
||||
[:label (merge {:class classes} attrs)
|
||||
[:input (cond-> {:class "form-checkbox" :type "checkbox"}
|
||||
checked (assoc :checked true)
|
||||
disabled (assoc :disabled true))]
|
||||
(when label [:span label])])))
|
||||
|
||||
;; ── Radio group ─────────────────────────────────────────────────────
|
||||
|
||||
(defn form-radio-group
|
||||
"Render a radio button group inside a fieldset.
|
||||
|
||||
Props:
|
||||
:label - legend text
|
||||
:name - radio group name
|
||||
:options - vector of {:value \"v\" :label \"Label\"} or strings
|
||||
:value - currently selected value
|
||||
:disabled - boolean
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [label radio-name options radio-value disabled on-change class attrs] :as _props}]
|
||||
(let [opts (mapv (fn [o]
|
||||
(if (string? o)
|
||||
{:value o :label o}
|
||||
o))
|
||||
options)]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-fieldset form-fieldset--inline"
|
||||
class (str " " class))]
|
||||
(into [:fieldset (merge {:class classes} attrs)]
|
||||
(cond-> []
|
||||
label (conj [:legend {:class "form-legend"} label])
|
||||
true (into (mapv (fn [o]
|
||||
[:label
|
||||
[:input (cond-> {:class "form-radio" :type "radio"
|
||||
:name (or radio-name "radio")}
|
||||
(some? (:value o)) (assoc :value (:value o))
|
||||
(= radio-value (:value o)) (assoc :checked true)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))]
|
||||
(:label o)])
|
||||
opts)))))
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-fieldset" "form-fieldset--inline"] class (conj class))]
|
||||
(into [:fieldset (merge {:class cls} attrs)]
|
||||
(cond-> []
|
||||
label (conj [:legend {:class ["form-legend"]} label])
|
||||
true (into (mapv (fn [o]
|
||||
[:label
|
||||
[:input (cond-> {:class ["form-radio"] :type "radio"
|
||||
:name (or radio-name "radio")}
|
||||
(some? (:value o)) (assoc :value (:value o))
|
||||
(= radio-value (:value o)) (assoc :checked true)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))]
|
||||
(:label o)])
|
||||
opts)))))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-fieldset form-fieldset--inline"
|
||||
class (str " " class))]
|
||||
(into [:fieldset (merge {:class classes} attrs)]
|
||||
(cond-> []
|
||||
label (conj [:legend {:class "form-legend"} label])
|
||||
true (into (mapv (fn [o]
|
||||
[:label
|
||||
[:input (cond-> {:class "form-radio" :type "radio"
|
||||
:name (or radio-name "radio")}
|
||||
(some? (:value o)) (assoc :value (:value o))
|
||||
(= radio-value (:value o)) (assoc :checked true)
|
||||
disabled (assoc :disabled true))]
|
||||
(:label o)])
|
||||
opts))))))))
|
||||
|
||||
;; ── File input ──────────────────────────────────────────────────────
|
||||
|
||||
(defn form-file
|
||||
"Render a file input element.
|
||||
|
||||
Props:
|
||||
:accept - accepted file types (e.g. \"image/*,.pdf\")
|
||||
:multiple - boolean, allow multiple files
|
||||
:disabled - boolean
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [accept multiple disabled on-change class attrs] :as _props}]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-file"
|
||||
class (str " " class))]
|
||||
[:input (cond-> (merge {:class classes :type "file"} attrs)
|
||||
accept (assoc :accept accept)
|
||||
multiple (assoc :multiple true)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))])
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-file"] class (conj class))]
|
||||
[:input (cond-> (merge {:class cls :type "file"} attrs)
|
||||
accept (assoc :accept accept)
|
||||
multiple (assoc :multiple true)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-file"
|
||||
class (str " " class))]
|
||||
[:input (cond-> (merge {:class classes :type "file"} attrs)
|
||||
accept (assoc :accept accept)
|
||||
multiple (assoc :multiple true)
|
||||
disabled (assoc :disabled true))])))
|
||||
|
||||
;; ── Range input ─────────────────────────────────────────────────────
|
||||
|
||||
(defn form-range
|
||||
"Render a range slider input.
|
||||
|
||||
Props:
|
||||
:min - minimum value
|
||||
:max - maximum value
|
||||
:step - step increment
|
||||
:value - current value
|
||||
:disabled - boolean
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [min max step value disabled on-change class attrs] :as _props}]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-range"
|
||||
class (str " " class))]
|
||||
[:input (cond-> (merge {:class classes :type "range"} attrs)
|
||||
(some? min) (assoc :min min)
|
||||
(some? max) (assoc :max max)
|
||||
(some? step) (assoc :step step)
|
||||
(some? value) (assoc :value value)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc :on-change on-change))])
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-range"] class (conj class))]
|
||||
[:input (cond-> (merge {:class cls :type "range"} attrs)
|
||||
(some? min) (assoc :min min)
|
||||
(some? max) (assoc :max max)
|
||||
(some? step) (assoc :step step)
|
||||
(some? value) (assoc :value value)
|
||||
disabled (assoc :disabled true)
|
||||
on-change (assoc-in [:on :change] on-change))])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-range"
|
||||
class (str " " class))]
|
||||
[:input (cond-> (merge {:class classes :type "range"} attrs)
|
||||
(some? min) (assoc :min min)
|
||||
(some? max) (assoc :max max)
|
||||
(some? step) (assoc :step step)
|
||||
(some? value) (assoc :value value)
|
||||
disabled (assoc :disabled true))])))
|
||||
|
||||
;; ── Input group ─────────────────────────────────────────────────────
|
||||
|
||||
(defn form-group
|
||||
"Render an input group (combined inputs, addons, and buttons).
|
||||
|
||||
Props:
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-group"
|
||||
class (str " " class))]
|
||||
(into [:div (merge {:class classes} attrs)] children))
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-group"] class (conj class))]
|
||||
(into [:div (merge {:class cls} attrs)] children))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-group"
|
||||
class (str " " class))]
|
||||
(into [:div (merge {:class classes} attrs)] children))))
|
||||
|
||||
(defn form-group-addon
|
||||
"Render an input group addon (static text before/after an input).
|
||||
|
||||
Props:
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "form-group-addon"
|
||||
class (str " " class))]
|
||||
(into [:span (merge {:class classes} attrs)] children))
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["form-group-addon"] class (conj class))]
|
||||
(into [:span (merge {:class cls} attrs)] children))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "form-group-addon"
|
||||
class (str " " class))]
|
||||
(into [:span (merge {:class classes} attrs)] children))))
|
||||
171
src/ui/form.css
171
src/ui/form.css
@@ -1,11 +1,45 @@
|
||||
/* ── Form field wrapper ─────────────────────────────────────────── */
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-1);
|
||||
margin-bottom: var(--size-4);
|
||||
}
|
||||
|
||||
.form-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-field--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--size-2);
|
||||
}
|
||||
|
||||
.form-field--error .form-input,
|
||||
.form-field--error .form-textarea,
|
||||
.form-field--error .form-select {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.form-field--error .form-input:focus,
|
||||
.form-field--error .form-textarea:focus,
|
||||
.form-field--error .form-select:focus {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ── Label ─────────────────────────────────────────────────────── */
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--fg-0);
|
||||
margin-bottom: var(--size-1);
|
||||
}
|
||||
|
||||
/* ── Inputs (text, email, password, date, etc.) ────────────────── */
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
@@ -38,6 +72,7 @@
|
||||
.form-textarea:disabled,
|
||||
.form-select:disabled {
|
||||
background: var(--bg-2);
|
||||
color: var(--fg-2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -51,31 +86,25 @@
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ── Textarea ──────────────────────────────────────────────────── */
|
||||
|
||||
.form-textarea {
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* ── Select ────────────────────────────────────────────────────── */
|
||||
|
||||
.form-select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--size-2) center;
|
||||
padding-right: var(--size-6);
|
||||
padding-right: var(--size-8);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--fg-2);
|
||||
margin-top: var(--size-1);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--danger);
|
||||
margin-top: var(--size-1);
|
||||
}
|
||||
/* ── Checkbox & Radio ──────────────────────────────────────────── */
|
||||
|
||||
.form-checkbox,
|
||||
.form-radio {
|
||||
@@ -83,6 +112,7 @@
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-0);
|
||||
border: var(--border-1);
|
||||
cursor: pointer;
|
||||
@@ -118,12 +148,57 @@
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.form-checkbox:focus,
|
||||
.form-radio:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.form-checkbox:disabled,
|
||||
.form-radio:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── File input ────────────────────────────────────────────────── */
|
||||
|
||||
.form-file {
|
||||
font-size: var(--font-sm);
|
||||
font-family: inherit;
|
||||
color: var(--fg-0);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-file::file-selector-button {
|
||||
padding: var(--size-2) var(--size-3);
|
||||
font-size: var(--font-sm);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
background: var(--bg-2);
|
||||
color: var(--fg-0);
|
||||
border: var(--border-1);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
margin-right: var(--size-3);
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.form-file::file-selector-button:hover {
|
||||
background: var(--bg-1);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-file:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-file:disabled::file-selector-button {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Range input ───────────────────────────────────────────────── */
|
||||
|
||||
.form-range {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
@@ -154,6 +229,76 @@
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.form-range:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-range:focus::-webkit-slider-thumb {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.form-range:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Hint & Error text ─────────────────────────────────────────── */
|
||||
|
||||
.form-hint {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ── Fieldset & Legend ──────────────────────────────────────────── */
|
||||
|
||||
.form-fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0 0 var(--size-4) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-2);
|
||||
}
|
||||
|
||||
.form-fieldset:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-fieldset--inline {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--size-4);
|
||||
}
|
||||
|
||||
.form-fieldset--inline label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--size-2);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--fg-0);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-legend {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--fg-0);
|
||||
margin-bottom: var(--size-1);
|
||||
}
|
||||
|
||||
.form-fieldset--inline .form-legend {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Input group ───────────────────────────────────────────────── */
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
(testing "explicit variant"
|
||||
(is (= ["btn" "btn-primary"] (button/button-class-list {:variant :primary})))
|
||||
(is (= ["btn" "btn-ghost"] (button/button-class-list {:variant :ghost})))
|
||||
(is (= ["btn" "btn-danger"] (button/button-class-list {:variant :danger}))))
|
||||
(is (= ["btn" "btn-danger"] (button/button-class-list {:variant :danger})))
|
||||
(is (= ["btn" "btn-link"] (button/button-class-list {:variant :link}))))
|
||||
|
||||
(testing "explicit size"
|
||||
(is (= ["btn" "btn-secondary" "btn-sm"] (button/button-class-list {:size :sm})))
|
||||
@@ -73,4 +74,26 @@
|
||||
(testing "multiple children"
|
||||
(let [result (button/button {:variant :primary} "A" "B")]
|
||||
(is (= "A" (nth result 2)))
|
||||
(is (= "B" (nth result 3))))))
|
||||
(is (= "B" (nth result 3)))))
|
||||
|
||||
(testing "href renders as <a> tag"
|
||||
(let [result (button/button {:variant :primary :href "/about"} "About")]
|
||||
(is (= :a (first result)))
|
||||
(is (= "/about" (get-in result [1 :href])))
|
||||
(is (= "btn btn-primary" (get-in result [1 :class])))
|
||||
(is (= "About" (nth result 2)))))
|
||||
|
||||
(testing "no href renders as <button> tag"
|
||||
(let [result (button/button {:variant :primary} "Click")]
|
||||
(is (= :button (first result)))))
|
||||
|
||||
(testing "link variant"
|
||||
(let [result (button/button {:variant :link} "Learn more")]
|
||||
(is (= :button (first result)))
|
||||
(is (= "btn btn-link" (get-in result [1 :class])))))
|
||||
|
||||
(testing "link variant with href renders as <a>"
|
||||
(let [result (button/button {:variant :link :href "https://example.com"} "Visit")]
|
||||
(is (= :a (first result)))
|
||||
(is (= "https://example.com" (get-in result [1 :href])))
|
||||
(is (= "btn btn-link" (get-in result [1 :class]))))))
|
||||
|
||||
258
test/ui/form_test.clj
Normal file
258
test/ui/form_test.clj
Normal file
@@ -0,0 +1,258 @@
|
||||
(ns ui.form-test
|
||||
(:require [clojure.test :refer [deftest is testing]]
|
||||
[ui.form :as form]))
|
||||
|
||||
;; ── form-field ──────────────────────────────────────────────────────
|
||||
|
||||
(deftest form-field-class-list-test
|
||||
(testing "default field"
|
||||
(is (= ["form-field"] (form/form-field-class-list {}))))
|
||||
(testing "with error"
|
||||
(is (= ["form-field" "form-field--error"] (form/form-field-class-list {:error true})))))
|
||||
|
||||
(deftest form-field-classes-test
|
||||
(testing "default"
|
||||
(is (= "form-field" (form/form-field-classes {}))))
|
||||
(testing "with error"
|
||||
(is (= "form-field form-field--error" (form/form-field-classes {:error true})))))
|
||||
|
||||
(deftest form-field-component-test
|
||||
(testing "renders with label and child"
|
||||
(let [result (form/form-field {:label "Name"} [:input {:type "text"}])]
|
||||
(is (= :div (first result)))
|
||||
(is (= "form-field" (get-in result [1 :class])))
|
||||
;; label
|
||||
(is (= :label (first (nth result 2))))
|
||||
(is (= "Name" (nth (nth result 2) 2)))
|
||||
;; child input
|
||||
(is (= :input (first (nth result 3))))))
|
||||
|
||||
(testing "renders hint text"
|
||||
(let [result (form/form-field {:label "Password" :hint "At least 8 characters"} [:input])]
|
||||
(is (some #(and (vector? %) (= :small (first %)) (= "form-hint" (get-in % [1 :class])))
|
||||
result))))
|
||||
|
||||
(testing "renders error text"
|
||||
(let [result (form/form-field {:label "Email" :error "Invalid email"} [:input])]
|
||||
(is (= "form-field form-field--error" (get-in result [1 :class])))
|
||||
(is (some #(and (vector? %) (= :small (first %)) (= "form-error" (get-in % [1 :class])))
|
||||
result))))
|
||||
|
||||
(testing "no label renders without label element"
|
||||
(let [result (form/form-field {} [:input])]
|
||||
(is (= :input (first (nth result 2))))))
|
||||
|
||||
(testing "extra class appended"
|
||||
(let [result (form/form-field {:class "extra"} [:input])]
|
||||
(is (= "form-field extra" (get-in result [1 :class]))))))
|
||||
|
||||
;; ── form-input ──────────────────────────────────────────────────────
|
||||
|
||||
(deftest form-input-class-list-test
|
||||
(testing "default"
|
||||
(is (= ["form-input"] (form/form-input-class-list {}))))
|
||||
(testing "with error"
|
||||
(is (= ["form-input" "form-input--error"] (form/form-input-class-list {:error true})))))
|
||||
|
||||
(deftest form-input-component-test
|
||||
(testing "basic text input"
|
||||
(let [result (form/form-input {:type :text :placeholder "Name"})]
|
||||
(is (= :input (first result)))
|
||||
(is (= "form-input" (get-in result [1 :class])))
|
||||
(is (= "text" (get-in result [1 :type])))
|
||||
(is (= "Name" (get-in result [1 :placeholder])))))
|
||||
|
||||
(testing "email type"
|
||||
(let [result (form/form-input {:type :email})]
|
||||
(is (= "email" (get-in result [1 :type])))))
|
||||
|
||||
(testing "password type"
|
||||
(let [result (form/form-input {:type :password})]
|
||||
(is (= "password" (get-in result [1 :type])))))
|
||||
|
||||
(testing "date type"
|
||||
(let [result (form/form-input {:type :date})]
|
||||
(is (= "date" (get-in result [1 :type])))))
|
||||
|
||||
(testing "datetime-local type"
|
||||
(let [result (form/form-input {:type :datetime-local})]
|
||||
(is (= "datetime-local" (get-in result [1 :type])))))
|
||||
|
||||
(testing "default type is text"
|
||||
(let [result (form/form-input {})]
|
||||
(is (= "text" (get-in result [1 :type])))))
|
||||
|
||||
(testing "disabled"
|
||||
(let [result (form/form-input {:disabled true})]
|
||||
(is (true? (get-in result [1 :disabled])))))
|
||||
|
||||
(testing "with value"
|
||||
(let [result (form/form-input {:value "hello"})]
|
||||
(is (= "hello" (get-in result [1 :value])))))
|
||||
|
||||
(testing "error styling"
|
||||
(let [result (form/form-input {:error true})]
|
||||
(is (= "form-input form-input--error" (get-in result [1 :class]))))))
|
||||
|
||||
;; ── form-textarea ───────────────────────────────────────────────────
|
||||
|
||||
(deftest form-textarea-class-list-test
|
||||
(testing "default"
|
||||
(is (= ["form-textarea"] (form/form-textarea-class-list {}))))
|
||||
(testing "with error"
|
||||
(is (= ["form-textarea" "form-textarea--error"] (form/form-textarea-class-list {:error true})))))
|
||||
|
||||
(deftest form-textarea-component-test
|
||||
(testing "basic textarea"
|
||||
(let [result (form/form-textarea {:placeholder "Message"})]
|
||||
(is (= :textarea (first result)))
|
||||
(is (= "form-textarea" (get-in result [1 :class])))
|
||||
(is (= "Message" (get-in result [1 :placeholder])))))
|
||||
|
||||
(testing "disabled"
|
||||
(let [result (form/form-textarea {:disabled true})]
|
||||
(is (true? (get-in result [1 :disabled])))))
|
||||
|
||||
(testing "with value"
|
||||
(let [result (form/form-textarea {:value "hello"})]
|
||||
(is (= "hello" (nth result 2))))))
|
||||
|
||||
;; ── form-select ─────────────────────────────────────────────────────
|
||||
|
||||
(deftest form-select-component-test
|
||||
(testing "basic select with options"
|
||||
(let [result (form/form-select {:options [{:value "a" :label "Option A"}
|
||||
{:value "b" :label "Option B"}]})]
|
||||
(is (= :select (first result)))
|
||||
(is (= "form-select" (get-in result [1 :class])))
|
||||
;; two options
|
||||
(is (= 4 (count result)))))
|
||||
|
||||
(testing "with placeholder"
|
||||
(let [result (form/form-select {:placeholder "Pick one"
|
||||
:options [{:value "a" :label "A"}]})]
|
||||
;; select + option A + placeholder option
|
||||
(is (some #(and (vector? %) (= "Pick one" (nth % 2 nil)))
|
||||
(rest (rest result))))))
|
||||
|
||||
(testing "disabled"
|
||||
(let [result (form/form-select {:disabled true :options []})]
|
||||
(is (true? (get-in result [1 :disabled])))))
|
||||
|
||||
(testing "string options"
|
||||
(let [result (form/form-select {:options ["Foo" "Bar"]})]
|
||||
(is (= 4 (count result))))))
|
||||
|
||||
;; ── form-checkbox ───────────────────────────────────────────────────
|
||||
|
||||
(deftest form-checkbox-component-test
|
||||
(testing "basic checkbox"
|
||||
(let [result (form/form-checkbox {:label "Agree"})]
|
||||
(is (= :label (first result)))
|
||||
(is (= "form-field form-field--inline" (get-in result [1 :class])))
|
||||
;; has input checkbox
|
||||
(let [input (nth result 2)]
|
||||
(is (= :input (first input)))
|
||||
(is (= "checkbox" (get-in input [1 :type])))
|
||||
(is (= "form-checkbox" (get-in input [1 :class]))))
|
||||
;; has label text
|
||||
(is (= [:span "Agree"] (nth result 3)))))
|
||||
|
||||
(testing "checked"
|
||||
(let [result (form/form-checkbox {:label "Agree" :checked true})
|
||||
input (nth result 2)]
|
||||
(is (true? (get-in input [1 :checked])))))
|
||||
|
||||
(testing "disabled"
|
||||
(let [result (form/form-checkbox {:disabled true})
|
||||
input (nth result 2)]
|
||||
(is (true? (get-in input [1 :disabled]))))))
|
||||
|
||||
;; ── form-radio-group ────────────────────────────────────────────────
|
||||
|
||||
(deftest form-radio-group-component-test
|
||||
(testing "basic radio group"
|
||||
(let [result (form/form-radio-group {:label "Preference"
|
||||
:radio-name "pref"
|
||||
:options [{:value "a" :label "A"}
|
||||
{:value "b" :label "B"}
|
||||
{:value "c" :label "C"}]})]
|
||||
(is (= :fieldset (first result)))
|
||||
(is (= "form-fieldset form-fieldset--inline" (get-in result [1 :class])))
|
||||
;; legend
|
||||
(is (= :legend (first (nth result 2))))
|
||||
(is (= "Preference" (nth (nth result 2) 2)))
|
||||
;; three radio labels
|
||||
(is (= 6 (count result))))) ; fieldset + attrs + legend + 3 radios
|
||||
|
||||
(testing "radio with selected value"
|
||||
(let [result (form/form-radio-group {:radio-name "pref"
|
||||
:radio-value "b"
|
||||
:options [{:value "a" :label "A"}
|
||||
{:value "b" :label "B"}]})
|
||||
;; find the radio for "b"
|
||||
radio-b (nth result 3) ; second radio (after attrs, no legend since no :label)
|
||||
input-b (nth radio-b 1)]
|
||||
(is (true? (get-in input-b [1 :checked])))))
|
||||
|
||||
(testing "string options"
|
||||
(let [result (form/form-radio-group {:radio-name "x" :options ["X" "Y"]})]
|
||||
;; 2 radios + attrs = 4 elements
|
||||
(is (= 4 (count result))))))
|
||||
|
||||
;; ── form-file ───────────────────────────────────────────────────────
|
||||
|
||||
(deftest form-file-component-test
|
||||
(testing "basic file input"
|
||||
(let [result (form/form-file {})]
|
||||
(is (= :input (first result)))
|
||||
(is (= "file" (get-in result [1 :type])))
|
||||
(is (= "form-file" (get-in result [1 :class])))))
|
||||
|
||||
(testing "with accept"
|
||||
(let [result (form/form-file {:accept "image/*"})]
|
||||
(is (= "image/*" (get-in result [1 :accept])))))
|
||||
|
||||
(testing "multiple"
|
||||
(let [result (form/form-file {:multiple true})]
|
||||
(is (true? (get-in result [1 :multiple])))))
|
||||
|
||||
(testing "disabled"
|
||||
(let [result (form/form-file {:disabled true})]
|
||||
(is (true? (get-in result [1 :disabled]))))))
|
||||
|
||||
;; ── form-range ──────────────────────────────────────────────────────
|
||||
|
||||
(deftest form-range-component-test
|
||||
(testing "basic range"
|
||||
(let [result (form/form-range {:min 0 :max 100 :value 50})]
|
||||
(is (= :input (first result)))
|
||||
(is (= "range" (get-in result [1 :type])))
|
||||
(is (= "form-range" (get-in result [1 :class])))
|
||||
(is (= 0 (get-in result [1 :min])))
|
||||
(is (= 100 (get-in result [1 :max])))
|
||||
(is (= 50 (get-in result [1 :value])))))
|
||||
|
||||
(testing "with step"
|
||||
(let [result (form/form-range {:step 5})]
|
||||
(is (= 5 (get-in result [1 :step])))))
|
||||
|
||||
(testing "disabled"
|
||||
(let [result (form/form-range {:disabled true})]
|
||||
(is (true? (get-in result [1 :disabled]))))))
|
||||
|
||||
;; ── form-group ──────────────────────────────────────────────────────
|
||||
|
||||
(deftest form-group-component-test
|
||||
(testing "basic group"
|
||||
(let [result (form/form-group {} [:input] [:button "Go"])]
|
||||
(is (= :div (first result)))
|
||||
(is (= "form-group" (get-in result [1 :class])))
|
||||
(is (= 4 (count result))))))
|
||||
|
||||
(deftest form-group-addon-component-test
|
||||
(testing "basic addon"
|
||||
(let [result (form/form-group-addon {} "https://")]
|
||||
(is (= :span (first result)))
|
||||
(is (= "form-group-addon" (get-in result [1 :class])))
|
||||
(is (= "https://" (nth result 2))))))
|
||||
Reference in New Issue
Block a user