feat: add 13 components adapted from Oat UI

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

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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