From c8579548454a93d08fd304497598b18203c69484 Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Thu, 5 Mar 2026 12:49:22 +0100 Subject: [PATCH] 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. --- bb.edn | 4 + dev/hiccup/src/dev/hiccup.clj | 67 +++- dev/replicant/src/dev/replicant.cljs | 67 +++- dev/squint/src/dev/squint.cljs | 68 ++++- src/ui/icon.cljc | 377 +++++++++++++++++++++++ src/ui/icon.css | 24 ++ src/ui/sidebar.cljc | 437 +++++++++++++++++++++++++++ src/ui/sidebar.css | 389 ++++++++++++++++++++++++ test/ui/icon_test.clj | 78 +++++ test/ui/sidebar_test.clj | 172 +++++++++++ 10 files changed, 1677 insertions(+), 6 deletions(-) create mode 100644 src/ui/icon.cljc create mode 100644 src/ui/icon.css create mode 100644 src/ui/sidebar.cljc create mode 100644 src/ui/sidebar.css create mode 100644 test/ui/icon_test.clj create mode 100644 test/ui/sidebar_test.clj diff --git a/bb.edn b/bb.edn index f5439f7..e700818 100644 --- a/bb.edn +++ b/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)))} diff --git a/dev/hiccup/src/dev/hiccup.clj b/dev/hiccup/src/dev/hiccup.clj index 37914f0..0f09da1 100644 --- a/dev/hiccup/src/dev/hiccup.clj +++ b/dev/hiccup/src/dev/hiccup.clj @@ -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 diff --git a/dev/replicant/src/dev/replicant.cljs b/dev/replicant/src/dev/replicant.cljs index 394017a..d2c4616 100644 --- a/dev/replicant/src/dev/replicant.cljs +++ b/dev/replicant/src/dev/replicant.cljs @@ -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 [_ _])) diff --git a/dev/squint/src/dev/squint.cljs b/dev/squint/src/dev/squint.cljs index 0593bd5..6bf4f7b 100644 --- a/dev/squint/src/dev/squint.cljs +++ b/dev/squint/src/dev/squint.cljs @@ -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"))) diff --git a/src/ui/icon.cljc b/src/ui/icon.cljc new file mode 100644 index 0000000..f07a840 --- /dev/null +++ b/src/ui/icon.cljc @@ -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)))))) diff --git a/src/ui/icon.css b/src/ui/icon.css new file mode 100644 index 0000000..66a1d94 --- /dev/null +++ b/src/ui/icon.css @@ -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); +} diff --git a/src/ui/sidebar.cljc b/src/ui/sidebar.cljc new file mode 100644 index 0000000..30a2ac2 --- /dev/null +++ b/src/ui/sidebar.cljc @@ -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 when set,