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:
30
bb.edn
30
bb.edn
@@ -17,8 +17,36 @@
|
||||
{:doc "Run all unit tests"
|
||||
:requires ([clojure.test :as t]
|
||||
[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])
|
||||
: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))
|
||||
(System/exit 1)))}
|
||||
|
||||
|
||||
@@ -1,35 +1,170 @@
|
||||
(ns dev.hiccup
|
||||
(:require [org.httpkit.server :as http]
|
||||
[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])
|
||||
(def sizes [:sm :md :lg])
|
||||
(defn section [title & children]
|
||||
[: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 []
|
||||
[:div {:style "display: grid; grid-template-columns: repeat(4, auto); gap: 1rem; align-items: center;"}
|
||||
;; Header row
|
||||
[: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)])
|
||||
;; ── Button ──────────────────────────────────────────────────────────
|
||||
(def button-variants [:primary :secondary :ghost :danger])
|
||||
(def button-sizes [:sm :md :lg])
|
||||
|
||||
;; Variant rows
|
||||
(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}
|
||||
(str (name variant) " " (name size)))])))])
|
||||
(defn button-demo []
|
||||
(section "Button"
|
||||
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
|
||||
(for [v button-variants]
|
||||
(button/button {:variant 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")))]))
|
||||
|
||||
(defn disabled-row []
|
||||
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap;"}
|
||||
(for [variant variants]
|
||||
(button/button {:variant variant :disabled true}
|
||||
(str (name variant) " 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 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 []
|
||||
(str
|
||||
"<!DOCTYPE html>\n"
|
||||
@@ -47,10 +182,20 @@
|
||||
[: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);"}
|
||||
"Toggle Dark Mode"]]
|
||||
[:h3 {:style "color: var(--fg-1); margin-bottom: 1rem;"} "Button Grid"]
|
||||
(button-grid)
|
||||
[:h3 {:style "color: var(--fg-1); margin: 2rem 0 1rem;"} "Disabled States"]
|
||||
(disabled-row)]]])))
|
||||
(button-demo)
|
||||
(alert-demo)
|
||||
(badge-demo)
|
||||
(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]}]
|
||||
(case uri
|
||||
|
||||
@@ -1,43 +1,180 @@
|
||||
(ns dev.replicant
|
||||
(: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])
|
||||
(def sizes [:sm :md :lg])
|
||||
(defn section [title & children]
|
||||
[: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 []
|
||||
[:div {:style {:display "grid"
|
||||
:grid-template-columns "repeat(4, auto)"
|
||||
: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)))])))])
|
||||
;; ── Button ──────────────────────────────────────────────────────────
|
||||
(def button-variants [:primary :secondary :ghost :danger])
|
||||
(def button-sizes [:sm :md :lg])
|
||||
|
||||
(defn disabled-row []
|
||||
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap"}}
|
||||
(for [variant variants]
|
||||
(button/button {:variant variant :disabled true}
|
||||
(str (name variant) " disabled")))])
|
||||
(defn button-demo []
|
||||
(section "Button"
|
||||
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
|
||||
(for [v button-variants]
|
||||
(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]
|
||||
(let [el (.-documentElement js/document)
|
||||
current (.. el -dataset -theme)]
|
||||
(set! (.. el -dataset -theme)
|
||||
(if (= current "dark") "light" "dark"))))
|
||||
|
||||
;; ── App ─────────────────────────────────────────────────────────────
|
||||
(defn app []
|
||||
[:div {:style {:max-width "800px" :margin "0 auto"}}
|
||||
[: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)"
|
||||
:border "var(--border-0)" :background "var(--bg-1)" :color "var(--fg-0)"}}
|
||||
"Toggle Dark Mode"]]
|
||||
[:h3 {:style {:color "var(--fg-1)" :margin-bottom "1rem"}} "Button Grid"]
|
||||
(button-grid)
|
||||
[:h3 {:style {:color "var(--fg-1)" :margin "2rem 0 1rem"}} "Disabled States"]
|
||||
(disabled-row)])
|
||||
(button-demo)
|
||||
(alert-demo)
|
||||
(badge-demo)
|
||||
(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! []
|
||||
(d/set-dispatch! (fn [_ _]))
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
(ns dev.squint
|
||||
(:require ["eucalypt" :as eu]
|
||||
[ui.button :as button]))
|
||||
|
||||
(def variants ["primary" "secondary" "ghost" "danger"])
|
||||
(def sizes ["sm" "md" "lg"])
|
||||
[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]))
|
||||
|
||||
(defn toggle-theme! [_e]
|
||||
(let [el (.-documentElement js/document)
|
||||
@@ -11,46 +21,161 @@
|
||||
(set! (.. el -dataset -theme)
|
||||
(if (= current "dark") "light" "dark"))))
|
||||
|
||||
(def label-style {"font-weight" "600"
|
||||
"color" "var(--fg-1)"
|
||||
"font-size" "0.75rem"
|
||||
"text-transform" "uppercase"
|
||||
"letter-spacing" "0.05em"})
|
||||
(defn section [title & children]
|
||||
(into [: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]]
|
||||
children))
|
||||
|
||||
(defn button-grid []
|
||||
(into
|
||||
[:div {:style {"display" "grid"
|
||||
"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)))
|
||||
;; ── Button ──────────────────────────────────────────────────────────
|
||||
(def button-variants ["primary" "secondary" "ghost" "danger"])
|
||||
(def button-sizes ["sm" "md" "lg"])
|
||||
|
||||
(defn disabled-row []
|
||||
(into
|
||||
[:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap"}}]
|
||||
(map (fn [variant]
|
||||
(button/button {:variant variant :disabled true}
|
||||
(str variant " disabled")))
|
||||
variants)))
|
||||
(defn button-demo []
|
||||
(section "Button"
|
||||
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
|
||||
(map (fn [v]
|
||||
(button/button {:variant v :on-click (fn [_] (js/console.log (str "Clicked: " v)))} v))
|
||||
button-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 []
|
||||
[:div {:style {"max-width" "800px" "margin" "0 auto"}}
|
||||
[: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)"
|
||||
"border" "var(--border-0)" "background" "var(--bg-1)" "color" "var(--fg-0)"}}
|
||||
"Toggle Dark Mode"]]
|
||||
[:h3 {:style {"color" "var(--fg-1)" "margin-bottom" "1rem"}} "Button Grid"]
|
||||
(button-grid)
|
||||
[:h3 {:style {"color" "var(--fg-1)" "margin" "2rem 0 1rem"}} "Disabled States"]
|
||||
(disabled-row)])
|
||||
(button-demo)
|
||||
(alert-demo)
|
||||
(badge-demo)
|
||||
(card-demo)
|
||||
(accordion-demo)
|
||||
(table-demo)
|
||||
(dialog-demo)
|
||||
(spinner-demo)
|
||||
(skeleton-demo)
|
||||
(progress-demo)
|
||||
(switch-demo)
|
||||
(tooltip-demo)
|
||||
(breadcrumb-demo)
|
||||
(pagination-demo)])
|
||||
|
||||
(defn init! []
|
||||
(eu/render (app) (js/document.getElementById "app")))
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
:fg-on-danger "#ffffff"
|
||||
:success "#16a34a"
|
||||
:fg-on-success "#ffffff"
|
||||
:warning "#d97706"
|
||||
:fg-on-warning "#ffffff"
|
||||
:border-0 "1px solid #e0e0e0"
|
||||
:border-1 "1px solid #cccccc"
|
||||
:border-2 "1px solid #999999"
|
||||
@@ -48,6 +50,8 @@
|
||||
:fg-on-danger "#ffffff"
|
||||
:success "#22c55e"
|
||||
:fg-on-success "#ffffff"
|
||||
:warning "#f59e0b"
|
||||
:fg-on-warning "#ffffff"
|
||||
:border-0 "1px solid #2a2a2a"
|
||||
:border-1 "1px solid #3a3a3a"
|
||||
:border-2 "1px solid #555555"
|
||||
|
||||
52
src/ui/accordion.cljc
Normal file
52
src/ui/accordion.cljc
Normal 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
62
src/ui/accordion.css
Normal 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
54
src/ui/alert.cljc
Normal 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
43
src/ui/alert.css
Normal 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
48
src/ui/badge.cljc
Normal 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
38
src/ui/badge.css
Normal 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
57
src/ui/breadcrumb.cljc
Normal 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
35
src/ui/breadcrumb.css
Normal 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
67
src/ui/card.cljc
Normal 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
43
src/ui/card.css
Normal 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
78
src/ui/dialog.cljc
Normal 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
59
src/ui/dialog.css
Normal 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
194
src/ui/form.css
Normal 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
51
src/ui/grid.css
Normal 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
70
src/ui/pagination.cljc
Normal 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
41
src/ui/pagination.css
Normal 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
52
src/ui/progress.cljc
Normal 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
26
src/ui/progress.css
Normal 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
44
src/ui/skeleton.cljc
Normal 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
42
src/ui/skeleton.css
vendored
Normal 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
48
src/ui/spinner.cljc
Normal 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
49
src/ui/spinner.css
Normal 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
65
src/ui/switch.cljc
Normal 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
52
src/ui/switch.css
Normal 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
76
src/ui/table.cljc
Normal 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
54
src/ui/table.css
Normal 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
25
src/ui/tooltip.cljc
Normal 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
43
src/ui/tooltip.css
Normal 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
51
src/ui/utilities.css
Normal 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;
|
||||
}
|
||||
31
test/ui/accordion_test.clj
Normal file
31
test/ui/accordion_test.clj
Normal 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
35
test/ui/alert_test.clj
Normal 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
31
test/ui/badge_test.clj
Normal 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]))))))
|
||||
37
test/ui/breadcrumb_test.clj
Normal file
37
test/ui/breadcrumb_test.clj
Normal 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
36
test/ui/card_test.clj
Normal 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
34
test/ui/dialog_test.clj
Normal 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]))))))
|
||||
30
test/ui/pagination_test.clj
Normal file
30
test/ui/pagination_test.clj
Normal 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
24
test/ui/progress_test.clj
Normal 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
20
test/ui/skeleton_test.clj
Normal 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
21
test/ui/spinner_test.clj
Normal 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
27
test/ui/switch_test.clj
Normal 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
31
test/ui/table_test.clj
Normal 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
15
test/ui/tooltip_test.clj
Normal 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]))))))
|
||||
Reference in New Issue
Block a user