feat: add icon support to button, alert, badge, and form input

Button:
- :icon-left, :icon-right props for buttons with leading/trailing icons
- :icon prop for icon-only buttons (square padding via .btn-icon class)
- Icon size scales with button size (sm→sm, md/lg→sm/md)

Alert:
- Auto-assigns variant-specific icons (circle-check, alert-triangle,
  alert-circle, info) per variant
- :icon-name prop to override default, false to suppress
- Layout restructured with .alert-icon + .alert-content wrapper

Badge:
- :icon-name prop adds a leading icon before text
- .badge-icon CSS scales icon to match badge font size

Form input:
- :icon-left and :icon-right props on form-input
- Wraps input in .form-input-wrap with absolutely-positioned icon spans
- Padding adjusts automatically via .form-input--icon-left/right

All three dev targets (hiccup, replicant, squint) updated with demos.
This commit is contained in:
Florian Schroedl
2026-03-05 19:33:55 +01:00
parent e3132a3cb4
commit 93edebf144
15 changed files with 438 additions and 122 deletions

View File

@@ -1,5 +1,6 @@
(ns ui.alert
(:require [clojure.string :as str]))
(:require [clojure.string :as str]
[ui.icon :as icon]))
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
@@ -17,38 +18,61 @@
[opts]
(str/join " " (alert-class-list opts)))
(def ^:private variant-icons
"Default icon names per alert variant."
{"success" :circle-check
"warning" :alert-triangle
"danger" :alert-circle
"info" :info})
(defn alert
"Render an alert element.
Props:
:variant - :success, :warning, :danger, :info (nil for neutral)
:title - optional title string
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [variant title class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> (alert-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes :role "alert"} attrs)]
(into [:div base-attrs]
(cond-> []
title (conj [:p {:class "alert-title"} title])
:always (into (map (fn [c] [:p {:class "alert-body"} c]) children)))))
:variant - :success, :warning, :danger, :info (nil for neutral)
:title - optional title string
:icon-name - override icon (nil uses variant default, false to suppress)
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [variant title icon-name class attrs] :as _props} & children]
(let [v (some-> variant kw-name)
iname (cond
(false? icon-name) nil ;; explicitly suppressed
icon-name icon-name ;; explicit override
v (get variant-icons v))]
#?(:squint
(let [classes (cond-> (alert-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes :role "alert"} attrs)]
(into [:div base-attrs]
(cond-> []
iname (conj [:span {:class "alert-icon"} (icon/icon {:icon-name iname :size :sm})])
true (conj (into [:div {:class "alert-content"}]
(cond-> []
title (conj [:p {:class "alert-title"} title])
:always (into (map (fn [c] [:p {:class "alert-body"} c]) children))))))))
:cljs
(let [cls (alert-class-list {:variant variant})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes :role "alert"} attrs)]
(into [:div base-attrs]
(cond-> []
title (conj [:p {:class ["alert-title"]} title])
:always (into (map (fn [c] [:p {:class ["alert-body"]} c]) children)))))
:cljs
(let [cls (alert-class-list {:variant variant})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes :role "alert"} attrs)]
(into [:div base-attrs]
(cond-> []
iname (conj [:span {:class ["alert-icon"]} (icon/icon {:icon-name iname :size :sm})])
true (conj (into [:div {:class ["alert-content"]}]
(cond-> []
title (conj [:p {:class ["alert-title"]} title])
:always (into (map (fn [c] [:p {:class ["alert-body"]} c]) children))))))))
:clj
(let [classes (cond-> (alert-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes :role "alert"} attrs)]
(into [:div base-attrs]
(cond-> []
iname (conj [:span {:class "alert-icon"} (icon/icon {:icon-name iname :size :sm})])
true (conj (into [:div {:class "alert-content"}]
(cond-> []
title (conj [:p {:class "alert-title"} title])
:always (into (map (fn [c] [:p {:class "alert-body"} c]) children)))))))))))
:clj
(let [classes (cond-> (alert-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes :role "alert"} attrs)]
(into [:div base-attrs]
(cond-> []
title (conj [:p {:class "alert-title"} title])
:always (into (map (fn [c] [:p {:class "alert-body"} c]) children)))))))

View File

