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