feat: add 13 components adapted from Oat UI

Components (.cljc + .css + tests):
- Alert (success/warning/danger/info variants)
- Badge (primary/secondary/outline/success/warning/danger)
- Card (card/card-header/card-body/card-footer)
- Accordion (collapsible with open/closed state)
- Table (headers/rows, striped/bordered variants)
- Dialog (modal with header/body/footer sections)
- Breadcrumb (nav with active item)
- Pagination (current/total with prev/next)
- Progress (value bar with color variants)
- Spinner (sm/md/lg sizes)
- Skeleton (line/box/circle/heading placeholders)
- Switch (toggle with checked/disabled states)
- Tooltip (hover text via data-tooltip attr)

CSS-only additions:
- Form elements (inputs, selects, checkboxes, radios, range, groups)
- Grid (12-column system with offsets, responsive)
- Utilities (flex, spacing, alignment, sr-only)

Also adds warning/fg-on-warning tokens to light and dark themes.
All 3 dev targets updated with full component showcase.
40 tests, 213 assertions, all passing.
This commit is contained in:
Florian Schroedl
2026-03-03 11:37:05 +01:00
parent d55e3d3a90
commit 18043cb150
47 changed files with 2556 additions and 106 deletions

30
bb.edn
View File

@@ -17,8 +17,36 @@
{:doc "Run all unit tests" {:doc "Run all unit tests"
:requires ([clojure.test :as t] :requires ([clojure.test :as t]
[ui.button-test] [ui.button-test]
[ui.alert-test]
[ui.badge-test]
[ui.card-test]
[ui.accordion-test]
[ui.table-test]
[ui.dialog-test]
[ui.spinner-test]
[ui.skeleton-test]
[ui.progress-test]
[ui.switch-test]
[ui.tooltip-test]
[ui.breadcrumb-test]
[ui.pagination-test]
[ui.theme-test]) [ui.theme-test])
:task (let [{:keys [fail error]} (t/run-tests 'ui.button-test 'ui.theme-test)] :task (let [{:keys [fail error]} (t/run-tests
'ui.button-test
'ui.alert-test
'ui.badge-test
'ui.card-test
'ui.accordion-test
'ui.table-test
'ui.dialog-test
'ui.spinner-test
'ui.skeleton-test
'ui.progress-test
'ui.switch-test
'ui.tooltip-test
'ui.breadcrumb-test
'ui.pagination-test
'ui.theme-test)]
(when (pos? (+ fail error)) (when (pos? (+ fail error))
(System/exit 1)))} (System/exit 1)))}

View File

@@ -1,35 +1,170 @@
(ns dev.hiccup (ns dev.hiccup
(:require [org.httpkit.server :as http] (:require [org.httpkit.server :as http]
[hiccup2.core :as h] [hiccup2.core :as h]
[ui.button :as button])) [ui.button :as button]
[ui.alert :as alert]
[ui.badge :as badge]
[ui.card :as card]
[ui.accordion :as accordion]
[ui.table :as table]
[ui.dialog :as dialog]
[ui.spinner :as spinner]
[ui.skeleton :as skeleton]
[ui.progress :as progress]
[ui.switch :as switch]
[ui.tooltip :as tooltip]
[ui.breadcrumb :as breadcrumb]
[ui.pagination :as pagination]))
(def variants [:primary :secondary :ghost :danger]) (defn section [title & children]
(def sizes [:sm :md :lg]) [:section {:style "margin-bottom: 2.5rem;"}
[:h3 {:style "color: var(--fg-1); margin-bottom: 1rem; border-bottom: var(--border-0); padding-bottom: 0.5rem;"} title]
(into [:div {:style "display: flex; flex-direction: column; gap: 1rem;"}] children)])
(defn button-grid [] ;; ── Button ──────────────────────────────────────────────────────────
[:div {:style "display: grid; grid-template-columns: repeat(4, auto); gap: 1rem; align-items: center;"} (def button-variants [:primary :secondary :ghost :danger])
;; Header row (def button-sizes [:sm :md :lg])
[:div]
(for [size sizes]
[:div {:style "font-weight: 600; text-align: center; color: var(--fg-1); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em;"}
(name size)])
;; Variant rows (defn button-demo []
(for [variant variants] (section "Button"
(list [:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
[:div {:style "font-weight: 600; color: var(--fg-1); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em;"} (for [v button-variants]
(name variant)] (button/button {:variant v} (name v)))]
(for [size sizes] [:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
[:div {:style "text-align: center;"} (for [s button-sizes]
(button/button {:variant variant :size size} (button/button {:variant :primary :size s} (str "size " (name s))))]
(str (name variant) " " (name size)))])))]) [:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
(for [v button-variants]
(button/button {:variant v :disabled true} (str (name v) " disabled")))]))
(defn disabled-row [] ;; ── Alert ───────────────────────────────────────────────────────────
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap;"} (defn alert-demo []
(for [variant variants] (section "Alert"
(button/button {:variant variant :disabled true} (alert/alert {:variant :success :title "Success!"} "Your changes have been saved.")
(str (name variant) " disabled")))]) (alert/alert {:variant :warning :title "Warning!"} "Please review before continuing.")
(alert/alert {:variant :danger :title "Error!"} "Something went wrong.")
(alert/alert {:variant :info :title "Info"} "This is an informational alert.")
(alert/alert {:title "Neutral"} "A neutral alert with no variant.")))
;; ── Badge ───────────────────────────────────────────────────────────
(defn badge-demo []
(section "Badge"
[:div {:style "display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;"}
(badge/badge {} "Default")
(badge/badge {:variant :secondary} "Secondary")
(badge/badge {:variant :outline} "Outline")
(badge/badge {:variant :success} "Success")
(badge/badge {:variant :warning} "Warning")
(badge/badge {:variant :danger} "Danger")]))
;; ── Card ────────────────────────────────────────────────────────────
(defn card-demo []
(section "Card"
(card/card {}
(card/card-header {} [:h4 "Card Title"] [:p "Card description goes here."])
(card/card-body {} [:p "This is the card content. It can contain any HTML."])
(card/card-footer {}
(button/button {:variant :secondary :size :sm} "Cancel")
(button/button {:variant :primary :size :sm} "Save")))))
;; ── Accordion ───────────────────────────────────────────────────────
(defn accordion-demo []
(section "Accordion"
(accordion/accordion {:title "What is this framework?"} "A cross-target component library for Clojure, ClojureScript, and Squint.")
(accordion/accordion {:title "How do I use it?" :open true} "Just require the namespace and call the component functions.")
(accordion/accordion {:title "Is it accessible?"} "Yes, components follow ARIA best practices.")))
;; ── Table ───────────────────────────────────────────────────────────
(defn table-demo []
(section "Table"
(table/table {:headers ["Name" "Email" "Role" "Status"]
:rows [["Alice Johnson" "alice@example.com" "Admin" "Active"]
["Bob Smith" "bob@example.com" "Editor" "Active"]
["Carol White" "carol@example.com" "Viewer" "Pending"]]})))
;; ── Dialog ──────────────────────────────────────────────────────────
(defn dialog-demo []
(section "Dialog"
[:p {:style "color: var(--fg-2); font-size: var(--font-sm);"} "Click button to open dialog."]
(button/button {:variant :primary
:attrs {:onclick "document.getElementById('demo-dialog').showModal()"}}
"Open dialog")
(dialog/dialog {:id "demo-dialog"}
(dialog/dialog-header {} [:h3 "Dialog Title"] [:p "Are you sure you want to continue?"])
(dialog/dialog-body {} [:p "This action cannot be undone."])
(dialog/dialog-footer {}
(button/button {:variant :secondary :size :sm
:attrs {:onclick "document.getElementById('demo-dialog').close()"}}
"Cancel")
(button/button {:variant :primary :size :sm
:attrs {:onclick "document.getElementById('demo-dialog').close()"}}
"Confirm")))))
;; ── Spinner ─────────────────────────────────────────────────────────
(defn spinner-demo []
(section "Spinner"
[:div {:style "display: flex; gap: 1.5rem; align-items: center;"}
(spinner/spinner {:size :sm})
(spinner/spinner {})
(spinner/spinner {:size :lg})]))
;; ── Skeleton ────────────────────────────────────────────────────────
(defn skeleton-demo []
(section "Skeleton"
[:div {:style "max-width: 400px;"}
(skeleton/skeleton {:variant :heading})
(skeleton/skeleton {:variant :line})
(skeleton/skeleton {:variant :line})
[:div {:style "display: flex; gap: 1rem; margin-top: var(--size-3);"}
(skeleton/skeleton {:variant :circle})
[:div {:style "flex: 1;"}
(skeleton/skeleton {:variant :line})
(skeleton/skeleton {:variant :line})]]]))
;; ── Progress ────────────────────────────────────────────────────────
(defn progress-demo []
(section "Progress"
(progress/progress {:value 25})
(progress/progress {:value 50 :variant :success})
(progress/progress {:value 75 :variant :warning})
(progress/progress {:value 90 :variant :danger})))
;; ── Switch ──────────────────────────────────────────────────────────
(defn switch-demo []
(section "Switch"
[:div {:style "display: flex; flex-direction: column; gap: 0.75rem;"}
(switch/switch-toggle {:label "Notifications" :checked false})
(switch/switch-toggle {:label "Dark mode" :checked true})
(switch/switch-toggle {:label "Disabled off" :disabled true})
(switch/switch-toggle {:label "Disabled on" :checked true :disabled true})]))
;; ── Tooltip ─────────────────────────────────────────────────────────
(defn tooltip-demo []
(section "Tooltip"
[:div {:style "display: flex; gap: 1.5rem; padding-top: 2rem;"}
(tooltip/tooltip {:text "Save your changes"}
(button/button {:variant :primary} "Save"))
(tooltip/tooltip {:text "Delete this item"}
(button/button {:variant :danger} "Delete"))
(tooltip/tooltip {:text "View profile"}
[:a {:href "#" :style "color: var(--accent);"} "Profile"])]))
;; ── Breadcrumb ──────────────────────────────────────────────────────
(defn breadcrumb-demo []
(section "Breadcrumb"
(breadcrumb/breadcrumb
{:items [{:label "Home" :href "#"}
{:label "Projects" :href "#"}
{:label "Oat Docs" :href "#"}
{:label "Components"}]})))
;; ── Pagination ──────────────────────────────────────────────────────
(defn pagination-demo []
(section "Pagination"
(pagination/pagination {:current 3 :total 5
:href-fn (fn [p] (str "#page-" p))})))
;; ── Page ────────────────────────────────────────────────────────────
(defn page [] (defn page []
(str (str
"<!DOCTYPE html>\n" "<!DOCTYPE html>\n"
@@ -47,10 +182,20 @@
[:button {:onclick "document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'" [:button {:onclick "document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'"
:style "padding: 0.5rem 1rem; cursor: pointer; border-radius: var(--radius-md); border: var(--border-0); background: var(--bg-1); color: var(--fg-0);"} :style "padding: 0.5rem 1rem; cursor: pointer; border-radius: var(--radius-md); border: var(--border-0); background: var(--bg-1); color: var(--fg-0);"}
"Toggle Dark Mode"]] "Toggle Dark Mode"]]
[:h3 {:style "color: var(--fg-1); margin-bottom: 1rem;"} "Button Grid"] (button-demo)
(button-grid) (alert-demo)
[:h3 {:style "color: var(--fg-1); margin: 2rem 0 1rem;"} "Disabled States"] (badge-demo)
(disabled-row)]]]))) (card-demo)
(accordion-demo)
(table-demo)
(dialog-demo)
(spinner-demo)
(skeleton-demo)
(progress-demo)
(switch-demo)
(tooltip-demo)
(breadcrumb-demo)
(pagination-demo)]]])))
(defn handler [{:keys [uri]}] (defn handler [{:keys [uri]}]
(case uri (case uri

View File

@@ -1,43 +1,180 @@
(ns dev.replicant (ns dev.replicant
(:require [replicant.dom :as d] (:require [replicant.dom :as d]
[ui.button :as button])) [ui.button :as button]
[ui.alert :as alert]
[ui.badge :as badge]
[ui.card :as card]
[ui.accordion :as accordion]
[ui.table :as table]
[ui.dialog :as dialog]
[ui.spinner :as spinner]
[ui.skeleton :as skeleton]
[ui.progress :as progress]
[ui.switch :as switch]
[ui.tooltip :as tooltip]
[ui.breadcrumb :as breadcrumb]
[ui.pagination :as pagination]))
(def variants [:primary :secondary :ghost :danger]) (defn section [title & children]
(def sizes [:sm :md :lg]) [:section {:style {:margin-bottom "2.5rem"}}
[:h3 {:style {:color "var(--fg-1)" :margin-bottom "1rem"
:border-bottom "var(--border-0)" :padding-bottom "0.5rem"}} title]
(into [:div {:style {:display "flex" :flex-direction "column" :gap "1rem"}}] children)])
(defn button-grid [] ;; ── Button ──────────────────────────────────────────────────────────
[:div {:style {:display "grid" (def button-variants [:primary :secondary :ghost :danger])
:grid-template-columns "repeat(4, auto)" (def button-sizes [:sm :md :lg])
:gap "1rem"
:align-items "center"}}
[:div]
(for [size sizes]
[:div {:style {:font-weight "600" :text-align "center" :color "var(--fg-1)"
:font-size "0.75rem" :text-transform "uppercase" :letter-spacing "0.05em"}}
(name size)])
(for [variant variants]
(list
[:div {:style {:font-weight "600" :color "var(--fg-1)"
:font-size "0.75rem" :text-transform "uppercase" :letter-spacing "0.05em"}}
(name variant)]
(for [size sizes]
[:div {:style {:text-align "center"}}
(button/button {:variant variant :size size
:on-click (fn [_] (js/console.log (str "Clicked: " (name variant) " " (name size))))}
(str (name variant) " " (name size)))])))])
(defn disabled-row [] (defn button-demo []
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap"}} (section "Button"
(for [variant variants] [:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
(button/button {:variant variant :disabled true} (for [v button-variants]
(str (name variant) " disabled")))]) (button/button {:variant v :on-click (fn [_] (js/console.log (str "Clicked: " (name v))))}
(name v)))]
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
(for [s button-sizes]
(button/button {:variant :primary :size s} (str "size " (name s))))]
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
(for [v button-variants]
(button/button {:variant v :disabled true} (str (name v) " disabled")))]))
;; ── Alert ───────────────────────────────────────────────────────────
(defn alert-demo []
(section "Alert"
(alert/alert {:variant :success :title "Success!"} "Your changes have been saved.")
(alert/alert {:variant :warning :title "Warning!"} "Please review before continuing.")
(alert/alert {:variant :danger :title "Error!"} "Something went wrong.")
(alert/alert {:variant :info :title "Info"} "This is an informational alert.")
(alert/alert {:title "Neutral"} "A neutral alert with no variant.")))
;; ── Badge ───────────────────────────────────────────────────────────
(defn badge-demo []
(section "Badge"
[:div {:style {:display "flex" :gap "0.5rem" :flex-wrap "wrap" :align-items "center"}}
(badge/badge {} "Default")
(badge/badge {:variant :secondary} "Secondary")
(badge/badge {:variant :outline} "Outline")
(badge/badge {:variant :success} "Success")
(badge/badge {:variant :warning} "Warning")
(badge/badge {:variant :danger} "Danger")]))
;; ── Card ────────────────────────────────────────────────────────────
(defn card-demo []
(section "Card"
(card/card {}
(card/card-header {} [:h4 "Card Title"] [:p "Card description goes here."])
(card/card-body {} [:p "This is the card content. It can contain any HTML."])
(card/card-footer {}
(button/button {:variant :secondary :size :sm} "Cancel")
(button/button {:variant :primary :size :sm} "Save")))))
;; ── Accordion ───────────────────────────────────────────────────────
(defn accordion-demo []
(section "Accordion"
(accordion/accordion {:title "What is this framework?"} "A cross-target component library.")
(accordion/accordion {:title "How do I use it?" :open true} "Just require the namespace and call functions.")
(accordion/accordion {:title "Is it accessible?"} "Yes, follows ARIA best practices.")))
;; ── Table ───────────────────────────────────────────────────────────
(defn table-demo []
(section "Table"
(table/table {:headers ["Name" "Email" "Role" "Status"]
:rows [["Alice Johnson" "alice@example.com" "Admin" "Active"]
["Bob Smith" "bob@example.com" "Editor" "Active"]
["Carol White" "carol@example.com" "Viewer" "Pending"]]})))
;; ── Dialog ──────────────────────────────────────────────────────────
(defn dialog-demo []
(section "Dialog"
[:p {:style {:color "var(--fg-2)" :font-size "var(--font-sm)"}} "Click button to open dialog."]
(button/button {:variant :primary
:on-click (fn [_]
(when-let [el (.getElementById js/document "demo-dialog")]
(.showModal el)))}
"Open dialog")
(dialog/dialog {:id "demo-dialog"}
(dialog/dialog-header {} [:h3 "Dialog Title"] [:p "Are you sure you want to continue?"])
(dialog/dialog-body {} [:p "This action cannot be undone."])
(dialog/dialog-footer {}
(button/button {:variant :secondary :size :sm
:on-click (fn [_] (.close (.getElementById js/document "demo-dialog")))}
"Cancel")
(button/button {:variant :primary :size :sm
:on-click (fn [_] (.close (.getElementById js/document "demo-dialog")))}
"Confirm")))))
;; ── Spinner ─────────────────────────────────────────────────────────
(defn spinner-demo []
(section "Spinner"
[:div {:style {:display "flex" :gap "1.5rem" :align-items "center"}}
(spinner/spinner {:size :sm})
(spinner/spinner {})
(spinner/spinner {:size :lg})]))
;; ── Skeleton ────────────────────────────────────────────────────────
(defn skeleton-demo []
(section "Skeleton"
[:div {:style {:max-width "400px"}}
(skeleton/skeleton {:variant :heading})
(skeleton/skeleton {:variant :line})
(skeleton/skeleton {:variant :line})
[:div {:style {:display "flex" :gap "1rem" :margin-top "var(--size-3)"}}
(skeleton/skeleton {:variant :circle})
[:div {:style {:flex "1"}}
(skeleton/skeleton {:variant :line})
(skeleton/skeleton {:variant :line})]]]))
;; ── Progress ────────────────────────────────────────────────────────
(defn progress-demo []
(section "Progress"
(progress/progress {:value 25})
(progress/progress {:value 50 :variant :success})
(progress/progress {:value 75 :variant :warning})
(progress/progress {:value 90 :variant :danger})))
;; ── Switch ──────────────────────────────────────────────────────────
(defn switch-demo []
(section "Switch"
[:div {:style {:display "flex" :flex-direction "column" :gap "0.75rem"}}
(switch/switch-toggle {:label "Notifications" :checked false})
(switch/switch-toggle {:label "Dark mode" :checked true})
(switch/switch-toggle {:label "Disabled off" :disabled true})
(switch/switch-toggle {:label "Disabled on" :checked true :disabled true})]))
;; ── Tooltip ─────────────────────────────────────────────────────────
(defn tooltip-demo []
(section "Tooltip"
[:div {:style {:display "flex" :gap "1.5rem" :padding-top "2rem"}}
(tooltip/tooltip {:text "Save your changes"}
(button/button {:variant :primary} "Save"))
(tooltip/tooltip {:text "Delete this item"}
(button/button {:variant :danger} "Delete"))
(tooltip/tooltip {:text "View profile"}
[:a {:href "#" :style {:color "var(--accent)"}} "Profile"])]))
;; ── Breadcrumb ──────────────────────────────────────────────────────
(defn breadcrumb-demo []
(section "Breadcrumb"
(breadcrumb/breadcrumb
{:items [{:label "Home" :href "#"}
{:label "Projects" :href "#"}
{:label "Oat Docs" :href "#"}
{:label "Components"}]})))
;; ── Pagination ──────────────────────────────────────────────────────
(defn pagination-demo []
(section "Pagination"
(pagination/pagination {:current 3 :total 5
:on-click (fn [p] (js/console.log (str "Page: " p)))})))
;; ── Theme toggle ────────────────────────────────────────────────────
(defn toggle-theme! [_e] (defn toggle-theme! [_e]
(let [el (.-documentElement js/document) (let [el (.-documentElement js/document)
current (.. el -dataset -theme)] current (.. el -dataset -theme)]
(set! (.. el -dataset -theme) (set! (.. el -dataset -theme)
(if (= current "dark") "light" "dark")))) (if (= current "dark") "light" "dark"))))
;; ── App ─────────────────────────────────────────────────────────────
(defn app [] (defn app []
[:div {:style {:max-width "800px" :margin "0 auto"}} [:div {:style {:max-width "800px" :margin "0 auto"}}
[:div {:style {:display "flex" :justify-content "space-between" :align-items "center" :margin-bottom "2rem"}} [:div {:style {:display "flex" :justify-content "space-between" :align-items "center" :margin-bottom "2rem"}}
@@ -46,10 +183,20 @@
:style {:padding "0.5rem 1rem" :cursor "pointer" :border-radius "var(--radius-md)" :style {:padding "0.5rem 1rem" :cursor "pointer" :border-radius "var(--radius-md)"
:border "var(--border-0)" :background "var(--bg-1)" :color "var(--fg-0)"}} :border "var(--border-0)" :background "var(--bg-1)" :color "var(--fg-0)"}}
"Toggle Dark Mode"]] "Toggle Dark Mode"]]
[:h3 {:style {:color "var(--fg-1)" :margin-bottom "1rem"}} "Button Grid"] (button-demo)
(button-grid) (alert-demo)
[:h3 {:style {:color "var(--fg-1)" :margin "2rem 0 1rem"}} "Disabled States"] (badge-demo)
(disabled-row)]) (card-demo)
(accordion-demo)
(table-demo)
(dialog-demo)
(spinner-demo)
(skeleton-demo)
(progress-demo)
(switch-demo)
(tooltip-demo)
(breadcrumb-demo)
(pagination-demo)])
(defn ^:export init! [] (defn ^:export init! []
(d/set-dispatch! (fn [_ _])) (d/set-dispatch! (fn [_ _]))

View File

@@ -1,9 +1,19 @@
(ns dev.squint (ns dev.squint
(:require ["eucalypt" :as eu] (:require ["eucalypt" :as eu]
[ui.button :as button])) [ui.button :as button]
[ui.alert :as alert]
(def variants ["primary" "secondary" "ghost" "danger"]) [ui.badge :as badge]
(def sizes ["sm" "md" "lg"]) [ui.card :as card]
[ui.accordion :as accordion]
[ui.table :as table]
[ui.dialog :as dialog]
[ui.spinner :as spinner]
[ui.skeleton :as skeleton]
[ui.progress :as progress]
[ui.switch :as switch]
[ui.tooltip :as tooltip]
[ui.breadcrumb :as breadcrumb]
[ui.pagination :as pagination]))
(defn toggle-theme! [_e] (defn toggle-theme! [_e]
(let [el (.-documentElement js/document) (let [el (.-documentElement js/document)
@@ -11,46 +21,161 @@
(set! (.. el -dataset -theme) (set! (.. el -dataset -theme)
(if (= current "dark") "light" "dark")))) (if (= current "dark") "light" "dark"))))
(def label-style {"font-weight" "600" (defn section [title & children]
"color" "var(--fg-1)" (into [:section {:style {"margin-bottom" "2.5rem"}}
"font-size" "0.75rem" [:h3 {:style {"color" "var(--fg-1)" "margin-bottom" "1rem"
"text-transform" "uppercase" "border-bottom" "var(--border-0)" "padding-bottom" "0.5rem"}} title]]
"letter-spacing" "0.05em"}) children))
(defn button-grid [] ;; ── Button ──────────────────────────────────────────────────────────
(into (def button-variants ["primary" "secondary" "ghost" "danger"])
[:div {:style {"display" "grid" (def button-sizes ["sm" "md" "lg"])
"grid-template-columns" "repeat(4, auto)"
"gap" "1rem"
"align-items" "center"}}
[:div]
[:div {:style (merge label-style {"text-align" "center"})} "sm"]
[:div {:style (merge label-style {"text-align" "center"})} "md"]
[:div {:style (merge label-style {"text-align" "center"})} "lg"]]
(mapcat (fn [variant]
[[:div {:style label-style} variant]
[:div {:style {"text-align" "center"}}
(button/button {:variant variant :size "sm"
:on-click (fn [_] (js/console.log (str "Clicked: " variant " sm")))}
(str variant " sm"))]
[:div {:style {"text-align" "center"}}
(button/button {:variant variant :size "md"
:on-click (fn [_] (js/console.log (str "Clicked: " variant " md")))}
(str variant " md"))]
[:div {:style {"text-align" "center"}}
(button/button {:variant variant :size "lg"
:on-click (fn [_] (js/console.log (str "Clicked: " variant " lg")))}
(str variant " lg"))]])
variants)))
(defn disabled-row [] (defn button-demo []
(into (section "Button"
[:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap"}}] (into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
(map (fn [variant] (map (fn [v]
(button/button {:variant variant :disabled true} (button/button {:variant v :on-click (fn [_] (js/console.log (str "Clicked: " v)))} v))
(str variant " disabled"))) button-variants))
variants))) (into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
(map (fn [s]
(button/button {:variant "primary" :size s} (str "size " s)))
button-sizes))
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
(map (fn [v]
(button/button {:variant v :disabled true} (str v " disabled")))
button-variants))))
;; ── Alert ───────────────────────────────────────────────────────────
(defn alert-demo []
(section "Alert"
(alert/alert {:variant "success" :title "Success!"} "Your changes have been saved.")
(alert/alert {:variant "warning" :title "Warning!"} "Please review before continuing.")
(alert/alert {:variant "danger" :title "Error!"} "Something went wrong.")
(alert/alert {:variant "info" :title "Info"} "This is an informational alert.")
(alert/alert {:title "Neutral"} "A neutral alert with no variant.")))
;; ── Badge ───────────────────────────────────────────────────────────
(defn badge-demo []
(section "Badge"
(into [:div {:style {"display" "flex" "gap" "0.5rem" "flex-wrap" "wrap" "align-items" "center"}}]
[(badge/badge {} "Default")
(badge/badge {:variant "secondary"} "Secondary")
(badge/badge {:variant "outline"} "Outline")
(badge/badge {:variant "success"} "Success")
(badge/badge {:variant "warning"} "Warning")
(badge/badge {:variant "danger"} "Danger")])))
;; ── Card ────────────────────────────────────────────────────────────
(defn card-demo []
(section "Card"
(card/card {}
(card/card-header {} [:h4 "Card Title"] [:p "Card description goes here."])
(card/card-body {} [:p "This is the card content. It can contain any HTML."])
(card/card-footer {}
(button/button {:variant "secondary" :size "sm"} "Cancel")
(button/button {:variant "primary" :size "sm"} "Save")))))
;; ── Accordion ───────────────────────────────────────────────────────
(defn accordion-demo []
(section "Accordion"
(accordion/accordion {:title "What is this framework?"} "A cross-target component library.")
(accordion/accordion {:title "How do I use it?" :open true} "Just require the namespace and call functions.")
(accordion/accordion {:title "Is it accessible?"} "Yes, follows ARIA best practices.")))
;; ── Table ───────────────────────────────────────────────────────────
(defn table-demo []
(section "Table"
(table/table {:headers ["Name" "Email" "Role" "Status"]
:rows [["Alice Johnson" "alice@example.com" "Admin" "Active"]
["Bob Smith" "bob@example.com" "Editor" "Active"]
["Carol White" "carol@example.com" "Viewer" "Pending"]]})))
;; ── Dialog ──────────────────────────────────────────────────────────
(defn dialog-demo []
(section "Dialog"
[:p {:style {"color" "var(--fg-2)" "font-size" "var(--font-sm)"}} "Click button to open dialog."]
(button/button {:variant "primary"
:on-click (fn [_]
(when-let [el (js/document.getElementById "demo-dialog-sq")]
(.showModal el)))}
"Open dialog")
(dialog/dialog {:id "demo-dialog-sq"}
(dialog/dialog-header {} [:h3 "Dialog Title"] [:p "Are you sure you want to continue?"])
(dialog/dialog-body {} [:p "This action cannot be undone."])
(dialog/dialog-footer {}
(button/button {:variant "secondary" :size "sm"
:on-click (fn [_] (.close (js/document.getElementById "demo-dialog-sq")))}
"Cancel")
(button/button {:variant "primary" :size "sm"
:on-click (fn [_] (.close (js/document.getElementById "demo-dialog-sq")))}
"Confirm")))))
;; ── Spinner ─────────────────────────────────────────────────────────
(defn spinner-demo []
(section "Spinner"
[:div {:style {"display" "flex" "gap" "1.5rem" "align-items" "center"}}
(spinner/spinner {:size "sm"})
(spinner/spinner {})
(spinner/spinner {:size "lg"})]))
;; ── Skeleton ────────────────────────────────────────────────────────
(defn skeleton-demo []
(section "Skeleton"
[:div {:style {"max-width" "400px"}}
(skeleton/skeleton {:variant "heading"})
(skeleton/skeleton {:variant "line"})
(skeleton/skeleton {:variant "line"})
[:div {:style {"display" "flex" "gap" "1rem" "margin-top" "var(--size-3)"}}
(skeleton/skeleton {:variant "circle"})
[:div {:style {"flex" "1"}}
(skeleton/skeleton {:variant "line"})
(skeleton/skeleton {:variant "line"})]]]))
;; ── Progress ────────────────────────────────────────────────────────
(defn progress-demo []
(section "Progress"
(progress/progress {:value 25})
(progress/progress {:value 50 :variant "success"})
(progress/progress {:value 75 :variant "warning"})
(progress/progress {:value 90 :variant "danger"})))
;; ── Switch ──────────────────────────────────────────────────────────
(defn switch-demo []
(section "Switch"
[:div {:style {"display" "flex" "flex-direction" "column" "gap" "0.75rem"}}
(switch/switch-toggle {:label "Notifications" :checked false})
(switch/switch-toggle {:label "Dark mode" :checked true})
(switch/switch-toggle {:label "Disabled off" :disabled true})
(switch/switch-toggle {:label "Disabled on" :checked true :disabled true})]))
;; ── Tooltip ─────────────────────────────────────────────────────────
(defn tooltip-demo []
(section "Tooltip"
[:div {:style {"display" "flex" "gap" "1.5rem" "padding-top" "2rem"}}
(tooltip/tooltip {:text "Save your changes"}
(button/button {:variant "primary"} "Save"))
(tooltip/tooltip {:text "Delete this item"}
(button/button {:variant "danger"} "Delete"))
(tooltip/tooltip {:text "View profile"}
[:a {:href "#" :style {"color" "var(--accent)"}} "Profile"])]))
;; ── Breadcrumb ──────────────────────────────────────────────────────
(defn breadcrumb-demo []
(section "Breadcrumb"
(breadcrumb/breadcrumb
{:items [{:label "Home" :href "#"}
{:label "Projects" :href "#"}
{:label "Oat Docs" :href "#"}
{:label "Components"}]})))
;; ── Pagination ──────────────────────────────────────────────────────
(defn pagination-demo []
(section "Pagination"
(pagination/pagination {:current 3 :total 5
:on-click (fn [p] (js/console.log (str "Page: " p)))})))
;; ── App ─────────────────────────────────────────────────────────────
(defn app [] (defn app []
[:div {:style {"max-width" "800px" "margin" "0 auto"}} [:div {:style {"max-width" "800px" "margin" "0 auto"}}
[:div {:style {"display" "flex" "justify-content" "space-between" "align-items" "center" "margin-bottom" "2rem"}} [:div {:style {"display" "flex" "justify-content" "space-between" "align-items" "center" "margin-bottom" "2rem"}}
@@ -59,10 +184,20 @@
:style {"padding" "0.5rem 1rem" "cursor" "pointer" "border-radius" "var(--radius-md)" :style {"padding" "0.5rem 1rem" "cursor" "pointer" "border-radius" "var(--radius-md)"
"border" "var(--border-0)" "background" "var(--bg-1)" "color" "var(--fg-0)"}} "border" "var(--border-0)" "background" "var(--bg-1)" "color" "var(--fg-0)"}}
"Toggle Dark Mode"]] "Toggle Dark Mode"]]
[:h3 {:style {"color" "var(--fg-1)" "margin-bottom" "1rem"}} "Button Grid"] (button-demo)
(button-grid) (alert-demo)
[:h3 {:style {"color" "var(--fg-1)" "margin" "2rem 0 1rem"}} "Disabled States"] (badge-demo)
(disabled-row)]) (card-demo)
(accordion-demo)
(table-demo)
(dialog-demo)
(spinner-demo)
(skeleton-demo)
(progress-demo)
(switch-demo)
(tooltip-demo)
(breadcrumb-demo)
(pagination-demo)])
(defn init! [] (defn init! []
(eu/render (app) (js/document.getElementById "app"))) (eu/render (app) (js/document.getElementById "app")))

