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:
4
bb.edn
4
bb.edn
@@ -31,6 +31,8 @@
|
||||
[ui.breadcrumb-test]
|
||||
[ui.pagination-test]
|
||||
[ui.form-test]
|
||||
[ui.icon-test]
|
||||
[ui.sidebar-test]
|
||||
[ui.theme-test])
|
||||
:task (let [{:keys [fail error]} (t/run-tests
|
||||
'ui.button-test
|
||||
@@ -48,6 +50,8 @@
|
||||
'ui.breadcrumb-test
|
||||
'ui.pagination-test
|
||||
'ui.form-test
|
||||
'ui.icon-test
|
||||
'ui.sidebar-test
|
||||
'ui.theme-test)]
|
||||
(when (pos? (+ fail error))
|
||||
(System/exit 1)))}
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
[ui.tooltip :as tooltip]
|
||||
[ui.breadcrumb :as breadcrumb]
|
||||
[ui.pagination :as pagination]
|
||||
[ui.form :as form]))
|
||||
[ui.form :as form]
|
||||
[ui.sidebar :as sidebar]
|
||||
[ui.icon :as icon]))
|
||||
|
||||
(defn section [title & children]
|
||||
[:section {:style "margin-bottom: 2.5rem;"}
|
||||
@@ -218,6 +220,65 @@
|
||||
(form/form-field {:label "Email" :error "Please enter a valid email address."}
|
||||
(form/form-input {:type :email :error true :value "invalid-email"}))]))
|
||||
|
||||
;; ── Icon ─────────────────────────────────────────────────────────
|
||||
(defn icon-demo []
|
||||
(section "Icon"
|
||||
[:div {:style "display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: center;"}
|
||||
(for [n [:home :search :settings :user :mail :bell :calendar :star
|
||||
:file :folder :code :terminal :globe :shield :zap :heart
|
||||
:plus :minus :check :edit :trash :download :upload :copy
|
||||
:eye :lock :bookmark :inbox :database :map-pin]]
|
||||
[:div {:style "display: flex; flex-direction: column; align-items: center; gap: 0.25rem;"}
|
||||
(icon/icon {:icon-name n})
|
||||
[:span {:style "font-size: var(--font-xs); color: var(--fg-2);"} (name n)]])]
|
||||
[:div {:style "display: flex; gap: 1rem; align-items: center; margin-top: 0.5rem;"}
|
||||
[:span {:style "font-size: var(--font-xs); color: var(--fg-2);"} "Sizes:"]
|
||||
(icon/icon {:icon-name :star :size :sm})
|
||||
(icon/icon {:icon-name :star})
|
||||
(icon/icon {:icon-name :star :size :lg})
|
||||
(icon/icon {:icon-name :star :size :xl})]))
|
||||
|
||||
;; ── Sidebar ─────────────────────────────────────────────────────────
|
||||
(defn sidebar-demo []
|
||||
(section "Sidebar"
|
||||
(sidebar/sidebar-layout {}
|
||||
(sidebar/sidebar {}
|
||||
(sidebar/sidebar-header {}
|
||||
(sidebar/sidebar-brand {:title "Acme Inc." :subtitle "Enterprise" :icon "A"})
|
||||
(sidebar/sidebar-search {:placeholder "Search..."}))
|
||||
(sidebar/sidebar-content {}
|
||||
(sidebar/sidebar-group {:label "Getting Started"}
|
||||
(sidebar/sidebar-menu {}
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :download} "Installation")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :folder :active true} "Project Structure")))
|
||||
(sidebar/sidebar-group {:label "Building"}
|
||||
(sidebar/sidebar-menu {}
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :globe} "Routing")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :database :badge "New"} "Data Fetching")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :layers} "Rendering")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :zap} "Caching")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :eye} "Styling")))
|
||||
(sidebar/sidebar-group {:label "API Reference"}
|
||||
(sidebar/sidebar-collapsible {:title "Components" :open true}
|
||||
(sidebar/sidebar-menu {}
|
||||
(sidebar/sidebar-menu-item {:href "#"} "Button")
|
||||
(sidebar/sidebar-menu-item {:href "#"} "Card")
|
||||
(sidebar/sidebar-menu-item {:href "#"} "Dialog")))
|
||||
(sidebar/sidebar-collapsible {:title "Functions"}
|
||||
(sidebar/sidebar-menu {}
|
||||
(sidebar/sidebar-menu-item {:href "#"} "fetch")
|
||||
(sidebar/sidebar-menu-item {:href "#"} "redirect")))))
|
||||
(sidebar/sidebar-footer {}
|
||||
(sidebar/sidebar-user {:user-name "Alice Johnson" :email "alice@example.com"})))
|
||||
(sidebar/sidebar-layout-main {}
|
||||
[:div {:style "padding: 2rem;"}
|
||||
[:h2 {:style "margin: 0 0 1rem; color: var(--fg-0);"} "Dashboard"]
|
||||
[:div {:style "display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;"}
|
||||
[:div {:style "aspect-ratio: 16/9; background: var(--bg-1); border-radius: var(--radius-lg); border: var(--border-0);"}]
|
||||
[:div {:style "aspect-ratio: 16/9; background: var(--bg-1); border-radius: var(--radius-lg); border: var(--border-0);"}]
|
||||
[:div {:style "aspect-ratio: 16/9; background: var(--bg-1); border-radius: var(--radius-lg); border: var(--border-0);"}]]
|
||||
[:div {:style "margin-top: 1rem; min-height: 200px; background: var(--bg-1); border-radius: var(--radius-lg); border: var(--border-0);"}]]))))
|
||||
|
||||
;; ── Page ────────────────────────────────────────────────────────────
|
||||
(defn page []
|
||||
(str
|
||||
@@ -250,7 +311,9 @@
|
||||
(tooltip-demo)
|
||||
(breadcrumb-demo)
|
||||
(pagination-demo)
|
||||
(form-demo)]]])))
|
||||
(form-demo)
|
||||
(icon-demo)]
|
||||
(sidebar-demo)]])))
|
||||
|
||||
(defn handler [{:keys [uri]}]
|
||||
(case uri
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
[ui.tooltip :as tooltip]
|
||||
[ui.breadcrumb :as breadcrumb]
|
||||
[ui.pagination :as pagination]
|
||||
[ui.form :as form]))
|
||||
[ui.form :as form]
|
||||
[ui.sidebar :as sidebar]
|
||||
[ui.icon :as icon]))
|
||||
|
||||
(defn section [title & children]
|
||||
[:section {:style {:margin-bottom "2.5rem"}}
|
||||
@@ -221,6 +223,65 @@
|
||||
(form/form-field {:label "Email" :error "Please enter a valid email address."}
|
||||
(form/form-input {:type :email :error true :value "invalid-email"}))]))
|
||||
|
||||
;; ── Icon ─────────────────────────────────────────────────────────
|
||||
(defn icon-demo []
|
||||
(section "Icon"
|
||||
[:div {:style {:display "flex" :gap "1.5rem" :flex-wrap "wrap" :align-items "center"}}
|
||||
(for [n [:home :search :settings :user :mail :bell :calendar :star
|
||||
:file :folder :code :terminal :globe :shield :zap :heart
|
||||
:plus :minus :check :edit :trash :download :upload :copy
|
||||
:eye :lock :bookmark :inbox :database :map-pin]]
|
||||
[:div {:style {:display "flex" :flex-direction "column" :align-items "center" :gap "0.25rem"}}
|
||||
(icon/icon {:icon-name n})
|
||||
[:span {:style {:font-size "var(--font-xs)" :color "var(--fg-2)"}} (name n)]])]
|
||||
[:div {:style {:display "flex" :gap "1rem" :align-items "center" :margin-top "0.5rem"}}
|
||||
[:span {:style {:font-size "var(--font-xs)" :color "var(--fg-2)"}} "Sizes:"]
|
||||
(icon/icon {:icon-name :star :size :sm})
|
||||
(icon/icon {:icon-name :star})
|
||||
(icon/icon {:icon-name :star :size :lg})
|
||||
(icon/icon {:icon-name :star :size :xl})]))
|
||||
|
||||
;; ── Sidebar ─────────────────────────────────────────────────────────
|
||||
(defn sidebar-demo []
|
||||
(section "Sidebar"
|
||||
(sidebar/sidebar-layout {}
|
||||
(sidebar/sidebar {}
|
||||
(sidebar/sidebar-header {}
|
||||
(sidebar/sidebar-brand {:title "Acme Inc." :subtitle "Enterprise" :icon "A"})
|
||||
(sidebar/sidebar-search {:placeholder "Search..."}))
|
||||
(sidebar/sidebar-content {}
|
||||
(sidebar/sidebar-group {:label "Getting Started"}
|
||||
(sidebar/sidebar-menu {}
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :download} "Installation")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :folder :active true} "Project Structure")))
|
||||
(sidebar/sidebar-group {:label "Building"}
|
||||
(sidebar/sidebar-menu {}
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :globe} "Routing")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :database :badge "New"} "Data Fetching")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :layers} "Rendering")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :zap} "Caching")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name :eye} "Styling")))
|
||||
(sidebar/sidebar-group {:label "API Reference"}
|
||||
(sidebar/sidebar-collapsible {:title "Components" :open true}
|
||||
(sidebar/sidebar-menu {}
|
||||
(sidebar/sidebar-menu-item {:href "#"} "Button")
|
||||
(sidebar/sidebar-menu-item {:href "#"} "Card")
|
||||
(sidebar/sidebar-menu-item {:href "#"} "Dialog")))
|
||||
(sidebar/sidebar-collapsible {:title "Functions"}
|
||||
(sidebar/sidebar-menu {}
|
||||
(sidebar/sidebar-menu-item {:href "#"} "fetch")
|
||||
(sidebar/sidebar-menu-item {:href "#"} "redirect")))))
|
||||
(sidebar/sidebar-footer {}
|
||||
(sidebar/sidebar-user {:user-name "Alice Johnson" :email "alice@example.com"})))
|
||||
(sidebar/sidebar-layout-main {}
|
||||
[:div {:style {:padding "2rem"}}
|
||||
[:h2 {:style {:margin "0 0 1rem" :color "var(--fg-0)"}} "Dashboard"]
|
||||
[:div {:style {:display "grid" :grid-template-columns "repeat(3, 1fr)" :gap "1rem"}}
|
||||
[:div {:style {:aspect-ratio "16/9" :background "var(--bg-1)" :border-radius "var(--radius-lg)" :border "var(--border-0)"}}]
|
||||
[:div {:style {:aspect-ratio "16/9" :background "var(--bg-1)" :border-radius "var(--radius-lg)" :border "var(--border-0)"}}]
|
||||
[:div {:style {:aspect-ratio "16/9" :background "var(--bg-1)" :border-radius "var(--radius-lg)" :border "var(--border-0)"}}]]
|
||||
[:div {:style {:margin-top "1rem" :min-height "200px" :background "var(--bg-1)" :border-radius "var(--radius-lg)" :border "var(--border-0)"}}]]))))
|
||||
|
||||
;; ── Theme toggle ────────────────────────────────────────────────────
|
||||
(defn toggle-theme! [_e]
|
||||
(let [el (.-documentElement js/document)
|
||||
@@ -251,7 +312,9 @@
|
||||
(tooltip-demo)
|
||||
(breadcrumb-demo)
|
||||
(pagination-demo)
|
||||
(form-demo)])
|
||||
(form-demo)
|
||||
(icon-demo)
|
||||
(sidebar-demo)])
|
||||
|
||||
(defn ^:export init! []
|
||||
(d/set-dispatch! (fn [_ _]))
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
[ui.tooltip :as tooltip]
|
||||
[ui.breadcrumb :as breadcrumb]
|
||||
[ui.pagination :as pagination]
|
||||
[ui.form :as form]))
|
||||
[ui.form :as form]
|
||||
[ui.sidebar :as sidebar]
|
||||
[ui.icon :as icon]))
|
||||
|
||||
(defn toggle-theme! [_e]
|
||||
(let [el (.-documentElement js/document)
|
||||
@@ -229,6 +231,66 @@
|
||||
(form/form-field {:label "Email" :error "Please enter a valid email address."}
|
||||
(form/form-input {:type "email" :error true :value "invalid-email"}))]))
|
||||
|
||||
;; ── Icon ─────────────────────────────────────────────────────────
|
||||
(defn icon-demo []
|
||||
(section "Icon"
|
||||
(into [:div {:style {"display" "flex" "gap" "1.5rem" "flex-wrap" "wrap" "align-items" "center"}}]
|
||||
(map (fn [n]
|
||||
[:div {:style {"display" "flex" "flex-direction" "column" "align-items" "center" "gap" "0.25rem"}}
|
||||
(icon/icon {:icon-name n})
|
||||
[:span {:style {"font-size" "var(--font-xs)" "color" "var(--fg-2)"}} n]])
|
||||
["home" "search" "settings" "user" "mail" "bell" "calendar" "star"
|
||||
"file" "folder" "code" "terminal" "globe" "shield" "zap" "heart"
|
||||
"plus" "minus" "check" "edit" "trash" "download" "upload" "copy"
|
||||
"eye" "lock" "bookmark" "inbox" "database" "map-pin"]))
|
||||
[:div {:style {"display" "flex" "gap" "1rem" "align-items" "center" "margin-top" "0.5rem"}}
|
||||
[:span {:style {"font-size" "var(--font-xs)" "color" "var(--fg-2)"}} "Sizes:"]
|
||||
(icon/icon {:icon-name "star" :size "sm"})
|
||||
(icon/icon {:icon-name "star"})
|
||||
(icon/icon {:icon-name "star" :size "lg"})
|
||||
(icon/icon {:icon-name "star" :size "xl"})]))
|
||||
|
||||
;; ── Sidebar ─────────────────────────────────────────────────────────
|
||||
(defn sidebar-demo []
|
||||
(section "Sidebar"
|
||||
(sidebar/sidebar-layout {}
|
||||
(sidebar/sidebar {}
|
||||
(sidebar/sidebar-header {}
|
||||
(sidebar/sidebar-brand {:title "Acme Inc." :subtitle "Enterprise" :icon "A"})
|
||||
(sidebar/sidebar-search {:placeholder "Search..."}))
|
||||
(sidebar/sidebar-content {}
|
||||
(sidebar/sidebar-group {:label "Getting Started"}
|
||||
(sidebar/sidebar-menu {}
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name "download"} "Installation")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name "folder" :active true} "Project Structure")))
|
||||
(sidebar/sidebar-group {:label "Building"}
|
||||
(sidebar/sidebar-menu {}
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name "globe"} "Routing")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name "database" :badge "New"} "Data Fetching")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name "layers"} "Rendering")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name "zap"} "Caching")
|
||||
(sidebar/sidebar-menu-item {:href "#" :icon-name "eye"} "Styling")))
|
||||
(sidebar/sidebar-group {:label "API Reference"}
|
||||
(sidebar/sidebar-collapsible {:title "Components" :open true}
|
||||
(sidebar/sidebar-menu {}
|
||||
(sidebar/sidebar-menu-item {:href "#"} "Button")
|
||||
(sidebar/sidebar-menu-item {:href "#"} "Card")
|
||||
(sidebar/sidebar-menu-item {:href "#"} "Dialog")))
|
||||
(sidebar/sidebar-collapsible {:title "Functions"}
|
||||
(sidebar/sidebar-menu {}
|
||||
(sidebar/sidebar-menu-item {:href "#"} "fetch")
|
||||
(sidebar/sidebar-menu-item {:href "#"} "redirect")))))
|
||||
(sidebar/sidebar-footer {}
|
||||
(sidebar/sidebar-user {:user-name "Alice Johnson" :email "alice@example.com"})))
|
||||
(sidebar/sidebar-layout-main {}
|
||||
[:div {:style {"padding" "2rem"}}
|
||||
[:h2 {:style {"margin" "0 0 1rem" "color" "var(--fg-0)"}} "Dashboard"]
|
||||
[:div {:style {"display" "grid" "grid-template-columns" "repeat(3, 1fr)" "gap" "1rem"}}
|
||||
[:div {:style {"aspect-ratio" "16/9" "background" "var(--bg-1)" "border-radius" "var(--radius-lg)" "border" "var(--border-0)"}}]
|
||||
[:div {:style {"aspect-ratio" "16/9" "background" "var(--bg-1)" "border-radius" "var(--radius-lg)" "border" "var(--border-0)"}}]
|
||||
[:div {:style {"aspect-ratio" "16/9" "background" "var(--bg-1)" "border-radius" "var(--radius-lg)" "border" "var(--border-0)"}}]]
|
||||
[:div {:style {"margin-top" "1rem" "min-height" "200px" "background" "var(--bg-1)" "border-radius" "var(--radius-lg)" "border" "var(--border-0)"}}]]))))
|
||||
|
||||
;; ── App ─────────────────────────────────────────────────────────────
|
||||
(defn app []
|
||||
[:div {:style {"max-width" "800px" "margin" "0 auto"}}
|
||||
@@ -252,7 +314,9 @@
|
||||
(tooltip-demo)
|
||||
(breadcrumb-demo)
|
||||
(pagination-demo)
|
||||
(form-demo)])
|
||||
(form-demo)
|
||||
(icon-demo)
|
||||
(sidebar-demo)])
|
||||
|
||||
(defn init! []
|
||||
(eu/render (app) (js/document.getElementById "app")))
|
||||
|
||||
377
src/ui/icon.cljc
Normal file
377
src/ui/icon.cljc
Normal 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
24
src/ui/icon.css
Normal 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
437
src/ui/sidebar.cljc
Normal 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
389
src/ui/sidebar.css
Normal 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);
|
||||
}
|
||||
78
test/ui/icon_test.clj
Normal file
78
test/ui/icon_test.clj
Normal file
@@ -0,0 +1,78 @@
|
||||
(ns ui.icon-test
|
||||
(:require [clojure.test :refer [deftest is testing]]
|
||||
[ui.icon :as icon]))
|
||||
|
||||
(deftest icon-class-list-test
|
||||
(testing "default size (md)"
|
||||
(is (= ["icon"] (icon/icon-class-list {})))
|
||||
(is (= ["icon"] (icon/icon-class-list {:size :md}))))
|
||||
(testing "small"
|
||||
(is (= ["icon" "icon-sm"] (icon/icon-class-list {:size :sm}))))
|
||||
(testing "large"
|
||||
(is (= ["icon" "icon-lg"] (icon/icon-class-list {:size :lg}))))
|
||||
(testing "xl"
|
||||
(is (= ["icon" "icon-xl"] (icon/icon-class-list {:size :xl})))))
|
||||
|
||||
(deftest icon-classes-test
|
||||
(testing "default"
|
||||
(is (= "icon" (icon/icon-classes {}))))
|
||||
(testing "with size"
|
||||
(is (= "icon icon-sm" (icon/icon-classes {:size :sm})))
|
||||
(is (= "icon icon-lg" (icon/icon-classes {:size :lg})))))
|
||||
|
||||
(deftest icon-component-test
|
||||
(testing "renders SVG for known icon"
|
||||
(let [result (icon/icon {:icon-name :home})]
|
||||
(is (= :svg (first result)))
|
||||
(is (= "icon" (get-in result [1 :class])))
|
||||
(is (= "0 0 24 24" (get-in result [1 :viewBox])))
|
||||
(is (= "none" (get-in result [1 :fill])))
|
||||
(is (= "currentColor" (get-in result [1 :stroke])))
|
||||
(is (= "true" (get-in result [1 :aria-hidden])))))
|
||||
|
||||
(testing "returns nil for unknown icon"
|
||||
(is (nil? (icon/icon {:icon-name :nonexistent-icon-xyz}))))
|
||||
|
||||
(testing "applies size class"
|
||||
(let [result (icon/icon {:icon-name :search :size :lg})]
|
||||
(is (= "icon icon-lg" (get-in result [1 :class])))))
|
||||
|
||||
(testing "appends custom class"
|
||||
(let [result (icon/icon {:icon-name :search :class "custom"})]
|
||||
(is (= "icon custom" (get-in result [1 :class])))))
|
||||
|
||||
(testing "merges extra attrs"
|
||||
(let [result (icon/icon {:icon-name :check :attrs {:id "my-icon"}})]
|
||||
(is (= "my-icon" (get-in result [1 :id])))))
|
||||
|
||||
(testing "home icon has correct number of children"
|
||||
(let [result (icon/icon {:icon-name :home})]
|
||||
;; :svg + attrs + 2 path elements = 4 items
|
||||
(is (= 4 (count result)))))
|
||||
|
||||
(testing "simple icon has correct path data"
|
||||
(let [result (icon/icon {:icon-name :check})
|
||||
path (nth result 2)]
|
||||
(is (= :path (first path)))
|
||||
(is (= "M20 6 9 17l-5-5" (get-in path [1 :d]))))))
|
||||
|
||||
(deftest icon-names-test
|
||||
(testing "icon-names contains expected icons"
|
||||
(is (contains? icon/icon-names :home))
|
||||
(is (contains? icon/icon-names :search))
|
||||
(is (contains? icon/icon-names :settings))
|
||||
(is (contains? icon/icon-names :user))
|
||||
(is (contains? icon/icon-names :mail))
|
||||
(is (contains? icon/icon-names :chevron-down))
|
||||
(is (contains? icon/icon-names :code))
|
||||
(is (contains? icon/icon-names :globe)))
|
||||
|
||||
(testing "icon-names has reasonable count"
|
||||
(is (>= (count icon/icon-names) 40))))
|
||||
|
||||
(deftest all-icons-render-test
|
||||
(testing "every icon in the set renders successfully"
|
||||
(doseq [n icon/icon-names]
|
||||
(let [result (icon/icon {:icon-name n})]
|
||||
(is (some? result) (str "Icon " n " should render"))
|
||||
(is (= :svg (first result)) (str "Icon " n " should be an SVG"))))))
|
||||
172
test/ui/sidebar_test.clj
Normal file
172
test/ui/sidebar_test.clj
Normal file
@@ -0,0 +1,172 @@
|
||||
(ns ui.sidebar-test
|
||||
(:require [clojure.test :refer [deftest is testing]]
|
||||
[ui.sidebar :as sidebar]))
|
||||
|
||||
;; ── Class generation ────────────────────────────────────────────────
|
||||
|
||||
(deftest sidebar-layout-class-list-test
|
||||
(testing "returns sidebar-layout class"
|
||||
(is (= ["sidebar-layout"] (sidebar/sidebar-layout-class-list {})))))
|
||||
|
||||
(deftest sidebar-class-list-test
|
||||
(testing "returns sidebar class"
|
||||
(is (= ["sidebar"] (sidebar/sidebar-class-list {})))))
|
||||
|
||||
(deftest sidebar-brand-class-list-test
|
||||
(testing "returns sidebar-brand class"
|
||||
(is (= ["sidebar-brand"] (sidebar/sidebar-brand-class-list {})))))
|
||||
|
||||
(deftest sidebar-search-class-list-test
|
||||
(testing "returns sidebar-search class"
|
||||
(is (= ["sidebar-search"] (sidebar/sidebar-search-class-list {})))))
|
||||
|
||||
(deftest sidebar-group-class-list-test
|
||||
(testing "returns sidebar-group class"
|
||||
(is (= ["sidebar-group"] (sidebar/sidebar-group-class-list {})))))
|
||||
|
||||
(deftest sidebar-menu-item-class-list-test
|
||||
(testing "default item"
|
||||
(is (= ["sidebar-menu-item"] (sidebar/sidebar-menu-item-class-list {}))))
|
||||
(testing "active item"
|
||||
(is (= ["sidebar-menu-item" "sidebar-menu-item-active"]
|
||||
(sidebar/sidebar-menu-item-class-list {:active true}))))
|
||||
(testing "inactive item"
|
||||
(is (= ["sidebar-menu-item"]
|
||||
(sidebar/sidebar-menu-item-class-list {:active false})))))
|
||||
|
||||
(deftest sidebar-menu-item-classes-test
|
||||
(testing "default"
|
||||
(is (= "sidebar-menu-item" (sidebar/sidebar-menu-item-classes {}))))
|
||||
(testing "active"
|
||||
(is (= "sidebar-menu-item sidebar-menu-item-active"
|
||||
(sidebar/sidebar-menu-item-classes {:active true})))))
|
||||
|
||||
;; ── Component rendering (clj target) ───────────────────────────────
|
||||
|
||||
(deftest sidebar-layout-test
|
||||
(testing "renders div with sidebar-layout class"
|
||||
(let [result (sidebar/sidebar-layout {} [:div "Content"])]
|
||||
(is (= :div (first result)))
|
||||
(is (= "sidebar-layout" (get-in result [1 :class])))))
|
||||
(testing "extra class appended"
|
||||
(let [result (sidebar/sidebar-layout {:class "custom"} [:div])]
|
||||
(is (= "sidebar-layout custom" (get-in result [1 :class]))))))
|
||||
|
||||
(deftest sidebar-test
|
||||
(testing "renders aside tag"
|
||||
(let [result (sidebar/sidebar {} [:div "Nav"])]
|
||||
(is (= :aside (first result)))
|
||||
(is (= "sidebar" (get-in result [1 :class]))))))
|
||||
|
||||
(deftest sidebar-header-test
|
||||
(testing "renders header div"
|
||||
(let [result (sidebar/sidebar-header {} [:span "Brand"])]
|
||||
(is (= :div (first result)))
|
||||
(is (= "sidebar-header" (get-in result [1 :class]))))))
|
||||
|
||||
(deftest sidebar-brand-test
|
||||
(testing "renders div by default"
|
||||
(let [result (sidebar/sidebar-brand {:title "Acme" :subtitle "v1.0"})]
|
||||
(is (= :div (first result)))
|
||||
(is (= "sidebar-brand" (get-in result [1 :class])))))
|
||||
(testing "renders <a> with href"
|
||||
(let [result (sidebar/sidebar-brand {:title "Acme" :href "/"})]
|
||||
(is (= :a (first result)))
|
||||
(is (= "/" (get-in result [1 :href])))))
|
||||
(testing "icon defaults to first char of title"
|
||||
(let [result (sidebar/sidebar-brand {:title "Acme"})]
|
||||
;; The icon span is at index 2
|
||||
(is (= "A" (nth (nth result 2) 2))))))
|
||||
|
||||
(deftest sidebar-search-test
|
||||
(testing "renders search input"
|
||||
(let [result (sidebar/sidebar-search {:placeholder "Find..."})]
|
||||
(is (= :div (first result)))
|
||||
(is (= "sidebar-search" (get-in result [1 :class])))
|
||||
;; input is the third child (after class div and icon span)
|
||||
(let [input (nth result 3)]
|
||||
(is (= :input (first input)))
|
||||
(is (= "Find..." (get-in input [1 :placeholder])))))))
|
||||
|
||||
(deftest sidebar-content-test
|
||||
(testing "renders nav tag"
|
||||
(let [result (sidebar/sidebar-content {} [:div "Groups"])]
|
||||
(is (= :nav (first result)))
|
||||
(is (= "sidebar-content" (get-in result [1 :class]))))))
|
||||
|
||||
(deftest sidebar-group-test
|
||||
(testing "renders label when provided"
|
||||
(let [result (sidebar/sidebar-group {:label "Section"} [:ul "Items"])]
|
||||
(is (= :div (first result)))
|
||||
(is (= "sidebar-group" (get-in result [1 :class])))
|
||||
;; Label is the second element
|
||||
(let [label (nth result 2)]
|
||||
(is (= "sidebar-group-label" (get-in label [1 :class])))
|
||||
(is (= "Section" (nth label 2))))))
|
||||
(testing "no label when omitted"
|
||||
(let [result (sidebar/sidebar-group {} [:ul "Items"])]
|
||||
;; Should only have attrs + children, no label div
|
||||
(is (= [:ul "Items"] (nth result 2))))))
|
||||
|
||||
(deftest sidebar-menu-test
|
||||
(testing "wraps children in <li> tags"
|
||||
(let [result (sidebar/sidebar-menu {} [:a "Link1"] [:a "Link2"])]
|
||||
(is (= :ul (first result)))
|
||||
(is (= "sidebar-menu" (get-in result [1 :class])))
|
||||
(is (= [:li [:a "Link1"]] (nth result 2)))
|
||||
(is (= [:li [:a "Link2"]] (nth result 3))))))
|
||||
|
||||
(deftest sidebar-menu-item-test
|
||||
(testing "renders <a> with href"
|
||||
(let [result (sidebar/sidebar-menu-item {:href "/about"} "About")]
|
||||
(is (= :a (first result)))
|
||||
(is (= "/about" (get-in result [1 :href])))
|
||||
(is (= "sidebar-menu-item" (get-in result [1 :class])))))
|
||||
(testing "renders <button> without href"
|
||||
(let [result (sidebar/sidebar-menu-item {} "Click")]
|
||||
(is (= :button (first result)))))
|
||||
(testing "active class"
|
||||
(let [result (sidebar/sidebar-menu-item {:active true :href "#"} "Active")]
|
||||
(is (= "sidebar-menu-item sidebar-menu-item-active" (get-in result [1 :class])))))
|
||||
(testing "badge"
|
||||
(let [result (sidebar/sidebar-menu-item {:href "#" :badge "3"} "Messages")]
|
||||
(is (= "Messages" (nth result 2)))
|
||||
(let [badge (nth result 3)]
|
||||
(is (= "sidebar-menu-item-badge" (get-in badge [1 :class])))
|
||||
(is (= "3" (nth badge 2)))))))
|
||||
|
||||
(deftest sidebar-collapsible-test
|
||||
(testing "renders <details> with summary"
|
||||
(let [result (sidebar/sidebar-collapsible {:title "Section" :open true} [:ul "Items"])]
|
||||
(is (= :details (first result)))
|
||||
(is (= "sidebar-collapsible" (get-in result [1 :class])))
|
||||
(is (true? (get-in result [1 :open])))
|
||||
;; summary
|
||||
(let [summary (nth result 2)]
|
||||
(is (= :summary (first summary)))
|
||||
(is (= "Section" (nth (nth summary 1) 1)))))))
|
||||
|
||||
(deftest sidebar-separator-test
|
||||
(testing "renders <hr>"
|
||||
(let [result (sidebar/sidebar-separator)]
|
||||
(is (= :hr (first result)))
|
||||
(is (= "sidebar-separator" (get-in result [1 :class]))))))
|
||||
|
||||
(deftest sidebar-footer-test
|
||||
(testing "renders footer div"
|
||||
(let [result (sidebar/sidebar-footer {} [:span "User"])]
|
||||
(is (= :div (first result)))
|
||||
(is (= "sidebar-footer" (get-in result [1 :class]))))))
|
||||
|
||||
(deftest sidebar-user-test
|
||||
(testing "renders user block"
|
||||
(let [result (sidebar/sidebar-user {:user-name "Alice" :email "alice@example.com"})]
|
||||
(is (= :div (first result)))
|
||||
(is (= "sidebar-user" (get-in result [1 :class])))
|
||||
;; avatar defaults to first 2 chars
|
||||
(let [avatar (nth result 2)]
|
||||
(is (= "Al" (nth avatar 2))))
|
||||
;; name
|
||||
(let [info (nth result 3)
|
||||
name-span (nth info 2)]
|
||||
(is (= "Alice" (nth name-span 2)))))))
|
||||
Reference in New Issue
Block a user