@@ -2,8 +2,7 @@
container-type: inline-size;
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
align-items: flex-start;
gap: var(--size-3);
padding: var(--size-4) var(--size-6);
background: var(--bg-1);
@@ -12,6 +11,18 @@
font-size: var(--font-sm);
}
.alert-icon {
display: flex;
align-items: center;
flex-shrink: 0;
padding-top: var(--size-1);
}
.alert-content {
flex: 1;
min-width: 0;
}
.alert-title {
font-weight: 600;
margin: 0;

View File

@@ -1,5 +1,6 @@
(ns ui.badge
(:require [clojure.string :as str]))
(:require [clojure.string :as str]
[ui.icon :as icon]))
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
@@ -25,24 +26,34 @@
"Render a badge element.
Props:
:variant - :primary, :secondary, :outline, :success, :warning, :danger
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [variant class attrs] :as _props} & children]
:variant - :primary, :secondary, :outline, :success, :warning, :danger
:icon-name - optional leading icon (e.g. :check, :star)
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [variant icon-name class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> (badge-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:span base-attrs] children))
(into [:span base-attrs]
(cond-> []
icon-name (conj (icon/icon {:icon-name icon-name :size :sm :class "badge-icon"}))
true (into children))))
:cljs
(let [cls (badge-class-list {:variant variant})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes} attrs)]
(into [:span base-attrs] children))
(into [:span base-attrs]
(cond-> []
icon-name (conj (icon/icon {:icon-name icon-name :size :sm :class "badge-icon"}))
true (into children))))
:clj
(let [classes (cond-> (badge-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:span base-attrs] children))))
(into [:span base-attrs]
(cond-> []
icon-name (conj (icon/icon {:icon-name icon-name :size :sm :class "badge-icon"}))
true (into children))))))

View File

@@ -11,6 +11,12 @@
color: var(--fg-on-accent);
}
.badge-icon {
width: 0.875em;
height: 0.875em;
flex-shrink: 0;
}
.badge-secondary {
background: var(--bg-2);
color: var(--fg-0);

View File

@@ -1,5 +1,6 @@
(ns ui.button
(:require [clojure.string :as str]))
(:require [clojure.string :as str]
[ui.icon :as icon]))
;; In squint, keywords are strings — name is identity
#?(:squint (defn- kw-name [s] s)
@@ -10,13 +11,15 @@
(def default-size "md")
(defn button-class-list
"Generate a vector of CSS class strings for a button given variant and size.
Returns e.g. [\"btn\" \"btn-primary\" \"btn-lg\"]."
[{:keys [variant size]}]
"Generate a vector of CSS class strings for a button given variant, size, and icon mode.
Returns e.g. [\"btn\" \"btn-primary\" \"btn-lg\"].
When :icon is provided (icon-only mode), adds \"btn-icon\"."
[{:keys [variant size icon]}]
(let [v (or (some-> variant kw-name) default-variant)
s (or (some-> size kw-name) default-size)]
(cond-> ["btn" (str "btn-" v)]
(not= s "md") (conj (str "btn-" s)))))
(not= s "md") (conj (str "btn-" s))
icon (conj "btn-icon"))))
(defn button-classes
"Generate CSS class string for a button. Returns a space-joined string."
@@ -28,42 +31,68 @@
When :href is provided, renders as <a> instead of <button>.
Props:
: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 href on-click disabled class attrs] :as _props} & children]
#?(:squint
(let [tag (if href :a :button)
classes (cond-> (button-classes {:variant variant :size size})
class (str " " class))
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))
: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
:icon-left - icon name keyword for left icon (e.g. :plus)
:icon-right - icon name keyword for right icon (e.g. :arrow-right)
:icon - icon name keyword for icon-only button (no text children)
:class - additional CSS classes (string or vector)
:attrs - additional HTML attributes map"
[{:keys [variant size href on-click disabled icon-left icon-right icon class attrs] :as _props} & children]
(let [icon-size (case (kw-name (or size default-size))
"sm" :sm
"lg" :md
#?(:squint "sm" :cljs :sm :clj :sm))]
#?(:squint
(let [tag (if href :a :button)
classes (cond-> (button-classes {:variant variant :size size :icon icon})
class (str " " class))
base-attrs (cond-> (merge {:class classes} attrs)
href (assoc :href href)
disabled (assoc :disabled true))
base-attrs (cond-> base-attrs
on-click (assoc :on-click on-click))]
(if icon
[tag base-attrs (icon/icon {:icon-name icon :size icon-size})]
(into [tag base-attrs]
(cond-> []
icon-left (conj (icon/icon {:icon-name icon-left :size icon-size}))
true (into children)
icon-right (conj (icon/icon {:icon-name icon-right :size icon-size}))))))
:cljs
(let [tag (if href :a :button)
cls (button-class-list {:variant variant :size size})
classes (cond-> cls
class (conj class))
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))
:cljs
(let [tag (if href :a :button)
cls (button-class-list {:variant variant :size size :icon icon})
classes (cond-> cls
class (conj class))
base-attrs (cond-> (merge {:class classes} attrs)
href (assoc :href href)
disabled (assoc :disabled true))
base-attrs (cond-> base-attrs
on-click (assoc-in [:on :click] on-click))]
(if icon
[tag base-attrs (icon/icon {:icon-name icon :size icon-size})]
(into [tag base-attrs]
(cond-> []
icon-left (conj (icon/icon {:icon-name icon-left :size icon-size}))
true (into children)
icon-right (conj (icon/icon {:icon-name icon-right :size icon-size}))))))
:clj
(let [tag (if href :a :button)
classes (cond-> (button-classes {:variant variant :size size :icon icon})
class (str " " class))
base-attrs (cond-> (merge {:class classes} attrs)
href (assoc :href href)
disabled (assoc :disabled true))]
(if icon
[tag base-attrs (icon/icon {:icon-name icon :size icon-size})]
(into [tag base-attrs]
(cond-> []
icon-left (conj (icon/icon {:icon-name icon-left :size icon-size}))
true (into children)
icon-right (conj (icon/icon {:icon-name icon-right :size icon-size})))))))))
:clj
(let [tag (if href :a :button)
classes (cond-> (button-classes {:variant variant :size size})
class (str " " class))
base-attrs (cond-> (merge {:class classes} attrs)
href (assoc :href href)
disabled (assoc :disabled true))]
(into [tag base-attrs] children))))