View File

@@ -23,6 +23,8 @@
:fg-on-danger "#ffffff" :fg-on-danger "#ffffff"
:success "#16a34a" :success "#16a34a"
:fg-on-success "#ffffff" :fg-on-success "#ffffff"
:warning "#d97706"
:fg-on-warning "#ffffff"
:border-0 "1px solid #e0e0e0" :border-0 "1px solid #e0e0e0"
:border-1 "1px solid #cccccc" :border-1 "1px solid #cccccc"
:border-2 "1px solid #999999" :border-2 "1px solid #999999"
@@ -48,6 +50,8 @@
:fg-on-danger "#ffffff" :fg-on-danger "#ffffff"
:success "#22c55e" :success "#22c55e"
:fg-on-success "#ffffff" :fg-on-success "#ffffff"
:warning "#f59e0b"
:fg-on-warning "#ffffff"
:border-0 "1px solid #2a2a2a" :border-0 "1px solid #2a2a2a"
:border-1 "1px solid #3a3a3a" :border-1 "1px solid #3a3a3a"
:border-2 "1px solid #555555" :border-2 "1px solid #555555"

52
src/ui/accordion.cljc Normal file
View File

@@ -0,0 +1,52 @@
(ns ui.accordion
(:require [clojure.string :as str]))
(defn accordion-class-list
"Generate a vector of CSS class strings for an accordion item."
[{:keys [open]}]
(cond-> ["accordion"]
open (conj "accordion--open")))
(defn accordion-classes
"Generate CSS class string for an accordion."
[opts]
(str/join " " (accordion-class-list opts)))
(defn accordion
"Render an accordion (collapsible) item.
Props:
:title - trigger text
:open - boolean, whether expanded
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [title open class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> (accordion-classes {:open open})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:div base-attrs
[:div {:class "accordion-trigger"} title]]
(when open
[[:div {:class "accordion-content"}
(into [:div] children)]])))
:cljs
(let [cls (accordion-class-list {:open open})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes} attrs)]
(into [:div base-attrs
[:div {:class ["accordion-trigger"]} title]]
(when open
[[:div {:class ["accordion-content"]}
(into [:div] children)]])))
:clj
(let [classes (cond-> (accordion-classes {:open open})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:div base-attrs
[:div {:class "accordion-trigger"} title]]
(when open
[[:div {:class "accordion-content"}
(into [:div] children)]])))))

