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:
Florian Schroedl
2026-03-03 17:04:41 +01:00
parent 6a3fee34c4
commit e4ee7b750e
10 changed files with 1118 additions and 43 deletions

2
bb.edn
View File

@@ -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)))}

View File

@@ -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

View File

@@ -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 [_ _]))

View File

@@ -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")))

View File

@@ -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))))

View File

@@ -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
View 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))))

View File

@@ -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;

View File

@@ -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
View 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))))))