feat: add icon component with 50+ Lucide-based SVG icons

Adds a general-purpose icon system (ui.icon) with inline SVG rendering:
- 50+ icons from the Lucide icon set (navigation, actions, objects, UI,
  status, dev/technical categories)
- Size variants: sm (--size-4), md (--size-5), lg (--size-6), xl (--size-8)
- Pure data approach: icon paths stored as hiccup vectors, rendered into
  SVG with stroke="currentColor" so icons inherit text color
- API: (icon/icon {:icon-name :home :size :lg :class "custom"})

Integrates icons into the sidebar component:
- sidebar-menu-item now accepts :icon-name prop
- Renders icon in a .sidebar-menu-item-icon wrapper at :sm size

All three dev targets updated with icon gallery demo and sidebar icons.
This commit is contained in:
Florian Schroedl
2026-03-05 12:49:22 +01:00
parent e3787363d2
commit c857954845
10 changed files with 1677 additions and 6 deletions

377
src/ui/icon.cljc Normal file
View File

@@ -0,0 +1,377 @@
(ns ui.icon
(: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)))
;; ── Icon path data ──────────────────────────────────────────────────
;; All icons use 24×24 viewBox, stroke-based (Lucide-compatible).
;; Each entry is a vector of SVG child elements as hiccup.
(def icon-paths
{;; ── Navigation ──────────────────────────────────────────────────
:home
[[:path {:d "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"}]
[:path {:d "M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"}]]
:menu
[[:path {:d "M4 5h16"}]
[:path {:d "M4 12h16"}]
[:path {:d "M4 19h16"}]]
:x
[[:path {:d "M18 6 6 18"}]
[:path {:d "m6 6 12 12"}]]
:chevron-down [[:path {:d "m6 9 6 6 6-6"}]]
:chevron-up [[:path {:d "m18 15-6-6-6 6"}]]
:chevron-left [[:path {:d "m15 18-6-6 6-6"}]]
:chevron-right [[:path {:d "m9 18 6-6-6-6"}]]
:arrow-left
[[:path {:d "m12 19-7-7 7-7"}]
[:path {:d "M19 12H5"}]]
:arrow-right
[[:path {:d "M5 12h14"}]
[:path {:d "m12 5 7 7-7 7"}]]
:arrow-up
[[:path {:d "m5 12 7-7 7 7"}]
[:path {:d "M12 19V5"}]]
:arrow-down
[[:path {:d "M12 5v14"}]
[:path {:d "m19 12-7 7-7-7"}]]
:external-link
[[:path {:d "M15 3h6v6"}]
[:path {:d "M10 14 21 3"}]
[:path {:d "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"}]]
;; ── Actions ─────────────────────────────────────────────────────
:search
[[:circle {:cx "11" :cy "11" :r "8"}]
[:path {:d "m21 21-4.34-4.34"}]]
:plus
[[:path {:d "M5 12h14"}]
[:path {:d "M12 5v14"}]]
:minus
[[:path {:d "M5 12h14"}]]
:check
[[:path {:d "M20 6 9 17l-5-5"}]]
:edit
[[:path {:d "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"}]]
:trash
[[:path {:d "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"}]
[:path {:d "M3 6h18"}]
[:path {:d "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"}]]
:download
[[:path {:d "M12 15V3"}]
[:path {:d "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}]
[:path {:d "m7 10 5 5 5-5"}]]
:upload
[[:path {:d "M12 3v12"}]
[:path {:d "m17 8-5-5-5 5"}]
[:path {:d "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}]]
:copy
[[:rect {:width "14" :height "14" :x "8" :y "8" :rx "2" :ry "2"}]
[:path {:d "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"}]]
:filter
[[:polygon {:points "22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"}]]
:link
[[:path {:d "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"}]
[:path {:d "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"}]]
:refresh
[[:path {:d "M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"}]
[:path {:d "M3 3v5h5"}]
[:path {:d "M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"}]
[:path {:d "M16 16h5v5"}]]
;; ── Objects ─────────────────────────────────────────────────────
:file
[[:path {:d "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"}]
[:path {:d "M14 2v5a1 1 0 0 0 1 1h5"}]]
:folder
[[:path {:d "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"}]]
:image
[[:rect {:width "18" :height "18" :x "3" :y "3" :rx "2" :ry "2"}]
[:circle {:cx "9" :cy "9" :r "2"}]
[:path {:d "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"}]]
:mail
[[:rect {:x "2" :y "4" :width "20" :height "16" :rx "2"}]
[:path {:d "m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"}]]
:bell
[[:path {:d "M10.268 21a2 2 0 0 0 3.464 0"}]
[:path {:d "M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"}]]
:calendar
[[:rect {:width "18" :height "18" :x "3" :y "4" :rx "2"}]
[:path {:d "M16 2v4"}]
[:path {:d "M8 2v4"}]
[:path {:d "M3 10h18"}]]
:clock
[[:circle {:cx "12" :cy "12" :r "10"}]
[:path {:d "M12 6v6l4 2"}]]
:bookmark
[[:path {:d "M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z"}]]
:star
[[:path {:d "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"}]]
:heart
[[:path {:d "M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"}]]
:inbox
[[:polyline {:points "22 12 16 12 14 15 10 15 8 12 2 12"}]
[:path {:d "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"}]]
:layers
[[:path {:d "M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"}]
[:path {:d "M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"}]
[:path {:d "M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"}]]
:package
[[:path {:d "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"}]
[:path {:d "M12 22V12"}]
[:polyline {:points "3.29 7 12 12 20.71 7"}]
[:path {:d "m7.5 4.27 9 5.15"}]]
;; ── UI / System ─────────────────────────────────────────────────
:settings
[[:path {:d "M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"}]
[:circle {:cx "12" :cy "12" :r "3"}]]
:user
[[:path {:d "M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"}]
[:circle {:cx "12" :cy "7" :r "4"}]]
:users
[[:path {:d "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"}]
[:circle {:cx "9" :cy "7" :r "4"}]
[:path {:d "M16 3.128a4 4 0 0 1 0 7.744"}]
[:path {:d "M22 21v-2a4 4 0 0 0-3-3.87"}]]
:log-out
[[:path {:d "m16 17 5-5-5-5"}]
[:path {:d "M21 12H9"}]
[:path {:d "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"}]]
:log-in
[[:path {:d "m10 17 5-5-5-5"}]
[:path {:d "M15 12H3"}]
[:path {:d "M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"}]]
:eye
[[:path {:d "M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"}]
[:circle {:cx "12" :cy "12" :r "3"}]]
:eye-off
[[:path {:d "M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"}]
[:path {:d "M14.084 14.158a3 3 0 0 1-4.242-4.242"}]
[:path {:d "M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"}]
[:path {:d "m2 2 20 20"}]]
:lock
[[:rect {:width "18" :height "11" :x "3" :y "11" :rx "2" :ry "2"}]
[:path {:d "M7 11V7a5 5 0 0 1 10 0v4"}]]
:grid
[[:rect {:x "3" :y "3" :width "18" :height "18" :rx "2"}]
[:path {:d "M3 9h18"}]
[:path {:d "M3 15h18"}]
[:path {:d "M9 3v18"}]
[:path {:d "M15 3v18"}]]
:list
[[:path {:d "M3 5h.01"}]
[:path {:d "M3 12h.01"}]
[:path {:d "M3 19h.01"}]
[:path {:d "M8 5h13"}]
[:path {:d "M8 12h13"}]
[:path {:d "M8 19h13"}]]
:layout-dashboard
[[:rect {:width "7" :height "9" :x "3" :y "3" :rx "1"}]
[:rect {:width "7" :height "5" :x "14" :y "3" :rx "1"}]
[:rect {:width "7" :height "9" :x "14" :y "12" :rx "1"}]
[:rect {:width "7" :height "5" :x "3" :y "16" :rx "1"}]]
:monitor
[[:rect {:width "20" :height "14" :x "2" :y "3" :rx "2"}]
[:line {:x1 "8" :x2 "16" :y1 "21" :y2 "21"}]
[:line {:x1 "12" :x2 "12" :y1 "17" :y2 "21"}]]
:moon
[[:path {:d "M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"}]]
:sun
[[:circle {:cx "12" :cy "12" :r "4"}]
[:path {:d "M12 2v2"}]
[:path {:d "M12 20v2"}]
[:path {:d "m4.93 4.93 1.41 1.41"}]
[:path {:d "m17.66 17.66 1.41 1.41"}]
[:path {:d "M2 12h2"}]
[:path {:d "M20 12h2"}]
[:path {:d "m6.34 17.66-1.41 1.41"}]
[:path {:d "m19.07 4.93-1.41 1.41"}]]
;; ── Status ──────────────────────────────────────────────────────
:alert-triangle
[[:path {:d "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"}]
[:path {:d "M12 9v4"}]
[:path {:d "M12 17h.01"}]]
:alert-circle
[[:circle {:cx "12" :cy "12" :r "10"}]
[:line {:x1 "12" :x2 "12" :y1 "8" :y2 "12"}]
[:line {:x1 "12" :x2 "12.01" :y1 "16" :y2 "16"}]]
:info
[[:circle {:cx "12" :cy "12" :r "10"}]
[:path {:d "M12 16v-4"}]
[:path {:d "M12 8h.01"}]]
:circle-check
[[:circle {:cx "12" :cy "12" :r "10"}]
[:path {:d "m9 12 2 2 4-4"}]]
:circle-x
[[:circle {:cx "12" :cy "12" :r "10"}]
[:path {:d "m15 9-6 6"}]
[:path {:d "m9 9 6 6"}]]
;; ── Dev / Technical ─────────────────────────────────────────────
:code
[[:path {:d "m16 18 6-6-6-6"}]
[:path {:d "m8 6-6 6 6 6"}]]
:terminal
[[:path {:d "m4 17 6-6-6-6"}]
[:path {:d "M12 19h8"}]]
:database
[[:ellipse {:cx "12" :cy "5" :rx "9" :ry "3"}]
[:path {:d "M3 5V19A9 3 0 0 0 21 19V5"}]
[:path {:d "M3 12A9 3 0 0 0 21 12"}]]
:globe
[[:circle {:cx "12" :cy "12" :r "10"}]
[:path {:d "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"}]
[:path {:d "M2 12h20"}]]
:shield
[[:path {:d "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"}]]
:zap
[[:path {:d "M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"}]]
:book-open
[[:path {:d "M12 7v14"}]
[:path {:d "M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"}]]
:map-pin
[[:path {:d "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"}]
[:circle {:cx "12" :cy "10" :r "3"}]]})
;; ── Public API ──────────────────────────────────────────────────────
(def icon-names
"Set of all available icon name keywords."
(set (keys icon-paths)))
(def default-size "md")
(defn icon-class-list
"Returns a vector of CSS class strings for an icon.
Sizes: :sm (16px), :md (20px), :lg (24px), :xl (32px)."
[{:keys [size]}]
(let [s (or (some-> size kw-name) default-size)]
(cond-> ["icon"]
(not= s "md") (conj (str "icon-" s)))))
(defn icon-classes
"Returns a space-joined class string for an icon."
[opts]
(str/join " " (icon-class-list opts)))
(defn icon
"Render an inline SVG icon.
Props:
:name - icon name keyword (e.g. :home, :search, :settings)
:size - :sm, :md (default), :lg, :xl
:class - additional CSS classes
:attrs - additional HTML/SVG attributes
Returns hiccup SVG element. Returns nil for unknown icon names."
[{:keys [icon-name size class attrs] :as _props}]
(let [n (kw-name icon-name)
paths (get icon-paths
#?(:squint n
:cljs (keyword n)
:clj (keyword n)))]
(when paths
#?(:squint
(let [classes (cond-> (icon-classes {:size size})
class (str " " class))
svg-attrs (merge {:xmlns "http://www.w3.org/2000/svg"
:viewBox "0 0 24 24"
:fill "none"
:stroke "currentColor"
:stroke-width "2"
:stroke-linecap "round"
:stroke-linejoin "round"
:class classes
:aria-hidden "true"}
attrs)]
(into [:svg svg-attrs] paths))
:cljs
(let [cls (icon-class-list {:size size})
classes (cond-> cls class (conj class))
svg-attrs (merge {:xmlns "http://www.w3.org/2000/svg"
:viewBox "0 0 24 24"
:fill "none"
:stroke "currentColor"
:stroke-width "2"
:stroke-linecap "round"
:stroke-linejoin "round"
:class classes
:aria-hidden "true"}
attrs)]
(into [:svg svg-attrs] paths))
:clj
(let [classes (cond-> (icon-classes {:size size})
class (str " " class))
svg-attrs (merge {:xmlns "http://www.w3.org/2000/svg"
:viewBox "0 0 24 24"
:fill "none"
:stroke "currentColor"
:stroke-width "2"
:stroke-linecap "round"
:stroke-linejoin "round"
:class classes
:aria-hidden "true"}
attrs)]
(into [:svg svg-attrs] paths))))))

24
src/ui/icon.css Normal file
View File

@@ -0,0 +1,24 @@
/* ── Icon ──────────────────────────────────────────────────────── */
.icon {
display: inline-block;
width: var(--size-5);
height: var(--size-5);
flex-shrink: 0;
vertical-align: middle;
}
.icon-sm {
width: var(--size-4);
height: var(--size-4);
}
.icon-lg {
width: var(--size-6);
height: var(--size-6);
}
.icon-xl {
width: var(--size-8);
height: var(--size-8);
}

437
src/ui/sidebar.cljc Normal file
View File

@@ -0,0 +1,437 @@
(ns ui.sidebar
(:require [clojure.string :as str]
[ui.icon :as icon]))
;; 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)))
;; ── Layout ──────────────────────────────────────────────────────────
(defn sidebar-layout-class-list [_opts] ["sidebar-layout"])
(defn sidebar-layout-classes [opts] (str/join " " (sidebar-layout-class-list opts)))
(defn sidebar-layout
"Wraps sidebar + main content in a flex row.
Props:
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:div (merge {:class (cond-> (sidebar-layout-classes {}) class (str " " class))} attrs)]
children)
:cljs
(into [:div (merge {:class (cond-> (sidebar-layout-class-list {}) class (conj class))} attrs)]
children)
:clj
(into [:div (merge {:class (cond-> (sidebar-layout-classes {}) class (str " " class))} attrs)]
children)))
(defn sidebar-layout-main
"Main content area next to the sidebar."
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:main (merge {:class (cond-> "sidebar-layout-main" class (str " " class))} attrs)]
children)
:cljs
(into [:main (merge {:class (cond-> ["sidebar-layout-main"] class (conj class))} attrs)]
children)
:clj
(into [:main (merge {:class (cond-> "sidebar-layout-main" class (str " " class))} attrs)]
children)))
;; ── Sidebar ─────────────────────────────────────────────────────────
(defn sidebar-class-list [_opts] ["sidebar"])
(defn sidebar-classes [opts] (str/join " " (sidebar-class-list opts)))
(defn sidebar
"Render the sidebar container.
Props:
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:aside (merge {:class (cond-> (sidebar-classes {}) class (str " " class))} attrs)]
children)
:cljs
(into [:aside (merge {:class (cond-> (sidebar-class-list {}) class (conj class))} attrs)]
children)
:clj
(into [:aside (merge {:class (cond-> (sidebar-classes {}) class (str " " class))} attrs)]
children)))
;; ── Sidebar Header ──────────────────────────────────────────────────
(defn sidebar-header
"Render the sidebar header area (top of sidebar).
Props:
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:div (merge {:class (cond-> "sidebar-header" class (str " " class))} attrs)]
children)
:cljs
(into [:div (merge {:class (cond-> ["sidebar-header"] class (conj class))} attrs)]
children)
:clj
(into [:div (merge {:class (cond-> "sidebar-header" class (str " " class))} attrs)]
children)))
;; ── Sidebar Brand ───────────────────────────────────────────────────
(defn sidebar-brand-class-list [_opts] ["sidebar-brand"])
(defn sidebar-brand-classes [opts] (str/join " " (sidebar-brand-class-list opts)))
(defn sidebar-brand
"Render a brand/logo block at the top of the sidebar.
Props:
:title - brand title (e.g. \"Acme Inc.\")
:subtitle - version or tagline (e.g. \"v1.0.0\")
:icon - single character or short text for the icon badge
:href - optional link URL
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [title subtitle icon href class attrs] :as _props}]
(let [icon-char (or icon (when title (subs title 0 1)))]
#?(:squint
(let [tag (if href :a :div)
classes (cond-> (sidebar-brand-classes {}) class (str " " class))
base-attrs (cond-> (merge {:class classes} attrs)
href (assoc :href href))]
[tag base-attrs
[:span {:class "sidebar-brand-icon"} icon-char]
[:span {:class "sidebar-brand-text"}
[:span {:class "sidebar-brand-title"} title]
(when subtitle
[:span {:class "sidebar-brand-subtitle"} subtitle])]])
:cljs
(let [tag (if href :a :div)
cls (sidebar-brand-class-list {})
classes (cond-> cls class (conj class))
base-attrs (cond-> (merge {:class classes} attrs)
href (assoc :href href))]
[tag base-attrs
[:span {:class ["sidebar-brand-icon"]} icon-char]
[:span {:class ["sidebar-brand-text"]}
[:span {:class ["sidebar-brand-title"]} title]
(when subtitle
[:span {:class ["sidebar-brand-subtitle"]} subtitle])]])
:clj
(let [tag (if href :a :div)
classes (cond-> (sidebar-brand-classes {}) class (str " " class))
base-attrs (cond-> (merge {:class classes} attrs)
href (assoc :href href))]
[tag base-attrs
[:span {:class "sidebar-brand-icon"} icon-char]
[:span {:class "sidebar-brand-text"}
[:span {:class "sidebar-brand-title"} title]
(when subtitle
[:span {:class "sidebar-brand-subtitle"} subtitle])]]))))
;; ── Sidebar Search ──────────────────────────────────────────────────
(defn sidebar-search-class-list [_opts] ["sidebar-search"])
(defn sidebar-search-classes [opts] (str/join " " (sidebar-search-class-list opts)))
(defn sidebar-search
"Render a search input in the sidebar.
Props:
:placeholder - input placeholder text
:value - current value
:on-change - change handler (cljs/squint only)
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [placeholder value on-change class attrs] :as _props}]
#?(:squint
(let [classes (cond-> (sidebar-search-classes {}) class (str " " class))
input-attrs (cond-> {:class "sidebar-search-input"
:type "search"
:placeholder (or placeholder "Search...")}
value (assoc :value value)
on-change (assoc :on-input on-change))]
[:div (merge {:class classes} attrs)
[:span {:class "sidebar-search-icon" :aria-hidden "true"}]
[:input input-attrs]])
:cljs
(let [cls (sidebar-search-class-list {})
classes (cond-> cls class (conj class))
input-attrs (cond-> {:class ["sidebar-search-input"]
:type "search"
:placeholder (or placeholder "Search...")}
value (assoc :value value)
on-change (assoc-in [:on :input] on-change))]
[:div (merge {:class classes} attrs)
[:span {:class ["sidebar-search-icon"] :aria-hidden "true"}]
[:input input-attrs]])
:clj
(let [classes (cond-> (sidebar-search-classes {}) class (str " " class))
input-attrs (cond-> {:class "sidebar-search-input"
:type "search"
:placeholder (or placeholder "Search...")}
value (assoc :value value))]
[:div (merge {:class classes} attrs)
[:span {:class "sidebar-search-icon" :aria-hidden "true"}]
[:input input-attrs]])))
;; ── Sidebar Content ─────────────────────────────────────────────────
(defn sidebar-content
"Render the scrollable content area of the sidebar.
Props:
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:nav (merge {:class (cond-> "sidebar-content" class (str " " class))} attrs)]
children)
:cljs
(into [:nav (merge {:class (cond-> ["sidebar-content"] class (conj class))} attrs)]
children)
:clj
(into [:nav (merge {:class (cond-> "sidebar-content" class (str " " class))} attrs)]
children)))
;; ── Sidebar Group ───────────────────────────────────────────────────
(defn sidebar-group-class-list [_opts] ["sidebar-group"])
(defn sidebar-group-classes [opts] (str/join " " (sidebar-group-class-list opts)))
(defn sidebar-group
"Render a nav group with a label and menu items.
Props:
:label - group heading text
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [label class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> (sidebar-group-classes {}) class (str " " class))]
(into [:div (merge {:class classes} attrs)]
(cond-> []
label (conj [:div {:class "sidebar-group-label"} label])
true (into children))))
:cljs
(let [cls (sidebar-group-class-list {})
classes (cond-> cls class (conj class))]
(into [:div (merge {:class classes} attrs)]
(cond-> []
label (conj [:div {:class ["sidebar-group-label"]} label])
true (into children))))
:clj
(let [classes (cond-> (sidebar-group-classes {}) class (str " " class))]
(into [:div (merge {:class classes} attrs)]
(cond-> []
label (conj [:div {:class "sidebar-group-label"} label])
true (into children))))))
;; ── Sidebar Menu ────────────────────────────────────────────────────
(defn sidebar-menu
"Render a list of sidebar menu items.
Props:
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:ul (merge {:class (cond-> "sidebar-menu" class (str " " class))} attrs)]
(map (fn [child] [:li child]) children))
:cljs
(into [:ul (merge {:class (cond-> ["sidebar-menu"] class (conj class))} attrs)]
(map (fn [child] [:li child]) children))
:clj
(into [:ul (merge {:class (cond-> "sidebar-menu" class (str " " class))} attrs)]
(map (fn [child] [:li child]) children))))
;; ── Sidebar Menu Item ───────────────────────────────────────────────
(defn sidebar-menu-item-class-list
"Returns class list for a menu item."
[{:keys [active]}]
(cond-> ["sidebar-menu-item"]
active (conj "sidebar-menu-item-active")))
(defn sidebar-menu-item-classes
"Returns space-joined class string for a menu item."
[opts]
(str/join " " (sidebar-menu-item-class-list opts)))
(defn sidebar-menu-item
"Render a single sidebar menu item.
Props:
:href - URL; renders as <a> when set, <button> otherwise
:active - boolean, highlights as active
:icon-name - keyword icon name (e.g. :home, :settings) from ui.icon
:badge - optional badge text/number
:on-click - click handler (cljs/squint only)
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [href active icon-name badge on-click class attrs] :as _props} & children]
(let [icon-el (when icon-name
#?(:squint
[:span {:class "sidebar-menu-item-icon"}
(icon/icon {:icon-name icon-name :size :sm})]
:cljs
[:span {:class ["sidebar-menu-item-icon"]}
(icon/icon {:icon-name icon-name :size :sm})]
:clj
[:span {:class "sidebar-menu-item-icon"}
(icon/icon {:icon-name icon-name :size :sm})]))]
#?(:squint
(let [tag (if href :a :button)
classes (cond-> (sidebar-menu-item-classes {:active active})
class (str " " class))
base-attrs (cond-> (merge {:class classes} attrs)
href (assoc :href href)
on-click (assoc :on-click on-click))]
(into [tag base-attrs]
(cond-> (if icon-el [icon-el] [])
true (into children)
badge (conj [:span {:class "sidebar-menu-item-badge"} badge]))))
:cljs
(let [tag (if href :a :button)
cls (sidebar-menu-item-class-list {:active active})
classes (cond-> cls class (conj class))
base-attrs (cond-> (merge {:class classes} attrs)
href (assoc :href href)
on-click (assoc-in [:on :click] on-click))]
(into [tag base-attrs]
(cond-> (if icon-el [icon-el] [])
true (into children)
badge (conj [:span {:class ["sidebar-menu-item-badge"]} badge]))))
:clj
(let [tag (if href :a :button)
classes (cond-> (sidebar-menu-item-classes {:active active})
class (str " " class))
base-attrs (cond-> (merge {:class classes} attrs)
href (assoc :href href))]
(into [tag base-attrs]
(cond-> (if icon-el [icon-el] [])
true (into children)
badge (conj [:span {:class "sidebar-menu-item-badge"} badge])))))))
;; ── Sidebar Collapsible ─────────────────────────────────────────────
(defn sidebar-collapsible
"Render a collapsible section in the sidebar using <details>/<summary>.
Props:
:title - trigger text
:open - boolean, initially expanded
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [title open class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> "sidebar-collapsible" class (str " " class))
base-attrs (cond-> (merge {:class classes} attrs)
open (assoc :open true))]
[:details base-attrs
[:summary
[:span title]
[:span {:class "sidebar-collapsible-chevron" :aria-hidden "true"}]]
(into [:div {:class "sidebar-collapsible-content"}] children)])
:cljs
(let [classes (cond-> ["sidebar-collapsible"] class (conj class))
base-attrs (cond-> (merge {:class classes} attrs)
open (assoc :open true))]
[:details base-attrs
[:summary
[:span title]
[:span {:class ["sidebar-collapsible-chevron"] :aria-hidden "true"}]]
(into [:div {:class ["sidebar-collapsible-content"]}] children)])
:clj
(let [classes (cond-> "sidebar-collapsible" class (str " " class))
base-attrs (cond-> (merge {:class classes} attrs)
open (assoc :open true))]
[:details base-attrs
[:summary
[:span title]
[:span {:class "sidebar-collapsible-chevron" :aria-hidden "true"}]]
(into [:div {:class "sidebar-collapsible-content"}] children)])))
;; ── Sidebar Separator ───────────────────────────────────────────────
(defn sidebar-separator
"Render a horizontal divider in the sidebar."
([] (sidebar-separator {}))
([{:keys [class attrs] :as _props}]
#?(:squint [:hr (merge {:class (cond-> "sidebar-separator" class (str " " class))} attrs)]
:cljs [:hr (merge {:class (cond-> ["sidebar-separator"] class (conj class))} attrs)]
:clj [:hr (merge {:class (cond-> "sidebar-separator" class (str " " class))} attrs)])))
;; ── Sidebar Footer ──────────────────────────────────────────────────
(defn sidebar-footer
"Render the sidebar footer area (bottom of sidebar).
Props:
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:div (merge {:class (cond-> "sidebar-footer" class (str " " class))} attrs)]
children)
:cljs
(into [:div (merge {:class (cond-> ["sidebar-footer"] class (conj class))} attrs)]
children)
:clj
(into [:div (merge {:class (cond-> "sidebar-footer" class (str " " class))} attrs)]
children)))
;; ── Sidebar User ────────────────────────────────────────────────────
(defn sidebar-user
"Render a user info block (typically in the footer).
Props:
:name - user display name
:email - user email
:avatar - one or two characters for the avatar circle
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [#?@(:squint [user-name] :cljs [user-name] :clj [user-name])
email avatar class attrs] :as _props}]
(let [initials (or avatar (when user-name (subs user-name 0 2)))]
#?(:squint
[:div (merge {:class (cond-> "sidebar-user" class (str " " class))} attrs)
[:span {:class "sidebar-user-avatar"} initials]
[:span {:class "sidebar-user-info"}
[:span {:class "sidebar-user-name"} user-name]
(when email
[:span {:class "sidebar-user-email"} email])]]
:cljs
[:div (merge {:class (cond-> ["sidebar-user"] class (conj class))} attrs)
[:span {:class ["sidebar-user-avatar"]} initials]
[:span {:class ["sidebar-user-info"]}
[:span {:class ["sidebar-user-name"]} user-name]
(when email
[:span {:class ["sidebar-user-email"]} email])]]
:clj
[:div (merge {:class (cond-> "sidebar-user" class (str " " class))} attrs)
[:span {:class "sidebar-user-avatar"} initials]
[:span {:class "sidebar-user-info"}
[:span {:class "sidebar-user-name"} user-name]
(when email
[:span {:class "sidebar-user-email"} email])]])))