62
src/ui/accordion.css Normal file
View File

@@ -0,0 +1,62 @@
.accordion {
border: var(--border-0);
border-radius: var(--radius-md);
overflow: hidden;
}
.accordion + .accordion {
margin-top: -1px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.accordion:has(+ .accordion) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.accordion-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--size-2);
width: 100%;
padding: var(--size-4);
font-weight: 500;
font-size: inherit;
font-family: inherit;
background: transparent;
border: none;
cursor: pointer;
user-select: none;
color: var(--fg-0);
transition: background-color 150ms ease;
}
.accordion-trigger:hover {
background: var(--bg-1);
}
.accordion-trigger::after {
content: "";
width: 1em;
height: 1em;
flex-shrink: 0;
background-color: currentColor;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
mask-size: contain;
mask-repeat: no-repeat;
transition: transform 150ms ease;
}
.accordion--open > .accordion-trigger {
border-bottom: var(--border-0);
}
.accordion--open > .accordion-trigger::after {
transform: rotate(180deg);
}
.accordion-content {
padding: var(--size-4);
}

54
src/ui/alert.cljc Normal file
View File

@@ -0,0 +1,54 @@
(ns ui.alert
(:require [clojure.string :as str]))
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
:clj (defn- kw-name [s] (name s)))
(defn alert-class-list
"Generate a vector of CSS class strings for an alert.
Variants: :success, :warning, :danger, :info (default: nil = neutral)."
[{:keys [variant]}]
(cond-> ["alert"]
variant (conj (str "alert-" (kw-name variant)))))
(defn alert-classes
"Generate CSS class string for an alert."
[opts]
(str/join " " (alert-class-list opts)))
(defn alert
"Render an alert element.
Props:
:variant - :success, :warning, :danger, :info (nil for neutral)
:title - optional title string
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [variant title class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> (alert-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes :role "alert"} attrs)]
(into [:div base-attrs]
(cond-> []
title (conj [:p {:class "alert-title"} title])
:always (into (map (fn [c] [:p {:class "alert-body"} c]) children)))))
:cljs
(let [cls (alert-class-list {:variant variant})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes :role "alert"} attrs)]
(into [:div base-attrs]
(cond-> []
title (conj [:p {:class ["alert-title"]} title])
:always (into (map (fn [c] [:p {:class ["alert-body"]} c]) children)))))
:clj
(let [classes (cond-> (alert-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes :role "alert"} attrs)]
(into [:div base-attrs]
(cond-> []
title (conj [:p {:class "alert-title"} title])
:always (into (map (fn [c] [:p {:class "alert-body"} c]) children)))))))

