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

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;