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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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