43
src/ui/alert.css Normal file
View File

@@ -0,0 +1,43 @@
.alert {
position: relative;
display: flex;
gap: var(--size-3);
padding: var(--size-4) var(--size-6);
background: var(--bg-1);
border: var(--border-0);
border-radius: var(--radius-md);
font-size: var(--font-sm);
}
.alert-title {
font-weight: 600;
margin: 0 0 var(--size-1) 0;
}
.alert-body {
margin: 0;
}
.alert-success {
border: none;
color: var(--success);
background: color-mix(in srgb, var(--success) 10%, var(--bg-0));
}
.alert-warning {
border: none;
color: var(--warning);
background: color-mix(in srgb, var(--warning) 10%, var(--bg-0));
}
.alert-danger {
border: none;
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, var(--bg-0));
}
.alert-info {
border: none;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, var(--bg-0));
}

48
src/ui/badge.cljc Normal file
View File

@@ -0,0 +1,48 @@
(ns ui.badge
(:require [clojure.string :as str]))
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
:clj (defn- kw-name [s] (name s)))
(def default-variant "primary")
(defn badge-class-list
"Generate a vector of CSS class strings for a badge.
Variants: :primary (default), :secondary, :outline, :success, :warning, :danger."
[{:keys [variant]}]
(let [v (or (some-> variant kw-name) default-variant)]
(if (= v "primary")
["badge"]
["badge" (str "badge-" v)])))
(defn badge-classes
"Generate CSS class string for a badge."
[opts]
(str/join " " (badge-class-list opts)))
(defn badge
"Render a badge element.
Props:
:variant - :primary, :secondary, :outline, :success, :warning, :danger
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [variant class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> (badge-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:span base-attrs] children))
:cljs
(let [cls (badge-class-list {:variant variant})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes} attrs)]
(into [:span base-attrs] children))
:clj
(let [classes (cond-> (badge-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:span base-attrs] children))))

38
src/ui/badge.css Normal file
View File

@@ -0,0 +1,38 @@
.badge {
display: inline-flex;
align-items: center;
gap: var(--size-1);
padding: var(--size-1) var(--size-3);
font-size: var(--font-xs);
font-weight: 500;
line-height: 1.5;
border-radius: 9999px;
background: var(--accent);
color: var(--fg-on-accent);
}
.badge-secondary {
background: var(--bg-2);
color: var(--fg-0);
}
.badge-outline {
background: transparent;
color: var(--fg-0);
border: var(--border-0);
}
.badge-success {
color: var(--success);
background: color-mix(in srgb, var(--success) 12%, var(--bg-0));
}
.badge-warning {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, var(--bg-0));
}
.badge-danger {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, var(--bg-0));
}

57
src/ui/breadcrumb.cljc Normal file
View File

@@ -0,0 +1,57 @@
(ns ui.breadcrumb
(:require [clojure.string :as str]))
(defn breadcrumb
"Render a breadcrumb navigation.
Props:
:class - additional CSS classes
:attrs - additional HTML attributes
Items: vector of {:label \"text\" :href \"/path\"}, last item is active."
[{:keys [items class attrs] :as _props}]
(let [n (count items)]
#?(:squint
(let [classes (cond-> "breadcrumb" class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:nav {:aria-label "Breadcrumb"}
(into [:ol base-attrs]
(map-indexed
(fn [i item]
(let [active (= i (dec n))]
[:li {:class (cond-> "breadcrumb-item"
active (str " breadcrumb-item--active"))}
(if active
(:label item)
[:a {:href (:href item)} (:label item)])]))
items))]
[]))
:cljs
(let [cls (cond-> ["breadcrumb"] class (conj class))
base-attrs (merge {:class cls} attrs)]
[:nav {:aria-label "Breadcrumb"}
(into [:ol base-attrs]
(map-indexed
(fn [i item]
(let [active (= i (dec n))]
[:li {:class (cond-> ["breadcrumb-item"]
active (conj "breadcrumb-item--active"))}
(if active
(:label item)
[:a {:href (:href item)} (:label item)])]))
items))])
:clj
(let [classes (cond-> "breadcrumb" class (str " " class))
base-attrs (merge {:class classes} attrs)]
[:nav {:aria-label "Breadcrumb"}
(into [:ol base-attrs]
(map-indexed
(fn [i item]
(let [active (= i (dec n))]
[:li {:class (cond-> "breadcrumb-item"
active (str " breadcrumb-item--active"))}
(if active
(:label item)
[:a {:href (:href item)} (:label item)])]))
items))]))))

35
src/ui/breadcrumb.css Normal file
View File

@@ -0,0 +1,35 @@
.breadcrumb {
display: flex;
align-items: center;
list-style: none;
padding: 0;
margin: 0;
font-size: var(--font-sm);
gap: var(--size-2);
}
.breadcrumb-item {
display: inline-flex;
align-items: center;
gap: var(--size-2);
}
.breadcrumb-item + .breadcrumb-item::before {
content: "/";
color: var(--fg-2);
}
.breadcrumb-item a {
color: var(--fg-1);
text-decoration: none;
}
.breadcrumb-item a:hover {
color: var(--fg-0);
text-decoration: underline;
}
.breadcrumb-item--active {
color: var(--fg-0);
font-weight: 500;
}

67
src/ui/card.cljc Normal file
View File