View File

@@ -78,6 +78,8 @@ a.btn-link {
line-height: var(--size-6);
}
/* ── Icon-only button ────────────────────────────────────────── */
.btn-icon {
padding: var(--size-2);
line-height: 1;
@@ -96,6 +98,8 @@ a.btn-link {
border-radius: 9999px;
}
/* ── Disabled ────────────────────────────────────────────────── */
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;

View File

@@ -93,7 +93,8 @@
(str/join " " (form-input-class-list opts)))
(defn form-input
"Render a text input element.
"Render a text input element. When :icon-left or :icon-right is provided,
wraps in a .form-input-wrap div with absolutely-positioned icons.
Props:
:type - :text, :email, :password, :date, :datetime-local, etc.
@@ -101,36 +102,67 @@
:value - input value
:disabled - boolean
:error - boolean, adds error styling
:icon-left - icon name keyword for left icon (e.g. :search)
:icon-right - icon name keyword for right icon (e.g. :check)
: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")]
[{:keys [type placeholder value disabled error icon-left icon-right on-change class attrs] :as _props}]
(let [input-type (or (some-> type kw-name) "text")
has-icons (or icon-left icon-right)]
#?(: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))])
(let [input-cls (cond-> (form-input-classes {:error error})
class (str " " class)
icon-left (str " form-input--icon-left")
icon-right (str " form-input--icon-right"))
input-el [:input (cond-> (merge {:class input-cls :type input-type} attrs)
placeholder (assoc :placeholder placeholder)
value (assoc :value value)
disabled (assoc :disabled true)
on-change (assoc :on-change on-change))]]
(if has-icons
(into [:div {:class "form-input-wrap"}]
(cond-> []
icon-left (conj [:span {:class "form-input-icon form-input-icon--left"} (icon/icon {:icon-name icon-left :size :sm})])
true (conj input-el)
icon-right (conj [:span {:class "form-input-icon form-input-icon--right"} (icon/icon {:icon-name icon-right :size :sm})])))
input-el))
: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))])
input-cls (cond-> cls
class (conj class)
icon-left (conj "form-input--icon-left")
icon-right (conj "form-input--icon-right"))
input-el [:input (cond-> (merge {:class input-cls :type input-type} attrs)
placeholder (assoc :placeholder placeholder)
value (assoc :value value)
disabled (assoc :disabled true)
on-change (assoc-in [:on :change] on-change))]]
(if has-icons
(into [:div {:class ["form-input-wrap"]}]
(cond-> []
icon-left (conj [:span {:class ["form-input-icon" "form-input-icon--left"]} (icon/icon {:icon-name icon-left :size :sm})])
true (conj input-el)
icon-right (conj [:span {:class ["form-input-icon" "form-input-icon--right"]} (icon/icon {:icon-name icon-right :size :sm})])))
input-el))
: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))]))))
(let [input-cls (cond-> (form-input-classes {:error error})
class (str " " class)
icon-left (str " form-input--icon-left")
icon-right (str " form-input--icon-right"))
input-el [:input (cond-> (merge {:class input-cls :type input-type} attrs)
placeholder (assoc :placeholder placeholder)
value (assoc :value value)
disabled (assoc :disabled true))]]
(if has-icons
(into [:div {:class "form-input-wrap"}]
(cond-> []
icon-left (conj [:span {:class "form-input-icon form-input-icon--left"} (icon/icon {:icon-name icon-left :size :sm})])
true (conj input-el)
icon-right (conj [:span {:class "form-input-icon form-input-icon--right"} (icon/icon {:icon-name icon-right :size :sm})])))
input-el)))))
;; ── Textarea ────────────────────────────────────────────────────────

View File

@@ -85,6 +85,39 @@
outline-color: var(--danger);
}
/* ── Input with icons ──────────────────────────────────────────── */
.form-input-wrap {
position: relative;
width: 100%;
}
.form-input-icon {
position: absolute;
top: 0;
bottom: 0;
display: flex;
align-items: center;
pointer-events: none;
color: var(--fg-2);
}
.form-input-icon--left {
left: var(--size-3);
}
.form-input-icon--right {
right: var(--size-3);
}
.form-input--icon-left {
padding-left: var(--size-8);
}
.form-input--icon-right {
padding-right: var(--size-8);
}
/* ── Textarea ──────────────────────────────────────────────────── */
.form-textarea {