389
src/ui/sidebar.css Normal file
View File

@@ -0,0 +1,389 @@
/* ── Sidebar Layout ─────────────────────────────────────────────── */
.sidebar-layout {
display: flex;
min-height: 100vh;
min-height: 100dvh;
}
.sidebar-layout-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* ── Sidebar ───────────────────────────────────────────────────── */
.sidebar {
display: flex;
flex-direction: column;
width: 16rem;
flex-shrink: 0;
background: var(--bg-0);
border-right: var(--border-0);
height: 100vh;
height: 100dvh;
position: sticky;
top: 0;
overflow: hidden;
}
/* ── Sidebar Header ────────────────────────────────────────────── */
.sidebar-header {
display: flex;
flex-direction: column;
gap: var(--size-3);
padding: var(--size-3) var(--size-3) var(--size-2);
}
/* ── Sidebar Brand ─────────────────────────────────────────────── */
.sidebar-brand {
display: flex;
align-items: center;
gap: var(--size-3);
padding: var(--size-1) var(--size-1);
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
cursor: pointer;
min-height: var(--size-10);
}
.sidebar-brand:hover {
background: var(--bg-1);
}
.sidebar-brand-icon {
display: flex;
align-items: center;
justify-content: center;
width: var(--size-8);
height: var(--size-8);
flex-shrink: 0;
border-radius: var(--radius-md);
background: var(--accent);
color: var(--fg-on-accent);
font-weight: 700;
font-size: var(--font-sm);
}
.sidebar-brand-text {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
.sidebar-brand-title {
font-size: var(--font-sm);
font-weight: 600;
color: var(--fg-0);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-brand-subtitle {
font-size: var(--font-xs);
color: var(--fg-2);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Sidebar Search ────────────────────────────────────────────── */
.sidebar-search {
position: relative;
}
.sidebar-search-icon {
position: absolute;
left: var(--size-2);
top: 50%;
transform: translateY(-50%);
width: 1rem;
height: 1rem;
pointer-events: none;
opacity: 0.5;
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' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
.sidebar-search-input {
width: 100%;
padding: var(--size-2) var(--size-3) var(--size-2) var(--size-8);
font-size: var(--font-sm);
font-family: inherit;
line-height: 1.5;
background: var(--bg-1);
color: var(--fg-0);
border: var(--border-0);
border-radius: var(--radius-md);
transition: border-color 150ms ease, background-color 150ms ease;
}
.sidebar-search-input::placeholder {
color: var(--fg-2);
}
.sidebar-search-input:focus-visible {
background: var(--bg-0);
border-color: var(--accent);
outline: none;
}
/* ── Sidebar Content ───────────────────────────────────────────── */
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: var(--size-2) var(--size-3);
}
/* Scrollbar styling */
.sidebar-content::-webkit-scrollbar {
width: 4px;
}
.sidebar-content::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-content::-webkit-scrollbar-thumb {
background: var(--bg-2);
border-radius: 9999px;
}
/* ── Sidebar Group ─────────────────────────────────────────────── */
.sidebar-group {
margin-bottom: var(--size-4);
}
.sidebar-group:last-child {
margin-bottom: 0;
}
.sidebar-group-label {
display: flex;
align-items: center;
padding: var(--size-1) var(--size-2);
font-size: var(--font-xs);
font-weight: 500;
color: var(--fg-2);
text-transform: uppercase;
letter-spacing: 0.05em;
user-select: none;
}
/* ── Sidebar Menu ──────────────────────────────────────────────── */
.sidebar-menu {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--size-1);
}
/* ── Sidebar Menu Item ─────────────────────────────────────────── */
.sidebar-menu-item {
display: flex;
align-items: center;
gap: var(--size-2);
padding: var(--size-2) var(--size-2);
font-size: var(--font-sm);
color: var(--fg-1);
border-radius: var(--radius-md);
text-decoration: none;
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
border: none;
background: transparent;
font-family: inherit;
width: 100%;
text-align: left;
line-height: 1.4;
}
a.sidebar-menu-item {
text-decoration: none;
color: var(--fg-1);
}
.sidebar-menu-item:hover {
background: var(--bg-1);
color: var(--fg-0);
}
.sidebar-menu-item-active {
background: var(--bg-1);
color: var(--fg-0);
font-weight: 500;
}
.sidebar-menu-item-active:hover {
background: var(--bg-2);
}
.sidebar-menu-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
flex-shrink: 0;
opacity: 0.7;
}
.sidebar-menu-item-active .sidebar-menu-item-icon {
opacity: 1;
}
.sidebar-menu-item-badge {
margin-left: auto;
font-size: var(--font-xs);
padding: 0 var(--size-2);
background: var(--bg-2);
color: var(--fg-2);
border-radius: 9999px;
line-height: 1.6;
font-weight: 500;
}
/* ── Sidebar Separator ─────────────────────────────────────────── */
.sidebar-separator {
height: 1px;
background: var(--bg-2);
border: none;
margin: var(--size-2) var(--size-2);
}
/* ── Sidebar Footer ────────────────────────────────────────────── */
.sidebar-footer {
display: flex;
flex-direction: column;
gap: var(--size-1);
padding: var(--size-3);
border-top: var(--border-0);
}
/* ── Sidebar User ──────────────────────────────────────────────── */
.sidebar-user {
display: flex;
align-items: center;
gap: var(--size-3);
padding: var(--size-2) var(--size-1);
border-radius: var(--radius-md);
}
.sidebar-user-avatar {
display: flex;
align-items: center;
justify-content: center;
width: var(--size-8);
height: var(--size-8);
flex-shrink: 0;
border-radius: 9999px;
background: var(--bg-2);
color: var(--fg-1);
font-weight: 600;
font-size: var(--font-xs);
}
.sidebar-user-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.sidebar-user-name {
font-size: var(--font-sm);
font-weight: 500;
color: var(--fg-0);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-user-email {
font-size: var(--font-xs);
color: var(--fg-2);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Sidebar Collapsible Section ───────────────────────────────── */
.sidebar-collapsible {
border: none;
margin: 0;
}
.sidebar-collapsible > summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--size-2);
padding: var(--size-2) var(--size-2);
font-size: var(--font-sm);
font-weight: 500;
color: var(--fg-1);
cursor: pointer;
user-select: none;
border-radius: var(--radius-md);
list-style: none;
transition: background-color 150ms ease, color 150ms ease;
}
.sidebar-collapsible > summary::-webkit-details-marker,
.sidebar-collapsible > summary::marker {
display: none;
content: "";
}
.sidebar-collapsible > summary:hover {
background: var(--bg-1);
color: var(--fg-0);
}
.sidebar-collapsible-chevron {
display: block;
width: 1rem;
height: 1rem;
flex-shrink: 0;
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='m9 18 6-6-6-6'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
transition: transform 150ms ease;
}
.sidebar-collapsible[open] > summary .sidebar-collapsible-chevron {
transform: rotate(90deg);
}
.sidebar-collapsible-content {
padding-left: var(--size-4);
}
.sidebar-collapsible-content .sidebar-menu {
padding-top: var(--size-1);
border-left: var(--border-0);
padding-left: var(--size-2);
}