@@ -0,0 +1,67 @@
(ns ui.card
(:require [clojure.string :as str]))
(defn card-class-list
"Generate a vector of CSS class strings for a card."
[_opts]
["card"])
(defn card-classes
"Generate CSS class string for a card."
[opts]
(str/join " " (card-class-list opts)))
(defn card
"Render a card element.
Props:
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> (card-classes {})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:article base-attrs] children))
:cljs
(let [cls (card-class-list {})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes} attrs)]
(into [:article base-attrs] children))
:clj
(let [classes (cond-> (card-classes {})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:article base-attrs] children))))
(defn card-header
"Render a card header section."
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:header (merge {:class (cond-> "card-header" class (str " " class))} attrs)] children)
:cljs
(into [:header (merge {:class (cond-> ["card-header"] class (conj class))} attrs)] children)
:clj
(into [:header (merge {:class (cond-> "card-header" class (str " " class))} attrs)] children)))
(defn card-body
"Render a card body section."
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:div (merge {:class (cond-> "card-body" class (str " " class))} attrs)] children)
:cljs
(into [:div (merge {:class (cond-> ["card-body"] class (conj class))} attrs)] children)
:clj
(into [:div (merge {:class (cond-> "card-body" class (str " " class))} attrs)] children)))
(defn card-footer
"Render a card footer section."
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:footer (merge {:class (cond-> "card-footer" class (str " " class))} attrs)] children)
:cljs
(into [:footer (merge {:class (cond-> ["card-footer"] class (conj class))} attrs)] children)
:clj
(into [:footer (merge {:class (cond-> "card-footer" class (str " " class))} attrs)] children)))

43
src/ui/card.css Normal file
View File

@@ -0,0 +1,43 @@
.card {
background: var(--bg-1);
color: var(--fg-0);
border: var(--border-0);
border-radius: var(--radius-md);
box-shadow: var(--shadow-0);
overflow: hidden;
}
.card-header {
display: flex;
flex-direction: column;
gap: var(--size-1);
padding: var(--size-6);
padding-bottom: 0;
}
.card-header h1,
.card-header h2,
.card-header h3,
.card-header h4,
.card-header h5,
.card-header h6 {
margin: 0;
}
.card-header p {
font-size: var(--font-sm);
color: var(--fg-2);
margin: 0;
}
.card-body {
padding: var(--size-6);
}
.card-footer {
display: flex;
justify-content: flex-end;
gap: var(--size-2);
padding: var(--size-6);
padding-top: 0;
}

78
src/ui/dialog.cljc Normal file
View File

@@ -0,0 +1,78 @@
(ns ui.dialog
(:require [clojure.string :as str]))
(defn dialog-class-list
"Generate a vector of CSS class strings for a dialog."
[_opts]
["dialog"])
(defn dialog-classes
"Generate CSS class string for a dialog."
[opts]
(str/join " " (dialog-class-list opts)))
(defn dialog
"Render a dialog element.
Props:
:open - boolean, whether the dialog is open
:id - dialog id for targeting
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [open id class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> (dialog-classes {})
class (str " " class))
base-attrs (merge {:class classes}
(when id {:id id})
(when open {:open true})
attrs)]
(into [:dialog base-attrs] children))
:cljs
(let [cls (dialog-class-list {})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes}
(when id {:id id})
(when open {:open true})
attrs)]
(into [:dialog base-attrs] children))
:clj
(let [classes (cond-> (dialog-classes {})
class (str " " class))
base-attrs (merge {:class classes}
(when id {:id id})
(when open {:open true})
attrs)]
(into [:dialog base-attrs] children))))
(defn dialog-header
"Render a dialog header section."
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:header (merge {:class (cond-> "dialog-header" class (str " " class))} attrs)] children)
:cljs
(into [:header (merge {:class (cond-> ["dialog-header"] class (conj class))} attrs)] children)
:clj
(into [:header (merge {:class (cond-> "dialog-header" class (str " " class))} attrs)] children)))
(defn dialog-body
"Render a dialog body section."
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:div (merge {:class (cond-> "dialog-body" class (str " " class))} attrs)] children)
:cljs
(into [:div (merge {:class (cond-> ["dialog-body"] class (conj class))} attrs)] children)
:clj
(into [:div (merge {:class (cond-> "dialog-body" class (str " " class))} attrs)] children)))
(defn dialog-footer
"Render a dialog footer section."
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:footer (merge {:class (cond-> "dialog-footer" class (str " " class))} attrs)] children)
:cljs
(into [:footer (merge {:class (cond-> ["dialog-footer"] class (conj class))} attrs)] children)
:clj
(into [:footer (merge {:class (cond-> "dialog-footer" class (str " " class))} attrs)] children)))

59
src/ui/dialog.css Normal file
View File

@@ -0,0 +1,59 @@
.dialog {
position: fixed;
inset: 0;
z-index: 50;
width: min(100% - 2rem, 32rem);
max-height: 85vh;
margin: auto;
padding: 0;
background: var(--bg-1);
border: var(--border-0);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-3);
overflow: hidden;
}
.dialog[open] {
display: flex;
flex-direction: column;
}
.dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.dialog-header {
display: flex;
flex-direction: column;
gap: var(--size-1);
padding: var(--size-6);
padding-bottom: 0;
}
.dialog-header h1,
.dialog-header h2,
.dialog-header h3,
.dialog-header h4,
.dialog-header h5,
.dialog-header h6 {
margin: 0;
}
.dialog-header p {
font-size: var(--font-sm);
color: var(--fg-2);
margin: 0;
}
.dialog-body {
padding: var(--size-6);
overflow-y: auto;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: var(--size-2);
padding: var(--size-6);
padding-top: 0;
}

194
src/ui/form.css Normal file
View File

@@ -0,0 +1,194 @@
.form-label {
display: block;
font-size: var(--font-sm);
font-weight: 500;
color: var(--fg-0);
margin-bottom: var(--size-1);
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: var(--size-2) var(--size-3);
font-size: var(--font-sm);
font-family: inherit;
line-height: 1.5;
background: var(--bg-0);
color: var(--fg-0);
border: var(--border-1);
border-radius: var(--radius-md);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.form-input::placeholder,
.form-textarea::placeholder {
color: var(--fg-2);
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
}
.form-input:disabled,
.form-textarea:disabled,
.form-select:disabled {
background: var(--bg-2);
cursor: not-allowed;
}
.form-input--error,
.form-textarea--error {
border-color: var(--danger);
}
.form-input--error:focus,
.form-textarea--error:focus {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 20%, transparent);
}
.form-textarea {
min-height: 5rem;
resize: vertical;
}
.form-select {
appearance: none;
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='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--size-2) center;
padding-right: var(--size-6);
cursor: pointer;
}
.form-hint {
font-size: var(--font-xs);
color: var(--fg-2);
margin-top: var(--size-1);
}
.form-error {
font-size: var(--font-xs);
color: var(--danger);
margin-top: var(--size-1);
}
.form-checkbox,
.form-radio {
appearance: none;
width: 1rem;
height: 1rem;
margin: 0;
background: var(--bg-0);
border: var(--border-1);
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease;
vertical-align: middle;
}
.form-checkbox {
border-radius: var(--radius-sm);
}
.form-radio {
border-radius: 9999px;
}
.form-checkbox:checked,
.form-radio:checked {
background: var(--accent);
border-color: var(--accent);
}
.form-checkbox:checked {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='4'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
background-size: 100%;
background-position: center;
background-repeat: no-repeat;
}
.form-radio:checked {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='4' fill='white'/%3E%3C/svg%3E");
background-size: 100%;
background-position: center;
background-repeat: no-repeat;
}
.form-checkbox:disabled,
.form-radio:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-range {
width: 100%;
height: 6px;
appearance: none;
background: var(--bg-2);
border-radius: 9999px;
cursor: pointer;
}
.form-range::-webkit-slider-thumb {
appearance: none;
width: 1.25rem;
height: 1.25rem;
background: var(--accent);
border-radius: 9999px;
transition: transform 150ms ease;
}
.form-range::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
.form-range::-moz-range-thumb {
width: 1.25rem;
height: 1.25rem;
background: var(--accent);
border: none;
border-radius: 9999px;
}
.form-group {
display: flex;
align-items: stretch;
}
.form-group > .form-input,
.form-group > .form-select {
border-radius: 0;
}
.form-group > *:first-child,
.form-group > *:first-child .form-input,
.form-group > *:first-child .form-select {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.form-group > *:last-child,
.form-group > *:last-child .form-input,
.form-group > *:last-child .form-select {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
}
.form-group > *:not(:first-child) {
margin-left: -1px;
}
.form-group-addon {
display: inline-flex;
align-items: center;
padding: var(--size-2) var(--size-3);
font-size: var(--font-sm);
background: var(--bg-2);
border: var(--border-1);
color: var(--fg-1);
white-space: nowrap;
}

51
src/ui/grid.css Normal file
View File

@@ -0,0 +1,51 @@
.container {
width: 100%;
max-width: 1280px;
margin-inline: auto;
padding-inline: 1rem;
}
.row {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 1.5rem;
width: 100%;
}
.col { grid-column-end: span 12; }
.col-1 { grid-column-end: span 1; }
.col-2 { grid-column-end: span 2; }
.col-3 { grid-column-end: span 3; }
.col-4 { grid-column-end: span 4; }
.col-5 { grid-column-end: span 5; }
.col-6 { grid-column-end: span 6; }
.col-7 { grid-column-end: span 7; }
.col-8 { grid-column-end: span 8; }
.col-9 { grid-column-end: span 9; }
.col-10 { grid-column-end: span 10; }
.col-11 { grid-column-end: span 11; }
.col-12 { grid-column-end: span 12; }
.offset-1 { grid-column-start: 2; }
.offset-2 { grid-column-start: 3; }
.offset-3 { grid-column-start: 4; }
.offset-4 { grid-column-start: 5; }
.offset-5 { grid-column-start: 6; }
.offset-6 { grid-column-start: 7; }
.col-end {
grid-column-end: -1;
}
@media (max-width: 768px) {
.row {
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
.col, [class*="col-"] {
grid-column-end: span 4;
}
[class*="offset-"] {
grid-column-start: auto;
}
}

70
src/ui/pagination.cljc Normal file
View File

@@ -0,0 +1,70 @@
(ns ui.pagination
(:require [clojure.string :as str]))
(defn pagination-item-class-list
"Generate a vector of CSS class strings for a pagination item."
[{:keys [active disabled]}]
(cond-> ["pagination-item"]
active (conj "pagination-item--active")
disabled (conj "pagination-item--disabled")))
(defn pagination-item-classes
"Generate CSS class string for a pagination item."
[opts]
(str/join " " (pagination-item-class-list opts)))
(defn pagination
"Render a pagination nav.
Props:
:current - current page number (1-based)
:total - total number of pages
:href-fn - fn of page number → URL string (for :clj)
:on-click - fn of page number → handler (for :cljs/:squint)
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [current total href-fn on-click class attrs] :as _props}]
(let [pages (range 1 (inc total))
prev-disabled (= current 1)
next-disabled (= current total)]
#?(:squint
(let [classes (cond-> "pagination" class (str " " class))
make-item (fn [page label active disabled]
[:li {:class (pagination-item-classes {:active active :disabled disabled})}
[:a (cond-> {:href (if href-fn (href-fn page) "#")}
(and on-click (not disabled))
(assoc :on-click (fn [e] (.preventDefault e) (on-click page))))
label]])]
[:nav {:aria-label "Pagination"}
(into [:ol (merge {:class classes} attrs)]
(concat
[(make-item (dec current) "← Previous" false prev-disabled)]
(map (fn [p] (make-item p (str p) (= p current) false)) pages)
[(make-item (inc current) "Next →" false next-disabled)]))])
:cljs
(let [cls (cond-> ["pagination"] class (conj class))
make-item (fn [page label active disabled]
[:li {:class (pagination-item-class-list {:active active :disabled disabled})}
[:a (cond-> {:href (if href-fn (href-fn page) "#")}
(and on-click (not disabled))
(assoc-in [:on :click] (fn [e] (.preventDefault e) (on-click page))))
label]])]
[:nav {:aria-label "Pagination"}
(into [:ol (merge {:class cls} attrs)]
(concat
[(make-item (dec current) "← Previous" false prev-disabled)]
(map (fn [p] (make-item p (str p) (= p current) false)) pages)
[(make-item (inc current) "Next →" false next-disabled)]))])
:clj
(let [classes (cond-> "pagination" class (str " " class))
make-item (fn [page label active disabled]
[:li {:class (pagination-item-classes {:active active :disabled disabled})}
[:a {:href (if href-fn (href-fn page) "#")} label]])]
[:nav {:aria-label "Pagination"}
(into [:ol (merge {:class classes} attrs)]
(concat
[(make-item (dec current) "← Previous" false prev-disabled)]
(map (fn [p] (make-item p (str p) (= p current) false)) pages)
[(make-item (inc current) "Next →" false next-disabled)]))]))))

