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"
|
{:doc "Run all unit tests"
|
||||||
:requires ([clojure.test :as t]
|
:requires ([clojure.test :as t]
|
||||||
[ui.button-test]
|
[ui.button-test]
|
||||||
|
[ui.alert-test]
|
||||||
|
[ui.badge-test]
|
||||||
|
[ui.card-test]
|
||||||
|
[ui.accordion-test]
|
||||||
|
[ui.table-test]
|
||||||
|
[ui.dialog-test]
|
||||||
|
[ui.spinner-test]
|
||||||
|
[ui.skeleton-test]
|
||||||
|
[ui.progress-test]
|
||||||
|
[ui.switch-test]
|
||||||
|
[ui.tooltip-test]
|
||||||
|
[ui.breadcrumb-test]
|
||||||
|
[ui.pagination-test]
|
||||||
[ui.theme-test])
|
[ui.theme-test])
|
||||||
:task (let [{:keys [fail error]} (t/run-tests 'ui.button-test 'ui.theme-test)]
|
:task (let [{:keys [fail error]} (t/run-tests
|
||||||
|
'ui.button-test
|
||||||
|
'ui.alert-test
|
||||||
|
'ui.badge-test
|
||||||
|
'ui.card-test
|
||||||
|
'ui.accordion-test
|
||||||
|
'ui.table-test
|
||||||
|
'ui.dialog-test
|
||||||
|
'ui.spinner-test
|
||||||
|
'ui.skeleton-test
|
||||||
|
'ui.progress-test
|
||||||
|
'ui.switch-test
|
||||||
|
'ui.tooltip-test
|
||||||
|
'ui.breadcrumb-test
|
||||||
|
'ui.pagination-test
|
||||||
|
'ui.theme-test)]
|
||||||
(when (pos? (+ fail error))
|
(when (pos? (+ fail error))
|
||||||
(System/exit 1)))}
|
(System/exit 1)))}
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,170 @@
|
|||||||
(ns dev.hiccup
|
(ns dev.hiccup
|
||||||
(:require [org.httpkit.server :as http]
|
(:require [org.httpkit.server :as http]
|
||||||
[hiccup2.core :as h]
|
[hiccup2.core :as h]
|
||||||
[ui.button :as button]))
|
[ui.button :as button]
|
||||||
|
[ui.alert :as alert]
|
||||||
|
[ui.badge :as badge]
|
||||||
|
[ui.card :as card]
|
||||||
|
[ui.accordion :as accordion]
|
||||||
|
[ui.table :as table]
|
||||||
|
[ui.dialog :as dialog]
|
||||||
|
[ui.spinner :as spinner]
|
||||||
|
[ui.skeleton :as skeleton]
|
||||||
|
[ui.progress :as progress]
|
||||||
|
[ui.switch :as switch]
|
||||||
|
[ui.tooltip :as tooltip]
|
||||||
|
[ui.breadcrumb :as breadcrumb]
|
||||||
|
[ui.pagination :as pagination]))
|
||||||
|
|
||||||
(def variants [:primary :secondary :ghost :danger])
|
(defn section [title & children]
|
||||||
(def sizes [:sm :md :lg])
|
[:section {:style "margin-bottom: 2.5rem;"}
|
||||||
|
[:h3 {:style "color: var(--fg-1); margin-bottom: 1rem; border-bottom: var(--border-0); padding-bottom: 0.5rem;"} title]
|
||||||
|
(into [:div {:style "display: flex; flex-direction: column; gap: 1rem;"}] children)])
|
||||||
|
|
||||||
(defn button-grid []
|
;; ── Button ──────────────────────────────────────────────────────────
|
||||||
[:div {:style "display: grid; grid-template-columns: repeat(4, auto); gap: 1rem; align-items: center;"}
|
(def button-variants [:primary :secondary :ghost :danger])
|
||||||
;; Header row
|
(def button-sizes [:sm :md :lg])
|
||||||
[:div]
|
|
||||||
(for [size sizes]
|
|
||||||
[:div {:style "font-weight: 600; text-align: center; color: var(--fg-1); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em;"}
|
|
||||||
(name size)])
|
|
||||||
|
|
||||||
;; Variant rows
|
(defn button-demo []
|
||||||
(for [variant variants]
|
(section "Button"
|
||||||
(list
|
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
|
||||||
[:div {:style "font-weight: 600; color: var(--fg-1); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em;"}
|
(for [v button-variants]
|
||||||
(name variant)]
|
(button/button {:variant v} (name v)))]
|
||||||
(for [size sizes]
|
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
|
||||||
[:div {:style "text-align: center;"}
|
(for [s button-sizes]
|
||||||
(button/button {:variant variant :size size}
|
(button/button {:variant :primary :size s} (str "size " (name s))))]
|
||||||
(str (name variant) " " (name size)))])))])
|
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"}
|
||||||
|
(for [v button-variants]
|
||||||
|
(button/button {:variant v :disabled true} (str (name v) " disabled")))]))
|
||||||
|
|
||||||
(defn disabled-row []
|
;; ── Alert ───────────────────────────────────────────────────────────
|
||||||
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap;"}
|
(defn alert-demo []
|
||||||
(for [variant variants]
|
(section "Alert"
|
||||||
(button/button {:variant variant :disabled true}
|
(alert/alert {:variant :success :title "Success!"} "Your changes have been saved.")
|
||||||
(str (name variant) " disabled")))])
|
(alert/alert {:variant :warning :title "Warning!"} "Please review before continuing.")
|
||||||
|
(alert/alert {:variant :danger :title "Error!"} "Something went wrong.")
|
||||||
|
(alert/alert {:variant :info :title "Info"} "This is an informational alert.")
|
||||||
|
(alert/alert {:title "Neutral"} "A neutral alert with no variant.")))
|
||||||
|
|
||||||
|
;; ── Badge ───────────────────────────────────────────────────────────
|
||||||
|
(defn badge-demo []
|
||||||
|
(section "Badge"
|
||||||
|
[:div {:style "display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;"}
|
||||||
|
(badge/badge {} "Default")
|
||||||
|
(badge/badge {:variant :secondary} "Secondary")
|
||||||
|
(badge/badge {:variant :outline} "Outline")
|
||||||
|
(badge/badge {:variant :success} "Success")
|
||||||
|
(badge/badge {:variant :warning} "Warning")
|
||||||
|
(badge/badge {:variant :danger} "Danger")]))
|
||||||
|
|
||||||
|
;; ── Card ────────────────────────────────────────────────────────────
|
||||||
|
(defn card-demo []
|
||||||
|
(section "Card"
|
||||||
|
(card/card {}
|
||||||
|
(card/card-header {} [:h4 "Card Title"] [:p "Card description goes here."])
|
||||||
|
(card/card-body {} [:p "This is the card content. It can contain any HTML."])
|
||||||
|
(card/card-footer {}
|
||||||
|
(button/button {:variant :secondary :size :sm} "Cancel")
|
||||||
|
(button/button {:variant :primary :size :sm} "Save")))))
|
||||||
|
|
||||||
|
;; ── Accordion ───────────────────────────────────────────────────────
|
||||||
|
(defn accordion-demo []
|
||||||
|
(section "Accordion"
|
||||||
|
(accordion/accordion {:title "What is this framework?"} "A cross-target component library for Clojure, ClojureScript, and Squint.")
|
||||||
|
(accordion/accordion {:title "How do I use it?" :open true} "Just require the namespace and call the component functions.")
|
||||||
|
(accordion/accordion {:title "Is it accessible?"} "Yes, components follow ARIA best practices.")))
|
||||||
|
|
||||||
|
;; ── Table ───────────────────────────────────────────────────────────
|
||||||
|
(defn table-demo []
|
||||||
|
(section "Table"
|
||||||
|
(table/table {:headers ["Name" "Email" "Role" "Status"]
|
||||||
|
:rows [["Alice Johnson" "alice@example.com" "Admin" "Active"]
|
||||||
|
["Bob Smith" "bob@example.com" "Editor" "Active"]
|
||||||
|
["Carol White" "carol@example.com" "Viewer" "Pending"]]})))
|
||||||
|
|
||||||
|
;; ── Dialog ──────────────────────────────────────────────────────────
|
||||||
|
(defn dialog-demo []
|
||||||
|
(section "Dialog"
|
||||||
|
[:p {:style "color: var(--fg-2); font-size: var(--font-sm);"} "Click button to open dialog."]
|
||||||
|
(button/button {:variant :primary
|
||||||
|
:attrs {:onclick "document.getElementById('demo-dialog').showModal()"}}
|
||||||
|
"Open dialog")
|
||||||
|
(dialog/dialog {:id "demo-dialog"}
|
||||||
|
(dialog/dialog-header {} [:h3 "Dialog Title"] [:p "Are you sure you want to continue?"])
|
||||||
|
(dialog/dialog-body {} [:p "This action cannot be undone."])
|
||||||
|
(dialog/dialog-footer {}
|
||||||
|
(button/button {:variant :secondary :size :sm
|
||||||
|
:attrs {:onclick "document.getElementById('demo-dialog').close()"}}
|
||||||
|
"Cancel")
|
||||||
|
(button/button {:variant :primary :size :sm
|
||||||
|
:attrs {:onclick "document.getElementById('demo-dialog').close()"}}
|
||||||
|
"Confirm")))))
|
||||||
|
|
||||||
|
;; ── Spinner ─────────────────────────────────────────────────────────
|
||||||
|
(defn spinner-demo []
|
||||||
|
(section "Spinner"
|
||||||
|
[:div {:style "display: flex; gap: 1.5rem; align-items: center;"}
|
||||||
|
(spinner/spinner {:size :sm})
|
||||||
|
(spinner/spinner {})
|
||||||
|
(spinner/spinner {:size :lg})]))
|
||||||
|
|
||||||
|
;; ── Skeleton ────────────────────────────────────────────────────────
|
||||||
|
(defn skeleton-demo []
|
||||||
|
(section "Skeleton"
|
||||||
|
[:div {:style "max-width: 400px;"}
|
||||||
|
(skeleton/skeleton {:variant :heading})
|
||||||
|
(skeleton/skeleton {:variant :line})
|
||||||
|
(skeleton/skeleton {:variant :line})
|
||||||
|
[:div {:style "display: flex; gap: 1rem; margin-top: var(--size-3);"}
|
||||||
|
(skeleton/skeleton {:variant :circle})
|
||||||
|
[:div {:style "flex: 1;"}
|
||||||
|
(skeleton/skeleton {:variant :line})
|
||||||
|
(skeleton/skeleton {:variant :line})]]]))
|
||||||
|
|
||||||
|
;; ── Progress ────────────────────────────────────────────────────────
|
||||||
|
(defn progress-demo []
|
||||||
|
(section "Progress"
|
||||||
|
(progress/progress {:value 25})
|
||||||
|
(progress/progress {:value 50 :variant :success})
|
||||||
|
(progress/progress {:value 75 :variant :warning})
|
||||||
|
(progress/progress {:value 90 :variant :danger})))
|
||||||
|
|
||||||
|
;; ── Switch ──────────────────────────────────────────────────────────
|
||||||
|
(defn switch-demo []
|
||||||
|
(section "Switch"
|
||||||
|
[:div {:style "display: flex; flex-direction: column; gap: 0.75rem;"}
|
||||||
|
(switch/switch-toggle {:label "Notifications" :checked false})
|
||||||
|
(switch/switch-toggle {:label "Dark mode" :checked true})
|
||||||
|
(switch/switch-toggle {:label "Disabled off" :disabled true})
|
||||||
|
(switch/switch-toggle {:label "Disabled on" :checked true :disabled true})]))
|
||||||
|
|
||||||
|
;; ── Tooltip ─────────────────────────────────────────────────────────
|
||||||
|
(defn tooltip-demo []
|
||||||
|
(section "Tooltip"
|
||||||
|
[:div {:style "display: flex; gap: 1.5rem; padding-top: 2rem;"}
|
||||||
|
(tooltip/tooltip {:text "Save your changes"}
|
||||||
|
(button/button {:variant :primary} "Save"))
|
||||||
|
(tooltip/tooltip {:text "Delete this item"}
|
||||||
|
(button/button {:variant :danger} "Delete"))
|
||||||
|
(tooltip/tooltip {:text "View profile"}
|
||||||
|
[:a {:href "#" :style "color: var(--accent);"} "Profile"])]))
|
||||||
|
|
||||||
|
;; ── Breadcrumb ──────────────────────────────────────────────────────
|
||||||
|
(defn breadcrumb-demo []
|
||||||
|
(section "Breadcrumb"
|
||||||
|
(breadcrumb/breadcrumb
|
||||||
|
{:items [{:label "Home" :href "#"}
|
||||||
|
{:label "Projects" :href "#"}
|
||||||
|
{:label "Oat Docs" :href "#"}
|
||||||
|
{:label "Components"}]})))
|
||||||
|
|
||||||
|
;; ── Pagination ──────────────────────────────────────────────────────
|
||||||
|
(defn pagination-demo []
|
||||||
|
(section "Pagination"
|
||||||
|
(pagination/pagination {:current 3 :total 5
|
||||||
|
:href-fn (fn [p] (str "#page-" p))})))
|
||||||
|
|
||||||
|
;; ── Page ────────────────────────────────────────────────────────────
|
||||||
(defn page []
|
(defn page []
|
||||||
(str
|
(str
|
||||||
"<!DOCTYPE html>\n"
|
"<!DOCTYPE html>\n"
|
||||||
@@ -47,10 +182,20 @@
|
|||||||
[:button {:onclick "document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'"
|
[:button {:onclick "document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'"
|
||||||
:style "padding: 0.5rem 1rem; cursor: pointer; border-radius: var(--radius-md); border: var(--border-0); background: var(--bg-1); color: var(--fg-0);"}
|
:style "padding: 0.5rem 1rem; cursor: pointer; border-radius: var(--radius-md); border: var(--border-0); background: var(--bg-1); color: var(--fg-0);"}
|
||||||
"Toggle Dark Mode"]]
|
"Toggle Dark Mode"]]
|
||||||
[:h3 {:style "color: var(--fg-1); margin-bottom: 1rem;"} "Button Grid"]
|
(button-demo)
|
||||||
(button-grid)
|
(alert-demo)
|
||||||
[:h3 {:style "color: var(--fg-1); margin: 2rem 0 1rem;"} "Disabled States"]
|
(badge-demo)
|
||||||
(disabled-row)]]])))
|
(card-demo)
|
||||||
|
(accordion-demo)
|
||||||
|
(table-demo)
|
||||||
|
(dialog-demo)
|
||||||
|
(spinner-demo)
|
||||||
|
(skeleton-demo)
|
||||||
|
(progress-demo)
|
||||||
|
(switch-demo)
|
||||||
|
(tooltip-demo)
|
||||||
|
(breadcrumb-demo)
|
||||||
|
(pagination-demo)]]])))
|
||||||
|
|
||||||
(defn handler [{:keys [uri]}]
|
(defn handler [{:keys [uri]}]
|
||||||
(case uri
|
(case uri
|
||||||
|
|||||||
@@ -1,43 +1,180 @@
|
|||||||
(ns dev.replicant
|
(ns dev.replicant
|
||||||
(:require [replicant.dom :as d]
|
(:require [replicant.dom :as d]
|
||||||
[ui.button :as button]))
|
[ui.button :as button]
|
||||||
|
[ui.alert :as alert]
|
||||||
|
[ui.badge :as badge]
|
||||||
|
[ui.card :as card]
|
||||||
|
[ui.accordion :as accordion]
|
||||||
|
[ui.table :as table]
|
||||||
|
[ui.dialog :as dialog]
|
||||||
|
[ui.spinner :as spinner]
|
||||||
|
[ui.skeleton :as skeleton]
|
||||||
|
[ui.progress :as progress]
|
||||||
|
[ui.switch :as switch]
|
||||||
|
[ui.tooltip :as tooltip]
|
||||||
|
[ui.breadcrumb :as breadcrumb]
|
||||||
|
[ui.pagination :as pagination]))
|
||||||
|
|
||||||
(def variants [:primary :secondary :ghost :danger])
|
(defn section [title & children]
|
||||||
(def sizes [:sm :md :lg])
|
[:section {:style {:margin-bottom "2.5rem"}}
|
||||||
|
[:h3 {:style {:color "var(--fg-1)" :margin-bottom "1rem"
|
||||||
|
:border-bottom "var(--border-0)" :padding-bottom "0.5rem"}} title]
|
||||||
|
(into [:div {:style {:display "flex" :flex-direction "column" :gap "1rem"}}] children)])
|
||||||
|
|
||||||
(defn button-grid []
|
;; ── Button ──────────────────────────────────────────────────────────
|
||||||
[:div {:style {:display "grid"
|
(def button-variants [:primary :secondary :ghost :danger])
|
||||||
:grid-template-columns "repeat(4, auto)"
|
(def button-sizes [:sm :md :lg])
|
||||||
:gap "1rem"
|
|
||||||
:align-items "center"}}
|
|
||||||
[:div]
|
|
||||||
(for [size sizes]
|
|
||||||
[:div {:style {:font-weight "600" :text-align "center" :color "var(--fg-1)"
|
|
||||||
:font-size "0.75rem" :text-transform "uppercase" :letter-spacing "0.05em"}}
|
|
||||||
(name size)])
|
|
||||||
(for [variant variants]
|
|
||||||
(list
|
|
||||||
[:div {:style {:font-weight "600" :color "var(--fg-1)"
|
|
||||||
:font-size "0.75rem" :text-transform "uppercase" :letter-spacing "0.05em"}}
|
|
||||||
(name variant)]
|
|
||||||
(for [size sizes]
|
|
||||||
[:div {:style {:text-align "center"}}
|
|
||||||
(button/button {:variant variant :size size
|
|
||||||
:on-click (fn [_] (js/console.log (str "Clicked: " (name variant) " " (name size))))}
|
|
||||||
(str (name variant) " " (name size)))])))])
|
|
||||||
|
|
||||||
(defn disabled-row []
|
(defn button-demo []
|
||||||
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap"}}
|
(section "Button"
|
||||||
(for [variant variants]
|
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
|
||||||
(button/button {:variant variant :disabled true}
|
(for [v button-variants]
|
||||||
(str (name variant) " disabled")))])
|
(button/button {:variant v :on-click (fn [_] (js/console.log (str "Clicked: " (name v))))}
|
||||||
|
(name v)))]
|
||||||
|
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
|
||||||
|
(for [s button-sizes]
|
||||||
|
(button/button {:variant :primary :size s} (str "size " (name s))))]
|
||||||
|
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}}
|
||||||
|
(for [v button-variants]
|
||||||
|
(button/button {:variant v :disabled true} (str (name v) " disabled")))]))
|
||||||
|
|
||||||
|
;; ── Alert ───────────────────────────────────────────────────────────
|
||||||
|
(defn alert-demo []
|
||||||
|
(section "Alert"
|
||||||
|
(alert/alert {:variant :success :title "Success!"} "Your changes have been saved.")
|
||||||
|
(alert/alert {:variant :warning :title "Warning!"} "Please review before continuing.")
|
||||||
|
(alert/alert {:variant :danger :title "Error!"} "Something went wrong.")
|
||||||
|
(alert/alert {:variant :info :title "Info"} "This is an informational alert.")
|
||||||
|
(alert/alert {:title "Neutral"} "A neutral alert with no variant.")))
|
||||||
|
|
||||||
|
;; ── Badge ───────────────────────────────────────────────────────────
|
||||||
|
(defn badge-demo []
|
||||||
|
(section "Badge"
|
||||||
|
[:div {:style {:display "flex" :gap "0.5rem" :flex-wrap "wrap" :align-items "center"}}
|
||||||
|
(badge/badge {} "Default")
|
||||||
|
(badge/badge {:variant :secondary} "Secondary")
|
||||||
|
(badge/badge {:variant :outline} "Outline")
|
||||||
|
(badge/badge {:variant :success} "Success")
|
||||||
|
(badge/badge {:variant :warning} "Warning")
|
||||||
|
(badge/badge {:variant :danger} "Danger")]))
|
||||||
|
|
||||||
|
;; ── Card ────────────────────────────────────────────────────────────
|
||||||
|
(defn card-demo []
|
||||||
|
(section "Card"
|
||||||
|
(card/card {}
|
||||||
|
(card/card-header {} [:h4 "Card Title"] [:p "Card description goes here."])
|
||||||
|
(card/card-body {} [:p "This is the card content. It can contain any HTML."])
|
||||||
|
(card/card-footer {}
|
||||||
|
(button/button {:variant :secondary :size :sm} "Cancel")
|
||||||
|
(button/button {:variant :primary :size :sm} "Save")))))
|
||||||
|
|
||||||
|
;; ── Accordion ───────────────────────────────────────────────────────
|
||||||
|
(defn accordion-demo []
|
||||||
|
(section "Accordion"
|
||||||
|
(accordion/accordion {:title "What is this framework?"} "A cross-target component library.")
|
||||||
|
(accordion/accordion {:title "How do I use it?" :open true} "Just require the namespace and call functions.")
|
||||||
|
(accordion/accordion {:title "Is it accessible?"} "Yes, follows ARIA best practices.")))
|
||||||
|
|
||||||
|
;; ── Table ───────────────────────────────────────────────────────────
|
||||||
|
(defn table-demo []
|
||||||
|
(section "Table"
|
||||||
|
(table/table {:headers ["Name" "Email" "Role" "Status"]
|
||||||
|
:rows [["Alice Johnson" "alice@example.com" "Admin" "Active"]
|
||||||
|
["Bob Smith" "bob@example.com" "Editor" "Active"]
|
||||||
|
["Carol White" "carol@example.com" "Viewer" "Pending"]]})))
|
||||||
|
|
||||||
|
;; ── Dialog ──────────────────────────────────────────────────────────
|
||||||
|
(defn dialog-demo []
|
||||||
|
(section "Dialog"
|
||||||
|
[:p {:style {:color "var(--fg-2)" :font-size "var(--font-sm)"}} "Click button to open dialog."]
|
||||||
|
(button/button {:variant :primary
|
||||||
|
:on-click (fn [_]
|
||||||
|
(when-let [el (.getElementById js/document "demo-dialog")]
|
||||||
|
(.showModal el)))}
|
||||||
|
"Open dialog")
|
||||||
|
(dialog/dialog {:id "demo-dialog"}
|
||||||
|
(dialog/dialog-header {} [:h3 "Dialog Title"] [:p "Are you sure you want to continue?"])
|
||||||
|
(dialog/dialog-body {} [:p "This action cannot be undone."])
|
||||||
|
(dialog/dialog-footer {}
|
||||||
|
(button/button {:variant :secondary :size :sm
|
||||||
|
:on-click (fn [_] (.close (.getElementById js/document "demo-dialog")))}
|
||||||
|
"Cancel")
|
||||||
|
(button/button {:variant :primary :size :sm
|
||||||
|
:on-click (fn [_] (.close (.getElementById js/document "demo-dialog")))}
|
||||||
|
"Confirm")))))
|
||||||
|
|
||||||
|
;; ── Spinner ─────────────────────────────────────────────────────────
|
||||||
|
(defn spinner-demo []
|
||||||
|
(section "Spinner"
|
||||||
|
[:div {:style {:display "flex" :gap "1.5rem" :align-items "center"}}
|
||||||
|
(spinner/spinner {:size :sm})
|
||||||
|
(spinner/spinner {})
|
||||||
|
(spinner/spinner {:size :lg})]))
|
||||||
|
|
||||||
|
;; ── Skeleton ────────────────────────────────────────────────────────
|
||||||
|
(defn skeleton-demo []
|
||||||
|
(section "Skeleton"
|
||||||
|
[:div {:style {:max-width "400px"}}
|
||||||
|
(skeleton/skeleton {:variant :heading})
|
||||||
|
(skeleton/skeleton {:variant :line})
|
||||||
|
(skeleton/skeleton {:variant :line})
|
||||||
|
[:div {:style {:display "flex" :gap "1rem" :margin-top "var(--size-3)"}}
|
||||||
|
(skeleton/skeleton {:variant :circle})
|
||||||
|
[:div {:style {:flex "1"}}
|
||||||
|
(skeleton/skeleton {:variant :line})
|
||||||
|
(skeleton/skeleton {:variant :line})]]]))
|
||||||
|
|
||||||
|
;; ── Progress ────────────────────────────────────────────────────────
|
||||||
|
(defn progress-demo []
|
||||||
|
(section "Progress"
|
||||||
|
(progress/progress {:value 25})
|
||||||
|
(progress/progress {:value 50 :variant :success})
|
||||||
|
(progress/progress {:value 75 :variant :warning})
|
||||||
|
(progress/progress {:value 90 :variant :danger})))
|
||||||
|
|
||||||
|
;; ── Switch ──────────────────────────────────────────────────────────
|
||||||
|
(defn switch-demo []
|
||||||
|
(section "Switch"
|
||||||
|
[:div {:style {:display "flex" :flex-direction "column" :gap "0.75rem"}}
|
||||||
|
(switch/switch-toggle {:label "Notifications" :checked false})
|
||||||
|
(switch/switch-toggle {:label "Dark mode" :checked true})
|
||||||
|
(switch/switch-toggle {:label "Disabled off" :disabled true})
|
||||||
|
(switch/switch-toggle {:label "Disabled on" :checked true :disabled true})]))
|
||||||
|
|
||||||
|
;; ── Tooltip ─────────────────────────────────────────────────────────
|
||||||
|
(defn tooltip-demo []
|
||||||
|
(section "Tooltip"
|
||||||
|
[:div {:style {:display "flex" :gap "1.5rem" :padding-top "2rem"}}
|
||||||
|
(tooltip/tooltip {:text "Save your changes"}
|
||||||
|
(button/button {:variant :primary} "Save"))
|
||||||
|
(tooltip/tooltip {:text "Delete this item"}
|
||||||
|
(button/button {:variant :danger} "Delete"))
|
||||||
|
(tooltip/tooltip {:text "View profile"}
|
||||||
|
[:a {:href "#" :style {:color "var(--accent)"}} "Profile"])]))
|
||||||
|
|
||||||
|
;; ── Breadcrumb ──────────────────────────────────────────────────────
|
||||||
|
(defn breadcrumb-demo []
|
||||||
|
(section "Breadcrumb"
|
||||||
|
(breadcrumb/breadcrumb
|
||||||
|
{:items [{:label "Home" :href "#"}
|
||||||
|
{:label "Projects" :href "#"}
|
||||||
|
{:label "Oat Docs" :href "#"}
|
||||||
|
{:label "Components"}]})))
|
||||||
|
|
||||||
|
;; ── Pagination ──────────────────────────────────────────────────────
|
||||||
|
(defn pagination-demo []
|
||||||
|
(section "Pagination"
|
||||||
|
(pagination/pagination {:current 3 :total 5
|
||||||
|
:on-click (fn [p] (js/console.log (str "Page: " p)))})))
|
||||||
|
|
||||||
|
;; ── Theme toggle ────────────────────────────────────────────────────
|
||||||
(defn toggle-theme! [_e]
|
(defn toggle-theme! [_e]
|
||||||
(let [el (.-documentElement js/document)
|
(let [el (.-documentElement js/document)
|
||||||
current (.. el -dataset -theme)]
|
current (.. el -dataset -theme)]
|
||||||
(set! (.. el -dataset -theme)
|
(set! (.. el -dataset -theme)
|
||||||
(if (= current "dark") "light" "dark"))))
|
(if (= current "dark") "light" "dark"))))
|
||||||
|
|
||||||
|
;; ── App ─────────────────────────────────────────────────────────────
|
||||||
(defn app []
|
(defn app []
|
||||||
[:div {:style {:max-width "800px" :margin "0 auto"}}
|
[:div {:style {:max-width "800px" :margin "0 auto"}}
|
||||||
[:div {:style {:display "flex" :justify-content "space-between" :align-items "center" :margin-bottom "2rem"}}
|
[:div {:style {:display "flex" :justify-content "space-between" :align-items "center" :margin-bottom "2rem"}}
|
||||||
@@ -46,10 +183,20 @@
|
|||||||
:style {:padding "0.5rem 1rem" :cursor "pointer" :border-radius "var(--radius-md)"
|
:style {:padding "0.5rem 1rem" :cursor "pointer" :border-radius "var(--radius-md)"
|
||||||
:border "var(--border-0)" :background "var(--bg-1)" :color "var(--fg-0)"}}
|
:border "var(--border-0)" :background "var(--bg-1)" :color "var(--fg-0)"}}
|
||||||
"Toggle Dark Mode"]]
|
"Toggle Dark Mode"]]
|
||||||
[:h3 {:style {:color "var(--fg-1)" :margin-bottom "1rem"}} "Button Grid"]
|
(button-demo)
|
||||||
(button-grid)
|
(alert-demo)
|
||||||
[:h3 {:style {:color "var(--fg-1)" :margin "2rem 0 1rem"}} "Disabled States"]
|
(badge-demo)
|
||||||
(disabled-row)])
|
(card-demo)
|
||||||
|
(accordion-demo)
|
||||||
|
(table-demo)
|
||||||
|
(dialog-demo)
|
||||||
|
(spinner-demo)
|
||||||
|
(skeleton-demo)
|
||||||
|
(progress-demo)
|
||||||
|
(switch-demo)
|
||||||
|
(tooltip-demo)
|
||||||
|
(breadcrumb-demo)
|
||||||
|
(pagination-demo)])
|
||||||
|
|
||||||
(defn ^:export init! []
|
(defn ^:export init! []
|
||||||
(d/set-dispatch! (fn [_ _]))
|
(d/set-dispatch! (fn [_ _]))
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
(ns dev.squint
|
(ns dev.squint
|
||||||
(:require ["eucalypt" :as eu]
|
(:require ["eucalypt" :as eu]
|
||||||
[ui.button :as button]))
|
[ui.button :as button]
|
||||||
|
[ui.alert :as alert]
|
||||||
(def variants ["primary" "secondary" "ghost" "danger"])
|
[ui.badge :as badge]
|
||||||
(def sizes ["sm" "md" "lg"])
|
[ui.card :as card]
|
||||||
|
[ui.accordion :as accordion]
|
||||||
|
[ui.table :as table]
|
||||||
|
[ui.dialog :as dialog]
|
||||||
|
[ui.spinner :as spinner]
|
||||||
|
[ui.skeleton :as skeleton]
|
||||||
|
[ui.progress :as progress]
|
||||||
|
[ui.switch :as switch]
|
||||||
|
[ui.tooltip :as tooltip]
|
||||||
|
[ui.breadcrumb :as breadcrumb]
|
||||||
|
[ui.pagination :as pagination]))
|
||||||
|
|
||||||
(defn toggle-theme! [_e]
|
(defn toggle-theme! [_e]
|
||||||
(let [el (.-documentElement js/document)
|
(let [el (.-documentElement js/document)
|
||||||
@@ -11,46 +21,161 @@
|
|||||||
(set! (.. el -dataset -theme)
|
(set! (.. el -dataset -theme)
|
||||||
(if (= current "dark") "light" "dark"))))
|
(if (= current "dark") "light" "dark"))))
|
||||||
|
|
||||||
(def label-style {"font-weight" "600"
|
(defn section [title & children]
|
||||||
"color" "var(--fg-1)"
|
(into [:section {:style {"margin-bottom" "2.5rem"}}
|
||||||
"font-size" "0.75rem"
|
[:h3 {:style {"color" "var(--fg-1)" "margin-bottom" "1rem"
|
||||||
"text-transform" "uppercase"
|
"border-bottom" "var(--border-0)" "padding-bottom" "0.5rem"}} title]]
|
||||||
"letter-spacing" "0.05em"})
|
children))
|
||||||
|
|
||||||
(defn button-grid []
|
;; ── Button ──────────────────────────────────────────────────────────
|
||||||
(into
|
(def button-variants ["primary" "secondary" "ghost" "danger"])
|
||||||
[:div {:style {"display" "grid"
|
(def button-sizes ["sm" "md" "lg"])
|
||||||
"grid-template-columns" "repeat(4, auto)"
|
|
||||||
"gap" "1rem"
|
|
||||||
"align-items" "center"}}
|
|
||||||
[:div]
|
|
||||||
[:div {:style (merge label-style {"text-align" "center"})} "sm"]
|
|
||||||
[:div {:style (merge label-style {"text-align" "center"})} "md"]
|
|
||||||
[:div {:style (merge label-style {"text-align" "center"})} "lg"]]
|
|
||||||
(mapcat (fn [variant]
|
|
||||||
[[:div {:style label-style} variant]
|
|
||||||
[:div {:style {"text-align" "center"}}
|
|
||||||
(button/button {:variant variant :size "sm"
|
|
||||||
:on-click (fn [_] (js/console.log (str "Clicked: " variant " sm")))}
|
|
||||||
(str variant " sm"))]
|
|
||||||
[:div {:style {"text-align" "center"}}
|
|
||||||
(button/button {:variant variant :size "md"
|
|
||||||
:on-click (fn [_] (js/console.log (str "Clicked: " variant " md")))}
|
|
||||||
(str variant " md"))]
|
|
||||||
[:div {:style {"text-align" "center"}}
|
|
||||||
(button/button {:variant variant :size "lg"
|
|
||||||
:on-click (fn [_] (js/console.log (str "Clicked: " variant " lg")))}
|
|
||||||
(str variant " lg"))]])
|
|
||||||
variants)))
|
|
||||||
|
|
||||||
(defn disabled-row []
|
(defn button-demo []
|
||||||
(into
|
(section "Button"
|
||||||
[:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap"}}]
|
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
|
||||||
(map (fn [variant]
|
(map (fn [v]
|
||||||
(button/button {:variant variant :disabled true}
|
(button/button {:variant v :on-click (fn [_] (js/console.log (str "Clicked: " v)))} v))
|
||||||
(str variant " disabled")))
|
button-variants))
|
||||||
variants)))
|
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
|
||||||
|
(map (fn [s]
|
||||||
|
(button/button {:variant "primary" :size s} (str "size " s)))
|
||||||
|
button-sizes))
|
||||||
|
(into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}]
|
||||||
|
(map (fn [v]
|
||||||
|
(button/button {:variant v :disabled true} (str v " disabled")))
|
||||||
|
button-variants))))
|
||||||
|
|
||||||
|
;; ── Alert ───────────────────────────────────────────────────────────
|
||||||
|
(defn alert-demo []
|
||||||
|
(section "Alert"
|
||||||
|
(alert/alert {:variant "success" :title "Success!"} "Your changes have been saved.")
|
||||||
|
(alert/alert {:variant "warning" :title "Warning!"} "Please review before continuing.")
|
||||||
|
(alert/alert {:variant "danger" :title "Error!"} "Something went wrong.")
|
||||||
|
(alert/alert {:variant "info" :title "Info"} "This is an informational alert.")
|
||||||
|
(alert/alert {:title "Neutral"} "A neutral alert with no variant.")))
|
||||||
|
|
||||||
|
;; ── Badge ───────────────────────────────────────────────────────────
|
||||||
|
(defn badge-demo []
|
||||||
|
(section "Badge"
|
||||||
|
(into [:div {:style {"display" "flex" "gap" "0.5rem" "flex-wrap" "wrap" "align-items" "center"}}]
|
||||||
|
[(badge/badge {} "Default")
|
||||||
|
(badge/badge {:variant "secondary"} "Secondary")
|
||||||
|
(badge/badge {:variant "outline"} "Outline")
|
||||||
|
(badge/badge {:variant "success"} "Success")
|
||||||
|
(badge/badge {:variant "warning"} "Warning")
|
||||||
|
(badge/badge {:variant "danger"} "Danger")])))
|
||||||
|
|
||||||
|
;; ── Card ────────────────────────────────────────────────────────────
|
||||||
|
(defn card-demo []
|
||||||
|
(section "Card"
|
||||||
|
(card/card {}
|
||||||
|
(card/card-header {} [:h4 "Card Title"] [:p "Card description goes here."])
|
||||||
|
(card/card-body {} [:p "This is the card content. It can contain any HTML."])
|
||||||
|
(card/card-footer {}
|
||||||
|
(button/button {:variant "secondary" :size "sm"} "Cancel")
|
||||||
|
(button/button {:variant "primary" :size "sm"} "Save")))))
|
||||||
|
|
||||||
|
;; ── Accordion ───────────────────────────────────────────────────────
|
||||||
|
(defn accordion-demo []
|
||||||
|
(section "Accordion"
|
||||||
|
(accordion/accordion {:title "What is this framework?"} "A cross-target component library.")
|
||||||
|
(accordion/accordion {:title "How do I use it?" :open true} "Just require the namespace and call functions.")
|
||||||
|
(accordion/accordion {:title "Is it accessible?"} "Yes, follows ARIA best practices.")))
|
||||||
|
|
||||||
|
;; ── Table ───────────────────────────────────────────────────────────
|
||||||
|
(defn table-demo []
|
||||||
|
(section "Table"
|
||||||
|
(table/table {:headers ["Name" "Email" "Role" "Status"]
|
||||||
|
:rows [["Alice Johnson" "alice@example.com" "Admin" "Active"]
|
||||||
|
["Bob Smith" "bob@example.com" "Editor" "Active"]
|
||||||
|
["Carol White" "carol@example.com" "Viewer" "Pending"]]})))
|
||||||
|
|
||||||
|
;; ── Dialog ──────────────────────────────────────────────────────────
|
||||||
|
(defn dialog-demo []
|
||||||
|
(section "Dialog"
|
||||||
|
[:p {:style {"color" "var(--fg-2)" "font-size" "var(--font-sm)"}} "Click button to open dialog."]
|
||||||
|
(button/button {:variant "primary"
|
||||||
|
:on-click (fn [_]
|
||||||
|
(when-let [el (js/document.getElementById "demo-dialog-sq")]
|
||||||
|
(.showModal el)))}
|
||||||
|
"Open dialog")
|
||||||
|
(dialog/dialog {:id "demo-dialog-sq"}
|
||||||
|
(dialog/dialog-header {} [:h3 "Dialog Title"] [:p "Are you sure you want to continue?"])
|
||||||
|
(dialog/dialog-body {} [:p "This action cannot be undone."])
|
||||||
|
(dialog/dialog-footer {}
|
||||||
|
(button/button {:variant "secondary" :size "sm"
|
||||||
|
:on-click (fn [_] (.close (js/document.getElementById "demo-dialog-sq")))}
|
||||||
|
"Cancel")
|
||||||
|
(button/button {:variant "primary" :size "sm"
|
||||||
|
:on-click (fn [_] (.close (js/document.getElementById "demo-dialog-sq")))}
|
||||||
|
"Confirm")))))
|
||||||
|
|
||||||
|
;; ── Spinner ─────────────────────────────────────────────────────────
|
||||||
|
(defn spinner-demo []
|
||||||
|
(section "Spinner"
|
||||||
|
[:div {:style {"display" "flex" "gap" "1.5rem" "align-items" "center"}}
|
||||||
|
(spinner/spinner {:size "sm"})
|
||||||
|
(spinner/spinner {})
|
||||||
|
(spinner/spinner {:size "lg"})]))
|
||||||
|
|
||||||
|
;; ── Skeleton ────────────────────────────────────────────────────────
|
||||||
|
(defn skeleton-demo []
|
||||||
|
(section "Skeleton"
|
||||||
|
[:div {:style {"max-width" "400px"}}
|
||||||
|
(skeleton/skeleton {:variant "heading"})
|
||||||
|
(skeleton/skeleton {:variant "line"})
|
||||||
|
(skeleton/skeleton {:variant "line"})
|
||||||
|
[:div {:style {"display" "flex" "gap" "1rem" "margin-top" "var(--size-3)"}}
|
||||||
|
(skeleton/skeleton {:variant "circle"})
|
||||||
|
[:div {:style {"flex" "1"}}
|
||||||
|
(skeleton/skeleton {:variant "line"})
|
||||||
|
(skeleton/skeleton {:variant "line"})]]]))
|
||||||
|
|
||||||
|
;; ── Progress ────────────────────────────────────────────────────────
|
||||||
|
(defn progress-demo []
|
||||||
|
(section "Progress"
|
||||||
|
(progress/progress {:value 25})
|
||||||
|
(progress/progress {:value 50 :variant "success"})
|
||||||
|
(progress/progress {:value 75 :variant "warning"})
|
||||||
|
(progress/progress {:value 90 :variant "danger"})))
|
||||||
|
|
||||||
|
;; ── Switch ──────────────────────────────────────────────────────────
|
||||||
|
(defn switch-demo []
|
||||||
|
(section "Switch"
|
||||||
|
[:div {:style {"display" "flex" "flex-direction" "column" "gap" "0.75rem"}}
|
||||||
|
(switch/switch-toggle {:label "Notifications" :checked false})
|
||||||
|
(switch/switch-toggle {:label "Dark mode" :checked true})
|
||||||
|
(switch/switch-toggle {:label "Disabled off" :disabled true})
|
||||||
|
(switch/switch-toggle {:label "Disabled on" :checked true :disabled true})]))
|
||||||
|
|
||||||
|
;; ── Tooltip ─────────────────────────────────────────────────────────
|
||||||
|
(defn tooltip-demo []
|
||||||
|
(section "Tooltip"
|
||||||
|
[:div {:style {"display" "flex" "gap" "1.5rem" "padding-top" "2rem"}}
|
||||||
|
(tooltip/tooltip {:text "Save your changes"}
|
||||||
|
(button/button {:variant "primary"} "Save"))
|
||||||
|
(tooltip/tooltip {:text "Delete this item"}
|
||||||
|
(button/button {:variant "danger"} "Delete"))
|
||||||
|
(tooltip/tooltip {:text "View profile"}
|
||||||
|
[:a {:href "#" :style {"color" "var(--accent)"}} "Profile"])]))
|
||||||
|
|
||||||
|
;; ── Breadcrumb ──────────────────────────────────────────────────────
|
||||||
|
(defn breadcrumb-demo []
|
||||||
|
(section "Breadcrumb"
|
||||||
|
(breadcrumb/breadcrumb
|
||||||
|
{:items [{:label "Home" :href "#"}
|
||||||
|
{:label "Projects" :href "#"}
|
||||||
|
{:label "Oat Docs" :href "#"}
|
||||||
|
{:label "Components"}]})))
|
||||||
|
|
||||||
|
;; ── Pagination ──────────────────────────────────────────────────────
|
||||||
|
(defn pagination-demo []
|
||||||
|
(section "Pagination"
|
||||||
|
(pagination/pagination {:current 3 :total 5
|
||||||
|
:on-click (fn [p] (js/console.log (str "Page: " p)))})))
|
||||||
|
|
||||||
|
;; ── App ─────────────────────────────────────────────────────────────
|
||||||
(defn app []
|
(defn app []
|
||||||
[:div {:style {"max-width" "800px" "margin" "0 auto"}}
|
[:div {:style {"max-width" "800px" "margin" "0 auto"}}
|
||||||
[:div {:style {"display" "flex" "justify-content" "space-between" "align-items" "center" "margin-bottom" "2rem"}}
|
[:div {:style {"display" "flex" "justify-content" "space-between" "align-items" "center" "margin-bottom" "2rem"}}
|
||||||
@@ -59,10 +184,20 @@
|
|||||||
:style {"padding" "0.5rem 1rem" "cursor" "pointer" "border-radius" "var(--radius-md)"
|
:style {"padding" "0.5rem 1rem" "cursor" "pointer" "border-radius" "var(--radius-md)"
|
||||||
"border" "var(--border-0)" "background" "var(--bg-1)" "color" "var(--fg-0)"}}
|
"border" "var(--border-0)" "background" "var(--bg-1)" "color" "var(--fg-0)"}}
|
||||||
"Toggle Dark Mode"]]
|
"Toggle Dark Mode"]]
|
||||||
[:h3 {:style {"color" "var(--fg-1)" "margin-bottom" "1rem"}} "Button Grid"]
|
(button-demo)
|
||||||
(button-grid)
|
(alert-demo)
|
||||||
[:h3 {:style {"color" "var(--fg-1)" "margin" "2rem 0 1rem"}} "Disabled States"]
|
(badge-demo)
|
||||||
(disabled-row)])
|
(card-demo)
|
||||||
|
(accordion-demo)
|
||||||
|
(table-demo)
|
||||||
|
(dialog-demo)
|
||||||
|
(spinner-demo)
|
||||||
|
(skeleton-demo)
|
||||||
|
(progress-demo)
|
||||||
|
(switch-demo)
|
||||||
|
(tooltip-demo)
|
||||||
|
(breadcrumb-demo)
|
||||||
|
(pagination-demo)])
|
||||||
|
|
||||||
(defn init! []
|
(defn init! []
|
||||||
(eu/render (app) (js/document.getElementById "app")))
|
(eu/render (app) (js/document.getElementById "app")))
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
:fg-on-danger "#ffffff"
|
:fg-on-danger "#ffffff"
|
||||||
:success "#16a34a"
|
:success "#16a34a"
|
||||||
:fg-on-success "#ffffff"
|
:fg-on-success "#ffffff"
|
||||||
|
:warning "#d97706"
|
||||||
|
:fg-on-warning "#ffffff"
|
||||||
:border-0 "1px solid #e0e0e0"
|
:border-0 "1px solid #e0e0e0"
|
||||||
:border-1 "1px solid #cccccc"
|
:border-1 "1px solid #cccccc"
|
||||||
:border-2 "1px solid #999999"
|
:border-2 "1px solid #999999"
|
||||||
@@ -48,6 +50,8 @@
|
|||||||
:fg-on-danger "#ffffff"
|
:fg-on-danger "#ffffff"
|
||||||
:success "#22c55e"
|
:success "#22c55e"
|
||||||
:fg-on-success "#ffffff"
|
:fg-on-success "#ffffff"
|
||||||
|
:warning "#f59e0b"
|
||||||
|
:fg-on-warning "#ffffff"
|
||||||
:border-0 "1px solid #2a2a2a"
|
:border-0 "1px solid #2a2a2a"
|
||||||
:border-1 "1px solid #3a3a3a"
|
:border-1 "1px solid #3a3a3a"
|
||||||
:border-2 "1px solid #555555"
|
:border-2 "1px solid #555555"
|
||||||
|
|||||||
52
src/ui/accordion.cljc
Normal file
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