41
src/ui/pagination.css Normal file
View File

@@ -0,0 +1,41 @@
.pagination {
display: flex;
align-items: center;
list-style: none;
padding: 0;
margin: 0;
gap: var(--size-1);
}
.pagination-item a,
.pagination-item span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 var(--size-2);
font-size: var(--font-sm);
text-decoration: none;
border-radius: var(--radius-md);
color: var(--fg-1);
transition: background-color 150ms ease, color 150ms ease;
}
.pagination-item a:hover {
background: var(--bg-1);
color: var(--fg-0);
}
.pagination-item--active a,
.pagination-item--active span {
background: var(--accent);
color: var(--fg-on-accent);
}
.pagination-item--disabled a,
.pagination-item--disabled span {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}

52
src/ui/progress.cljc Normal file
View File

@@ -0,0 +1,52 @@
(ns ui.progress
(:require [clojure.string :as str]))
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
:clj (defn- kw-name [s] (name s)))
(defn progress-bar-class-list
"Generate a vector of CSS class strings for the progress bar fill.
Variants: nil (default/accent), :success, :warning, :danger."
[{:keys [variant]}]
(cond-> ["progress-bar"]
variant (conj (str "progress-bar--" (kw-name variant)))))
(defn progress-bar-classes
"Generate CSS class string for the progress bar fill."
[opts]
(str/join " " (progress-bar-class-list opts)))
(defn progress
"Render a progress bar.
Props:
:value - number 0-100
:variant - nil, :success, :warning, :danger
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [value variant class attrs] :as _props}]
(let [pct (str (or value 0) "%")]
#?(:squint
(let [classes (cond-> "progress" class (str " " class))
bar-cls (progress-bar-classes {:variant variant})]
[:div (merge {:class classes :role "progressbar"
:aria-valuenow (str (or value 0))
:aria-valuemin "0" :aria-valuemax "100"} attrs)
[:div {:class bar-cls :style {"width" pct}}]])
:cljs
(let [cls (cond-> ["progress"] class (conj class))
bar-cls (progress-bar-class-list {:variant variant})]
[:div (merge {:class cls :role "progressbar"
:aria-valuenow (str (or value 0))
:aria-valuemin "0" :aria-valuemax "100"} attrs)
[:div {:class bar-cls :style {:width pct}}]])
:clj
(let [classes (cond-> "progress" class (str " " class))
bar-cls (progress-bar-classes {:variant variant})]
[:div (merge {:class classes :role "progressbar"
:aria-valuenow (str (or value 0))
:aria-valuemin "0" :aria-valuemax "100"} attrs)
[:div {:class bar-cls :style (str "width: " pct)}]]))))

26
src/ui/progress.css Normal file
View File

@@ -0,0 +1,26 @@
.progress {
width: 100%;
height: 6px;
background: var(--bg-2);
border-radius: 9999px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--accent);
border-radius: 9999px;
transition: width 300ms ease;
}
.progress-bar--success {
background: var(--success);
}
.progress-bar--warning {
background: var(--warning);
}
.progress-bar--danger {
background: var(--danger);
}

44
src/ui/skeleton.cljc Normal file
View File

@@ -0,0 +1,44 @@
(ns ui.skeleton
(:require [clojure.string :as str]))
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
:clj (defn- kw-name [s] (name s)))
(defn skeleton-class-list
"Generate a vector of CSS class strings for a skeleton placeholder.
Variants: :line (text line), :box (square), :circle, :heading."
[{:keys [variant]}]
(cond-> ["skeleton"]
variant (conj (str "skeleton--" (kw-name variant)))))
(defn skeleton-classes
"Generate CSS class string for a skeleton."
[opts]
(str/join " " (skeleton-class-list opts)))
(defn skeleton
"Render a skeleton loading placeholder.
Props:
:variant - :line, :box, :circle, :heading
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [variant class attrs] :as _props}]
#?(:squint
(let [classes (cond-> (skeleton-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes :role "status" :aria-label "Loading"} attrs)]
[:div base-attrs])
:cljs
(let [cls (skeleton-class-list {:variant variant})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes :role "status" :aria-label "Loading"} attrs)]
[:div base-attrs])
:clj
(let [classes (cond-> (skeleton-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes :role "status" :aria-label "Loading"} attrs)]
[:div base-attrs])))

42
src/ui/skeleton.css vendored Normal file
View File

@@ -0,0 +1,42 @@
.skeleton {
background: var(--bg-2);
border-radius: var(--radius-md);
animation: skeleton-pulse 2s infinite;
background-size: 200% 100%;
background-image: linear-gradient(
90deg,
var(--bg-2) 0%,
var(--bg-1) 50%,
var(--bg-2) 100%
);
}
.skeleton + .skeleton {
margin-top: var(--size-3);
}
.skeleton--line {
height: 1rem;
width: 100%;
}
.skeleton--box {
width: 4rem;
height: 4rem;
}
.skeleton--circle {
width: 3rem;
height: 3rem;
border-radius: 9999px;
}
.skeleton--heading {
height: 1.5rem;
width: 60%;
}
@keyframes skeleton-pulse {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}

48
src/ui/spinner.cljc Normal file
View File

@@ -0,0 +1,48 @@
(ns ui.spinner
(:require [clojure.string :as str]))
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
:clj (defn- kw-name [s] (name s)))
(def default-size "md")
(defn spinner-class-list
"Generate a vector of CSS class strings for a spinner.
Sizes: :sm, :md (default), :lg."
[{:keys [size]}]
(let [s (or (some-> size kw-name) default-size)]
(cond-> ["spinner"]
(= s "sm") (conj "spinner-sm")
(= s "lg") (conj "spinner-lg"))))
(defn spinner-classes
"Generate CSS class string for a spinner."
[opts]
(str/join " " (spinner-class-list opts)))
(defn spinner
"Render a spinner element.
Props:
:size - :sm, :md, :lg
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [size class attrs] :as _props}]
#?(:squint
(let [classes (cond-> (spinner-classes {:size size})
class (str " " class))
base-attrs (merge {:class classes :role "status" :aria-label "Loading"} attrs)]
[:span base-attrs])
:cljs
(let [cls (spinner-class-list {:size size})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes :role "status" :aria-label "Loading"} attrs)]
[:span base-attrs])
:clj
(let [classes (cond-> (spinner-classes {:size size})
class (str " " class))
base-attrs (merge {:class classes :role "status" :aria-label "Loading"} attrs)]
[:span base-attrs])))

49
src/ui/spinner.css Normal file
View File

@@ -0,0 +1,49 @@
.spinner {
display: inline-block;
width: 1.5rem;
height: 1.5rem;
border: 2px solid var(--bg-2);
border-top-color: var(--accent);
border-radius: 9999px;
animation: spinner-spin 1s linear infinite;
}
.spinner-sm {
width: 1rem;
height: 1rem;
}
.spinner-lg {
width: 2rem;
height: 2rem;
border-width: 3px;
}
.spinner-overlay {
position: relative;
}
.spinner-overlay > * {
opacity: 0.3;
pointer-events: none;
}
.spinner-overlay::before {
content: "";
position: absolute;
inset: 0;
margin: auto;
z-index: 1;
width: 1.5rem;
height: 1.5rem;
border: 2px solid var(--bg-2);
border-top-color: var(--accent);
border-radius: 9999px;
animation: spinner-spin 1s linear infinite;
}
@keyframes spinner-spin {
to {
transform: rotate(360deg);
}
}

65
src/ui/switch.cljc Normal file
View File

@@ -0,0 +1,65 @@
(ns ui.switch
(:require [clojure.string :as str]))
(defn switch-class-list
"Generate a vector of CSS class strings for a switch."
[{:keys [disabled]}]
(cond-> ["switch"]
disabled (conj "switch--disabled")))
(defn switch-classes
"Generate CSS class string for a switch."
[opts]
(str/join " " (switch-class-list opts)))
(defn switch-toggle
"Render a toggle switch element.
Props:
:checked - boolean, whether the switch is on
:disabled - boolean
:on-change - change handler (ignored in :clj target)
:label - label text
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [checked disabled on-change label class attrs] :as _props}]
#?(:squint
(let [classes (cond-> (switch-classes {:disabled disabled})
class (str " " class))
track-cls (cond-> "switch-track"
checked (str " switch-track--checked"))]
[:label (merge {:class classes} attrs)
[:input {:class "switch-input" :type "checkbox"
:checked (boolean checked)
:disabled (boolean disabled)
:on-change on-change}]
[:span {:class track-cls}
[:span {:class "switch-thumb"}]]
(when label [:span label])])
:cljs
(let [cls (switch-class-list {:disabled disabled})
classes (cond-> cls class (conj class))
track-cls (cond-> ["switch-track"]
checked (conj "switch-track--checked"))]
[:label (merge {:class classes} attrs)
[:input (cond-> {:class ["switch-input"] :type "checkbox"
:checked (boolean checked)
:disabled (boolean disabled)}
on-change (assoc-in [:on :change] on-change))]
[:span {:class track-cls}
[:span {:class ["switch-thumb"]}]]
(when label [:span label])])
:clj
(let [classes (cond-> (switch-classes {:disabled disabled})
class (str " " class))
track-cls (cond-> "switch-track"
checked (str " switch-track--checked"))]
[:label (merge {:class classes} attrs)
[:input {:class "switch-input" :type "checkbox"
:checked (boolean checked)
:disabled (boolean disabled)}]
[:span {:class track-cls}
[:span {:class "switch-thumb"}]]
(when label [:span label])])))

52
src/ui/switch.css Normal file
View File

@@ -0,0 +1,52 @@
.switch {
display: inline-flex;
align-items: center;
gap: var(--size-2);
cursor: pointer;
user-select: none;
font-size: var(--font-sm);
color: var(--fg-0);
}
.switch-track {
position: relative;
width: 2.5rem;
height: 1.375rem;
background: var(--bg-2);
border-radius: 9999px;
transition: background-color 200ms ease;
flex-shrink: 0;
}
.switch-track--checked {
background: var(--accent);
}
.switch-thumb {
position: absolute;
top: 2px;
left: 2px;
width: calc(1.375rem - 4px);
height: calc(1.375rem - 4px);
background: var(--bg-0);
border-radius: 9999px;
box-shadow: var(--shadow-0);
transition: transform 200ms ease;
}
.switch-track--checked .switch-thumb {
transform: translateX(calc(2.5rem - 1.375rem));
}
.switch--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.switch-input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}

76
src/ui/table.cljc Normal file
View File

@@ -0,0 +1,76 @@
(ns ui.table
(:require [clojure.string :as str]))
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
:clj (defn- kw-name [s] (name s)))
(defn table-class-list
"Generate a vector of CSS class strings for a table.
Variants: nil (default), :striped, :bordered."
[{:keys [variant]}]
(cond-> ["table"]
variant (conj (str "table--" (kw-name variant)))))
(defn table-classes
"Generate CSS class string for a table."
[opts]
(str/join " " (table-class-list opts)))
(defn table
"Render a table element.
Props:
:headers - vector of column header strings
:rows - vector of row vectors (each row is a vector of cell values)
:variant - nil, :striped, :bordered
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [headers rows variant class attrs] :as _props}]
#?(:squint
(let [classes (cond-> (table-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
[:div {:class "table-wrapper"}
[:table base-attrs
(when (seq headers)
[:thead
(into [:tr]
(map (fn [h] [:th h]) headers))])
(into [:tbody]
(map (fn [row]
(into [:tr]
(map (fn [cell] [:td cell]) row)))
rows))]])
:cljs
(let [cls (table-class-list {:variant variant})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes} attrs)]
[:div {:class ["table-wrapper"]}
[:table base-attrs
(when (seq headers)
[:thead
(into [:tr]
(map (fn [h] [:th h]) headers))])
(into [:tbody]
(map (fn [row]
(into [:tr]
(map (fn [cell] [:td cell]) row)))
rows))]])
:clj
(let [classes (cond-> (table-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
[:div {:class "table-wrapper"}
[:table base-attrs
(when (seq headers)
[:thead
(into [:tr]
(map (fn [h] [:th h]) headers))])
(into [:tbody]
(map (fn [row]
(into [:tr]
(map (fn [cell] [:td cell]) row)))
rows))]])))

54
src/ui/table.css Normal file
View File

@@ -0,0 +1,54 @@
.table-wrapper {
min-width: 320px;
width: 100%;
overflow-x: auto;
}
.table {
border-collapse: collapse;
width: 100%;
font-size: var(--font-sm);
}
.table thead {
border-bottom: var(--border-1);
}
.table th {
padding: var(--size-3) var(--size-2);
text-align: left;
font-weight: 500;
color: var(--fg-2);
}
.table td {
padding: var(--size-3) var(--size-2);
}
.table tbody tr {
border-bottom: var(--border-0);
transition: background-color 150ms ease;
}
.table tbody tr:last-child {
border-bottom: none;
}
.table tbody tr:hover {
background: var(--bg-1);
}
.table--striped tbody tr:nth-child(even) {
background: var(--bg-1);
}
.table--bordered {
border: var(--border-0);
border-radius: var(--radius-md);
overflow: hidden;
}
.table--bordered th,
.table--bordered td {
border: var(--border-0);
}

25
src/ui/tooltip.cljc Normal file
View File

@@ -0,0 +1,25 @@
(ns ui.tooltip
(:require [clojure.string :as str]))
(defn tooltip
"Render a tooltip wrapper. Shows tooltip text on hover.
Props:
:text - tooltip text to display
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [text class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> "tooltip" class (str " " class))
base-attrs (merge {:class classes :data-tooltip text} attrs)]
(into [:span base-attrs] children))
:cljs
(let [cls (cond-> ["tooltip"] class (conj class))
base-attrs (merge {:class cls :data-tooltip text} attrs)]
(into [:span base-attrs] children))
:clj
(let [classes (cond-> "tooltip" class (str " " class))
base-attrs (merge {:class classes :data-tooltip text} attrs)]
(into [:span base-attrs] children))))

43
src/ui/tooltip.css Normal file
View File

@@ -0,0 +1,43 @@
.tooltip {
position: relative;
display: inline-block;
}
.tooltip::before,
.tooltip::after {
position: absolute;
left: 50%;
opacity: 0;
visibility: hidden;
transition: opacity 150ms ease, transform 150ms ease, visibility 150ms ease;
pointer-events: none;
z-index: 1000;
}
.tooltip::after {
content: attr(data-tooltip);
bottom: calc(100% + 10px);
transform: translateX(-50%) translateY(4px);
padding: var(--size-2) var(--size-3);
font-size: var(--font-xs);
line-height: 1;
white-space: nowrap;
background: var(--fg-0);
color: var(--bg-0);
border-radius: var(--radius-md);
}
.tooltip::before {
content: "";
bottom: calc(100% - 5px);
transform: translateX(-50%) translateY(4px);
border: 6px solid transparent;
border-top-color: var(--fg-0);
}
.tooltip:hover::before,
.tooltip:hover::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}

51
src/ui/utilities.css Normal file
View File

@@ -0,0 +1,51 @@
.align-left { text-align: start; }
.align-center { text-align: center; }
.align-right { text-align: end; }
.text-muted { color: var(--fg-1); }
.text-faint { color: var(--fg-2); }
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.hstack {
display: flex;
align-items: center;
gap: var(--size-4);
flex-wrap: wrap;
}
.vstack {
display: flex;
flex-direction: column;
gap: var(--size-3);
}
.gap-1 { gap: var(--size-1); }
.gap-2 { gap: var(--size-2); }
.gap-3 { gap: var(--size-3); }
.gap-4 { gap: var(--size-4); }
.mt-2 { margin-top: var(--size-2); }
.mt-4 { margin-top: var(--size-4); }
.mt-6 { margin-top: var(--size-6); }
.mb-2 { margin-bottom: var(--size-2); }
.mb-4 { margin-bottom: var(--size-4); }
.mb-6 { margin-bottom: var(--size-6); }
.p-4 { padding: var(--size-4); }
.w-full { width: 100%; }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

View File

@@ -0,0 +1,31 @@
(ns ui.accordion-test
(:require [clojure.test :refer [deftest is testing]]
[ui.accordion :as accordion]))
(deftest accordion-class-list-test
(testing "closed accordion"
(is (= ["accordion"] (accordion/accordion-class-list {})))
(is (= ["accordion"] (accordion/accordion-class-list {:open false}))))
(testing "open accordion"
(is (= ["accordion" "accordion--open"] (accordion/accordion-class-list {:open true})))))
(deftest accordion-classes-test
(testing "space-joined output"
(is (= "accordion" (accordion/accordion-classes {})))
(is (= "accordion accordion--open" (accordion/accordion-classes {:open true})))))
(deftest accordion-component-test
(testing "closed accordion renders trigger only"
(let [result (accordion/accordion {:title "Question?"} "Answer.")]
(is (= :div (first result)))
(is (= "accordion" (get-in result [1 :class])))
;; trigger is present
(is (= "accordion-trigger" (get-in result [2 1 :class])))))
(testing "open accordion includes content"
(let [result (accordion/accordion {:title "Q?" :open true} "A.")]
(is (= "accordion accordion--open" (get-in result [1 :class])))
;; should contain accordion-content div
(is (some #(and (vector? %) (= "accordion-content" (get-in % [1 :class])))
(rest (rest result)))))))

35
test/ui/alert_test.clj Normal file
View File

@@ -0,0 +1,35 @@
(ns ui.alert-test
(:require [clojure.test :refer [deftest is testing]]
[ui.alert :as alert]))
(deftest alert-class-list-test
(testing "neutral (no variant)"
(is (= ["alert"] (alert/alert-class-list {}))))
(testing "explicit variants"
(is (= ["alert" "alert-success"] (alert/alert-class-list {:variant :success})))
(is (= ["alert" "alert-warning"] (alert/alert-class-list {:variant :warning})))
(is (= ["alert" "alert-danger"] (alert/alert-class-list {:variant :danger})))
(is (= ["alert" "alert-info"] (alert/alert-class-list {:variant :info})))))
(deftest alert-classes-test
(testing "space-joined output"
(is (= "alert" (alert/alert-classes {})))
(is (= "alert alert-success" (alert/alert-classes {:variant :success})))))
(deftest alert-component-test
(testing "basic alert renders correct hiccup"
(let [result (alert/alert {:variant :success :title "Done!"} "Saved.")]
(is (= :div (first result)))
(is (= "alert alert-success" (get-in result [1 :class])))
(is (= "alert" (get-in result [1 :role])))))
(testing "alert with title includes title paragraph"
(let [result (alert/alert {:title "Title"} "Body")]
(is (some #(and (vector? %) (= "alert-title" (get-in % [1 :class])))
(rest (rest result))))))
(testing "alert without title has no title paragraph"
(let [result (alert/alert {} "Body")]
(is (not (some #(and (vector? %) (= "alert-title" (get-in % [1 :class])))
(rest (rest result))))))))

31
test/ui/badge_test.clj Normal file
View File

@@ -0,0 +1,31 @@
(ns ui.badge-test
(:require [clojure.test :refer [deftest is testing]]
[ui.badge :as badge]))
(deftest badge-class-list-test
(testing "default variant (primary)"
(is (= ["badge"] (badge/badge-class-list {}))))
(testing "explicit variants"
(is (= ["badge"] (badge/badge-class-list {:variant :primary})))
(is (= ["badge" "badge-secondary"] (badge/badge-class-list {:variant :secondary})))
(is (= ["badge" "badge-outline"] (badge/badge-class-list {:variant :outline})))
(is (= ["badge" "badge-success"] (badge/badge-class-list {:variant :success})))
(is (= ["badge" "badge-warning"] (badge/badge-class-list {:variant :warning})))
(is (= ["badge" "badge-danger"] (badge/badge-class-list {:variant :danger})))))
(deftest badge-classes-test
(testing "space-joined output"
(is (= "badge" (badge/badge-classes {})))
(is (= "badge badge-danger" (badge/badge-classes {:variant :danger})))))
(deftest badge-component-test
(testing "renders a span"
(let [result (badge/badge {} "New")]
(is (= :span (first result)))
(is (= "badge" (get-in result [1 :class])))
(is (= "New" (nth result 2)))))
(testing "extra class gets appended"
(let [result (badge/badge {:class "extra"} "X")]
(is (= "badge extra" (get-in result [1 :class]))))))

View File

@@ -0,0 +1,37 @@
(ns ui.breadcrumb-test
(:require [clojure.test :refer [deftest is testing]]
[ui.breadcrumb :as breadcrumb]))
(deftest breadcrumb-component-test
(testing "renders a nav with breadcrumb list"
(let [result (breadcrumb/breadcrumb
{:items [{:label "Home" :href "/"}
{:label "Projects" :href "/projects"}
{:label "Current"}]})]
(is (= :nav (first result)))
(is (= "Breadcrumb" (get-in result [1 :aria-label])))
;; ol is inside
(let [ol (nth result 2)]
(is (= :ol (first ol)))
(is (= "breadcrumb" (get-in ol [1 :class])))
;; 3 items
(is (= 3 (count (drop 2 ol)))))))
(testing "last item is active"
(let [result (breadcrumb/breadcrumb
{:items [{:label "Home" :href "/"}
{:label "Active"}]})
ol (nth result 2)
items (drop 2 ol)
last-item (last items)]
(is (clojure.string/includes? (get-in last-item [1 :class]) "breadcrumb-item--active")))))
(deftest breadcrumb-links-test
(testing "non-active items have links"
(let [result (breadcrumb/breadcrumb
{:items [{:label "Home" :href "/"}
{:label "End"}]})
ol (nth result 2)
first-item (nth ol 2)]
;; first item should have an anchor child
(is (= :a (first (nth first-item 2)))))))

36
test/ui/card_test.clj Normal file
View File

@@ -0,0 +1,36 @@
(ns ui.card-test
(:require [clojure.test :refer [deftest is testing]]
[ui.card :as card]))
(deftest card-class-list-test
(testing "always returns card class"
(is (= ["card"] (card/card-class-list {})))))
(deftest card-component-test
(testing "renders an article"
(let [result (card/card {} "Content")]
(is (= :article (first result)))
(is (= "card" (get-in result [1 :class])))
(is (= "Content" (nth result 2)))))
(testing "extra class gets appended"
(let [result (card/card {:class "extra"} "X")]
(is (= "card extra" (get-in result [1 :class]))))))
(deftest card-header-test
(testing "renders header"
(let [result (card/card-header {} [:h3 "Title"])]
(is (= :header (first result)))
(is (= "card-header" (get-in result [1 :class]))))))
(deftest card-body-test
(testing "renders body"
(let [result (card/card-body {} "Content")]
(is (= :div (first result)))
(is (= "card-body" (get-in result [1 :class]))))))
(deftest card-footer-test
(testing "renders footer"
(let [result (card/card-footer {} "Actions")]
(is (= :footer (first result)))
(is (= "card-footer" (get-in result [1 :class]))))))

34
test/ui/dialog_test.clj Normal file
View File

@@ -0,0 +1,34 @@
(ns ui.dialog-test
(:require [clojure.test :refer [deftest is testing]]
[ui.dialog :as dialog]))
(deftest dialog-class-list-test
(testing "always returns dialog class"
(is (= ["dialog"] (dialog/dialog-class-list {})))))
(deftest dialog-component-test
(testing "renders a dialog element"
(let [result (dialog/dialog {:id "my-dialog"} "Content")]
(is (= :dialog (first result)))
(is (= "dialog" (get-in result [1 :class])))
(is (= "my-dialog" (get-in result [1 :id])))))
(testing "open dialog has open attr"
(let [result (dialog/dialog {:open true} "Content")]
(is (true? (get-in result [1 :open]))))))
(deftest dialog-sections-test
(testing "dialog-header renders header"
(let [result (dialog/dialog-header {} [:h3 "Title"])]
(is (= :header (first result)))
(is (= "dialog-header" (get-in result [1 :class])))))
(testing "dialog-body renders div"
(let [result (dialog/dialog-body {} "Body")]
(is (= :div (first result)))
(is (= "dialog-body" (get-in result [1 :class])))))
(testing "dialog-footer renders footer"
(let [result (dialog/dialog-footer {} "Footer")]
(is (= :footer (first result)))
(is (= "dialog-footer" (get-in result [1 :class]))))))

View File

@@ -0,0 +1,30 @@
(ns ui.pagination-test
(:require [clojure.test :refer [deftest is testing]]
[ui.pagination :as pagination]))
(deftest pagination-item-class-list-test
(testing "default item"
(is (= ["pagination-item"] (pagination/pagination-item-class-list {}))))
(testing "active item"
(is (= ["pagination-item" "pagination-item--active"]
(pagination/pagination-item-class-list {:active true}))))
(testing "disabled item"
(is (= ["pagination-item" "pagination-item--disabled"]
(pagination/pagination-item-class-list {:disabled true}))))
(testing "active + disabled"
(is (= ["pagination-item" "pagination-item--active" "pagination-item--disabled"]
(pagination/pagination-item-class-list {:active true :disabled true})))))
(deftest pagination-component-test
(testing "renders nav with pagination list"
(let [result (pagination/pagination {:current 2 :total 5})]
(is (= :nav (first result)))
(is (= "Pagination" (get-in result [1 :aria-label])))
(let [ol (nth result 2)]
(is (= :ol (first ol)))
(is (= "pagination" (get-in ol [1 :class])))
;; 5 page items + prev + next = 7
(is (= 7 (count (drop 2 ol))))))))

24
test/ui/progress_test.clj Normal file
View File

@@ -0,0 +1,24 @@
(ns ui.progress-test
(:require [clojure.test :refer [deftest is testing]]
[ui.progress :as progress]))
(deftest progress-bar-class-list-test
(testing "default (no variant)"
(is (= ["progress-bar"] (progress/progress-bar-class-list {}))))
(testing "explicit variants"
(is (= ["progress-bar" "progress-bar--success"] (progress/progress-bar-class-list {:variant :success})))
(is (= ["progress-bar" "progress-bar--warning"] (progress/progress-bar-class-list {:variant :warning})))
(is (= ["progress-bar" "progress-bar--danger"] (progress/progress-bar-class-list {:variant :danger})))))
(deftest progress-component-test
(testing "renders progress with correct structure"
(let [result (progress/progress {:value 60})]
(is (= :div (first result)))
(is (= "progress" (get-in result [1 :class])))
(is (= "progressbar" (get-in result [1 :role])))
;; inner bar
(let [bar (nth result 2)]
(is (= :div (first bar)))
(is (= "progress-bar" (get-in bar [1 :class])))
(is (= "width: 60%" (get-in bar [1 :style])))))))

20
test/ui/skeleton_test.clj Normal file
View File

@@ -0,0 +1,20 @@
(ns ui.skeleton-test
(:require [clojure.test :refer [deftest is testing]]
[ui.skeleton :as skeleton]))
(deftest skeleton-class-list-test
(testing "default (no variant)"
(is (= ["skeleton"] (skeleton/skeleton-class-list {}))))
(testing "explicit variants"
(is (= ["skeleton" "skeleton--line"] (skeleton/skeleton-class-list {:variant :line})))
(is (= ["skeleton" "skeleton--box"] (skeleton/skeleton-class-list {:variant :box})))
(is (= ["skeleton" "skeleton--circle"] (skeleton/skeleton-class-list {:variant :circle})))
(is (= ["skeleton" "skeleton--heading"] (skeleton/skeleton-class-list {:variant :heading})))))
(deftest skeleton-component-test
(testing "renders a div with role=status"
(let [result (skeleton/skeleton {:variant :line})]
(is (= :div (first result)))
(is (= "skeleton skeleton--line" (get-in result [1 :class])))
(is (= "status" (get-in result [1 :role]))))))

21
test/ui/spinner_test.clj Normal file
View File

@@ -0,0 +1,21 @@
(ns ui.spinner-test
(:require [clojure.test :refer [deftest is testing]]
[ui.spinner :as spinner]))
(deftest spinner-class-list-test
(testing "default size (md)"
(is (= ["spinner"] (spinner/spinner-class-list {})))
(is (= ["spinner"] (spinner/spinner-class-list {:size :md}))))
(testing "small size"
(is (= ["spinner" "spinner-sm"] (spinner/spinner-class-list {:size :sm}))))
(testing "large size"
(is (= ["spinner" "spinner-lg"] (spinner/spinner-class-list {:size :lg})))))
(deftest spinner-component-test
(testing "renders a span"
(let [result (spinner/spinner {})]
(is (= :span (first result)))
(is (= "spinner" (get-in result [1 :class])))
(is (= "status" (get-in result [1 :role]))))))

27
test/ui/switch_test.clj Normal file
View File

@@ -0,0 +1,27 @@
(ns ui.switch-test
(:require [clojure.test :refer [deftest is testing]]
[ui.switch :as switch]))
(deftest switch-class-list-test
(testing "default switch"
(is (= ["switch"] (switch/switch-class-list {}))))
(testing "disabled switch"
(is (= ["switch" "switch--disabled"] (switch/switch-class-list {:disabled true})))))
(deftest switch-component-test
(testing "renders a label"
(let [result (switch/switch-toggle {:label "Notifications"})]
(is (= :label (first result)))
(is (= "switch" (get-in result [1 :class])))))
(testing "checked switch has checked track class"
(let [result (switch/switch-toggle {:checked true :label "On"})]
;; Find the track span
(let [track (nth result 3)]
(is (= "switch-track switch-track--checked" (get-in track [1 :class]))))))
(testing "unchecked switch has no checked track class"
(let [result (switch/switch-toggle {:checked false :label "Off"})]
(let [track (nth result 3)]
(is (= "switch-track" (get-in track [1 :class])))))))

31
test/ui/table_test.clj Normal file
View File

@@ -0,0 +1,31 @@
(ns ui.table-test
(:require [clojure.test :refer [deftest is testing]]
[ui.table :as table]))
(deftest table-class-list-test
(testing "default table"
(is (= ["table"] (table/table-class-list {}))))
(testing "striped variant"
(is (= ["table" "table--striped"] (table/table-class-list {:variant :striped}))))
(testing "bordered variant"
(is (= ["table" "table--bordered"] (table/table-class-list {:variant :bordered})))))
(deftest table-classes-test
(testing "space-joined output"
(is (= "table" (table/table-classes {})))
(is (= "table table--striped" (table/table-classes {:variant :striped})))))
(deftest table-component-test
(testing "renders a table with headers and rows"
(let [result (table/table {:headers ["Name" "Email"]
:rows [["Alice" "alice@example.com"]
["Bob" "bob@example.com"]]})]
;; outer div.table-wrapper
(is (= :div (first result)))
(is (= "table-wrapper" (get-in result [1 :class])))
;; table element inside
(let [tbl (nth result 2)]
(is (= :table (first tbl)))
(is (= "table" (get-in tbl [1 :class])))))))

15
test/ui/tooltip_test.clj Normal file
View File

@@ -0,0 +1,15 @@
(ns ui.tooltip-test
(:require [clojure.test :refer [deftest is testing]]
[ui.tooltip :as tooltip]))
(deftest tooltip-component-test
(testing "renders a span with data-tooltip attr"
(let [result (tooltip/tooltip {:text "Hello"} "Hover me")]
(is (= :span (first result)))
(is (= "tooltip" (get-in result [1 :class])))
(is (= "Hello" (get-in result [1 :data-tooltip])))
(is (= "Hover me" (nth result 2)))))
(testing "extra class appended"
(let [result (tooltip/tooltip {:text "Tip" :class "extra"} "X")]
(is (= "tooltip extra" (get-in result [1 :class]))))))