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:
@@ -23,6 +23,8 @@
|
||||
:fg-on-danger "#ffffff"
|
||||
:success "#16a34a"
|
||||
:fg-on-success "#ffffff"
|
||||
:warning "#d97706"
|
||||
:fg-on-warning "#ffffff"
|
||||
:border-0 "1px solid #e0e0e0"
|
||||
:border-1 "1px solid #cccccc"
|
||||
:border-2 "1px solid #999999"
|
||||
@@ -48,6 +50,8 @@
|
||||
:fg-on-danger "#ffffff"
|
||||
:success "#22c55e"
|
||||
:fg-on-success "#ffffff"
|
||||
:warning "#f59e0b"
|
||||
:fg-on-warning "#ffffff"
|
||||
:border-0 "1px solid #2a2a2a"
|
||||
:border-1 "1px solid #3a3a3a"
|
||||
:border-2 "1px solid #555555"
|
||||
|
||||
52
src/ui/accordion.cljc
Normal file
52
src/ui/accordion.cljc
Normal file
@@ -0,0 +1,52 @@
|
||||
(ns ui.accordion
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
(defn accordion-class-list
|
||||
"Generate a vector of CSS class strings for an accordion item."
|
||||
[{:keys [open]}]
|
||||
(cond-> ["accordion"]
|
||||
open (conj "accordion--open")))
|
||||
|
||||
(defn accordion-classes
|
||||
"Generate CSS class string for an accordion."
|
||||
[opts]
|
||||
(str/join " " (accordion-class-list opts)))
|
||||
|
||||
(defn accordion
|
||||
"Render an accordion (collapsible) item.
|
||||
|
||||
Props:
|
||||
:title - trigger text
|
||||
:open - boolean, whether expanded
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [title open class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (accordion-classes {:open open})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:div base-attrs
|
||||
[:div {:class "accordion-trigger"} title]]
|
||||
(when open
|
||||
[[:div {:class "accordion-content"}
|
||||
(into [:div] children)]])))
|
||||
|
||||
:cljs
|
||||
(let [cls (accordion-class-list {:open open})
|
||||
classes (cond-> cls class (conj class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:div base-attrs
|
||||
[:div {:class ["accordion-trigger"]} title]]
|
||||
(when open
|
||||
[[:div {:class ["accordion-content"]}
|
||||
(into [:div] children)]])))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (accordion-classes {:open open})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:div base-attrs
|
||||
[:div {:class "accordion-trigger"} title]]
|
||||
(when open
|
||||
[[:div {:class "accordion-content"}
|
||||
(into [:div] children)]])))))
|
||||
62
src/ui/accordion.css
Normal file
62
src/ui/accordion.css
Normal file
@@ -0,0 +1,62 @@
|
||||
.accordion {
|
||||
border: var(--border-0);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion + .accordion {
|
||||
margin-top: -1px;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.accordion:has(+ .accordion) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.accordion-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--size-2);
|
||||
width: 100%;
|
||||
padding: var(--size-4);
|
||||
font-weight: 500;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--fg-0);
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.accordion-trigger:hover {
|
||||
background: var(--bg-1);
|
||||
}
|
||||
|
||||
.accordion-trigger::after {
|
||||
content: "";
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
flex-shrink: 0;
|
||||
background-color: currentColor;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.accordion--open > .accordion-trigger {
|
||||
border-bottom: var(--border-0);
|
||||
}
|
||||
|
||||
.accordion--open > .accordion-trigger::after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
padding: var(--size-4);
|
||||
}
|
||||
54
src/ui/alert.cljc
Normal file
54
src/ui/alert.cljc
Normal file
@@ -0,0 +1,54 @@
|
||||
(ns ui.alert
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
#?(:squint (defn- kw-name [s] s)
|
||||
:cljs (defn- kw-name [s] (name s))
|
||||
:clj (defn- kw-name [s] (name s)))
|
||||
|
||||
(defn alert-class-list
|
||||
"Generate a vector of CSS class strings for an alert.
|
||||
Variants: :success, :warning, :danger, :info (default: nil = neutral)."
|
||||
[{:keys [variant]}]
|
||||
(cond-> ["alert"]
|
||||
variant (conj (str "alert-" (kw-name variant)))))
|
||||
|
||||
(defn alert-classes
|
||||
"Generate CSS class string for an alert."
|
||||
[opts]
|
||||
(str/join " " (alert-class-list opts)))
|
||||
|
||||
(defn alert
|
||||
"Render an alert element.
|
||||
|
||||
Props:
|
||||
:variant - :success, :warning, :danger, :info (nil for neutral)
|
||||
:title - optional title string
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [variant title class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (alert-classes {:variant variant})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes :role "alert"} attrs)]
|
||||
(into [:div base-attrs]
|
||||
(cond-> []
|
||||
title (conj [:p {:class "alert-title"} title])
|
||||
:always (into (map (fn [c] [:p {:class "alert-body"} c]) children)))))
|
||||
|
||||
:cljs
|
||||
(let [cls (alert-class-list {:variant variant})
|
||||
classes (cond-> cls class (conj class))
|
||||
base-attrs (merge {:class classes :role "alert"} attrs)]
|
||||
(into [:div base-attrs]
|
||||
(cond-> []
|
||||
title (conj [:p {:class ["alert-title"]} title])
|
||||
:always (into (map (fn [c] [:p {:class ["alert-body"]} c]) children)))))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (alert-classes {:variant variant})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes :role "alert"} attrs)]
|
||||
(into [:div base-attrs]
|
||||
(cond-> []
|
||||
title (conj [:p {:class "alert-title"} title])
|
||||
:always (into (map (fn [c] [:p {:class "alert-body"} c]) children)))))))
|
||||
43
src/ui/alert.css
Normal file
43
src/ui/alert.css
Normal file
@@ -0,0 +1,43 @@
|
||||
.alert {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--size-3);
|
||||
padding: var(--size-4) var(--size-6);
|
||||
background: var(--bg-1);
|
||||
border: var(--border-0);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--size-1) 0;
|
||||
}
|
||||
|
||||
.alert-body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
border: none;
|
||||
color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 10%, var(--bg-0));
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
border: none;
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 10%, var(--bg-0));
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
border: none;
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 10%, var(--bg-0));
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--bg-0));
|
||||
}
|
||||
48
src/ui/badge.cljc
Normal file
48
src/ui/badge.cljc
Normal file
@@ -0,0 +1,48 @@
|
||||
(ns ui.badge
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
#?(:squint (defn- kw-name [s] s)
|
||||
:cljs (defn- kw-name [s] (name s))
|
||||
:clj (defn- kw-name [s] (name s)))
|
||||
|
||||
(def default-variant "primary")
|
||||
|
||||
(defn badge-class-list
|
||||
"Generate a vector of CSS class strings for a badge.
|
||||
Variants: :primary (default), :secondary, :outline, :success, :warning, :danger."
|
||||
[{:keys [variant]}]
|
||||
(let [v (or (some-> variant kw-name) default-variant)]
|
||||
(if (= v "primary")
|
||||
["badge"]
|
||||
["badge" (str "badge-" v)])))
|
||||
|
||||
(defn badge-classes
|
||||
"Generate CSS class string for a badge."
|
||||
[opts]
|
||||
(str/join " " (badge-class-list opts)))
|
||||
|
||||
(defn badge
|
||||
"Render a badge element.
|
||||
|
||||
Props:
|
||||
:variant - :primary, :secondary, :outline, :success, :warning, :danger
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [variant class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (badge-classes {:variant variant})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:span base-attrs] children))
|
||||
|
||||
:cljs
|
||||
(let [cls (badge-class-list {:variant variant})
|
||||
classes (cond-> cls class (conj class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:span base-attrs] children))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (badge-classes {:variant variant})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:span base-attrs] children))))
|
||||
38
src/ui/badge.css
Normal file
38
src/ui/badge.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--size-1);
|
||||
padding: var(--size-1) var(--size-3);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
border-radius: 9999px;
|
||||
background: var(--accent);
|
||||
color: var(--fg-on-accent);
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: var(--bg-2);
|
||||
color: var(--fg-0);
|
||||
}
|
||||
|
||||
.badge-outline {
|
||||
background: transparent;
|
||||
color: var(--fg-0);
|
||||
border: var(--border-0);
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 12%, var(--bg-0));
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 12%, var(--bg-0));
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 12%, var(--bg-0));
|
||||
}
|
||||
57
src/ui/breadcrumb.cljc
Normal file
57
src/ui/breadcrumb.cljc
Normal file
@@ -0,0 +1,57 @@
|
||||
(ns ui.breadcrumb
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
(defn breadcrumb
|
||||
"Render a breadcrumb navigation.
|
||||
|
||||
Props:
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes
|
||||
Items: vector of {:label \"text\" :href \"/path\"}, last item is active."
|
||||
[{:keys [items class attrs] :as _props}]
|
||||
(let [n (count items)]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "breadcrumb" class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:nav {:aria-label "Breadcrumb"}
|
||||
(into [:ol base-attrs]
|
||||
(map-indexed
|
||||
(fn [i item]
|
||||
(let [active (= i (dec n))]
|
||||
[:li {:class (cond-> "breadcrumb-item"
|
||||
active (str " breadcrumb-item--active"))}
|
||||
(if active
|
||||
(:label item)
|
||||
[:a {:href (:href item)} (:label item)])]))
|
||||
items))]
|
||||
[]))
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["breadcrumb"] class (conj class))
|
||||
base-attrs (merge {:class cls} attrs)]
|
||||
[:nav {:aria-label "Breadcrumb"}
|
||||
(into [:ol base-attrs]
|
||||
(map-indexed
|
||||
(fn [i item]
|
||||
(let [active (= i (dec n))]
|
||||
[:li {:class (cond-> ["breadcrumb-item"]
|
||||
active (conj "breadcrumb-item--active"))}
|
||||
(if active
|
||||
(:label item)
|
||||
[:a {:href (:href item)} (:label item)])]))
|
||||
items))])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "breadcrumb" class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
[:nav {:aria-label "Breadcrumb"}
|
||||
(into [:ol base-attrs]
|
||||
(map-indexed
|
||||
(fn [i item]
|
||||
(let [active (= i (dec n))]
|
||||
[:li {:class (cond-> "breadcrumb-item"
|
||||
active (str " breadcrumb-item--active"))}
|
||||
(if active
|
||||
(:label item)
|
||||
[:a {:href (:href item)} (:label item)])]))
|
||||
items))]))))
|
||||
35
src/ui/breadcrumb.css
Normal file
35
src/ui/breadcrumb.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: var(--font-sm);
|
||||
gap: var(--size-2);
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--size-2);
|
||||
}
|
||||
|
||||
.breadcrumb-item + .breadcrumb-item::before {
|
||||
content: "/";
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.breadcrumb-item a {
|
||||
color: var(--fg-1);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb-item a:hover {
|
||||
color: var(--fg-0);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.breadcrumb-item--active {
|
||||
color: var(--fg-0);
|
||||
font-weight: 500;
|
||||
}
|
||||
67
src/ui/card.cljc
Normal file
67
src/ui/card.cljc
Normal file
@@ -0,0 +1,67 @@
|
||||
(ns ui.card
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
(defn card-class-list
|
||||
"Generate a vector of CSS class strings for a card."
|
||||
[_opts]
|
||||
["card"])
|
||||
|
||||
(defn card-classes
|
||||
"Generate CSS class string for a card."
|
||||
[opts]
|
||||
(str/join " " (card-class-list opts)))
|
||||
|
||||
(defn card
|
||||
"Render a card element.
|
||||
|
||||
Props:
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (card-classes {})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:article base-attrs] children))
|
||||
|
||||
:cljs
|
||||
(let [cls (card-class-list {})
|
||||
classes (cond-> cls class (conj class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:article base-attrs] children))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (card-classes {})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
(into [:article base-attrs] children))))
|
||||
|
||||
(defn card-header
|
||||
"Render a card header section."
|
||||
[{:keys [class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(into [:header (merge {:class (cond-> "card-header" class (str " " class))} attrs)] children)
|
||||
:cljs
|
||||
(into [:header (merge {:class (cond-> ["card-header"] class (conj class))} attrs)] children)
|
||||
:clj
|
||||
(into [:header (merge {:class (cond-> "card-header" class (str " " class))} attrs)] children)))
|
||||
|
||||
(defn card-body
|
||||
"Render a card body section."
|
||||
[{:keys [class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(into [:div (merge {:class (cond-> "card-body" class (str " " class))} attrs)] children)
|
||||
:cljs
|
||||
(into [:div (merge {:class (cond-> ["card-body"] class (conj class))} attrs)] children)
|
||||
:clj
|
||||
(into [:div (merge {:class (cond-> "card-body" class (str " " class))} attrs)] children)))
|
||||
|
||||
(defn card-footer
|
||||
"Render a card footer section."
|
||||
[{:keys [class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(into [:footer (merge {:class (cond-> "card-footer" class (str " " class))} attrs)] children)
|
||||
:cljs
|
||||
(into [:footer (merge {:class (cond-> ["card-footer"] class (conj class))} attrs)] children)
|
||||
:clj
|
||||
(into [:footer (merge {:class (cond-> "card-footer" class (str " " class))} attrs)] children)))
|
||||
43
src/ui/card.css
Normal file
43
src/ui/card.css
Normal file
@@ -0,0 +1,43 @@
|
||||
.card {
|
||||
background: var(--bg-1);
|
||||
color: var(--fg-0);
|
||||
border: var(--border-0);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-1);
|
||||
padding: var(--size-6);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.card-header h1,
|
||||
.card-header h2,
|
||||
.card-header h3,
|
||||
.card-header h4,
|
||||
.card-header h5,
|
||||
.card-header h6 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--fg-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: var(--size-6);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--size-2);
|
||||
padding: var(--size-6);
|
||||
padding-top: 0;
|
||||
}
|
||||
78
src/ui/dialog.cljc
Normal file
78
src/ui/dialog.cljc
Normal file
@@ -0,0 +1,78 @@
|
||||
(ns ui.dialog
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
(defn dialog-class-list
|
||||
"Generate a vector of CSS class strings for a dialog."
|
||||
[_opts]
|
||||
["dialog"])
|
||||
|
||||
(defn dialog-classes
|
||||
"Generate CSS class string for a dialog."
|
||||
[opts]
|
||||
(str/join " " (dialog-class-list opts)))
|
||||
|
||||
(defn dialog
|
||||
"Render a dialog element.
|
||||
|
||||
Props:
|
||||
:open - boolean, whether the dialog is open
|
||||
:id - dialog id for targeting
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [open id class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (dialog-classes {})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes}
|
||||
(when id {:id id})
|
||||
(when open {:open true})
|
||||
attrs)]
|
||||
(into [:dialog base-attrs] children))
|
||||
|
||||
:cljs
|
||||
(let [cls (dialog-class-list {})
|
||||
classes (cond-> cls class (conj class))
|
||||
base-attrs (merge {:class classes}
|
||||
(when id {:id id})
|
||||
(when open {:open true})
|
||||
attrs)]
|
||||
(into [:dialog base-attrs] children))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (dialog-classes {})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes}
|
||||
(when id {:id id})
|
||||
(when open {:open true})
|
||||
attrs)]
|
||||
(into [:dialog base-attrs] children))))
|
||||
|
||||
(defn dialog-header
|
||||
"Render a dialog header section."
|
||||
[{:keys [class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(into [:header (merge {:class (cond-> "dialog-header" class (str " " class))} attrs)] children)
|
||||
:cljs
|
||||
(into [:header (merge {:class (cond-> ["dialog-header"] class (conj class))} attrs)] children)
|
||||
:clj
|
||||
(into [:header (merge {:class (cond-> "dialog-header" class (str " " class))} attrs)] children)))
|
||||
|
||||
(defn dialog-body
|
||||
"Render a dialog body section."
|
||||
[{:keys [class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(into [:div (merge {:class (cond-> "dialog-body" class (str " " class))} attrs)] children)
|
||||
:cljs
|
||||
(into [:div (merge {:class (cond-> ["dialog-body"] class (conj class))} attrs)] children)
|
||||
:clj
|
||||
(into [:div (merge {:class (cond-> "dialog-body" class (str " " class))} attrs)] children)))
|
||||
|
||||
(defn dialog-footer
|
||||
"Render a dialog footer section."
|
||||
[{:keys [class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(into [:footer (merge {:class (cond-> "dialog-footer" class (str " " class))} attrs)] children)
|
||||
:cljs
|
||||
(into [:footer (merge {:class (cond-> ["dialog-footer"] class (conj class))} attrs)] children)
|
||||
:clj
|
||||
(into [:footer (merge {:class (cond-> "dialog-footer" class (str " " class))} attrs)] children)))
|
||||
59
src/ui/dialog.css
Normal file
59
src/ui/dialog.css
Normal file
@@ -0,0 +1,59 @@
|
||||
.dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
width: min(100% - 2rem, 32rem);
|
||||
max-height: 85vh;
|
||||
margin: auto;
|
||||
padding: 0;
|
||||
background: var(--bg-1);
|
||||
border: var(--border-0);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-1);
|
||||
padding: var(--size-6);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.dialog-header h1,
|
||||
.dialog-header h2,
|
||||
.dialog-header h3,
|
||||
.dialog-header h4,
|
||||
.dialog-header h5,
|
||||
.dialog-header h6 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-header p {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--fg-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: var(--size-6);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--size-2);
|
||||
padding: var(--size-6);
|
||||
padding-top: 0;
|
||||
}
|
||||
194
src/ui/form.css
Normal file
194
src/ui/form.css
Normal file
@@ -0,0 +1,194 @@
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--fg-0);
|
||||
margin-bottom: var(--size-1);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: var(--size-2) var(--size-3);
|
||||
font-size: var(--font-sm);
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
background: var(--bg-0);
|
||||
color: var(--fg-0);
|
||||
border: var(--border-1);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.form-input::placeholder,
|
||||
.form-textarea::placeholder {
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.form-input:disabled,
|
||||
.form-textarea:disabled,
|
||||
.form-select:disabled {
|
||||
background: var(--bg-2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-input--error,
|
||||
.form-textarea--error {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.form-input--error:focus,
|
||||
.form-textarea--error:focus {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 20%, transparent);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right var(--size-2) center;
|
||||
padding-right: var(--size-6);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--fg-2);
|
||||
margin-top: var(--size-1);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--danger);
|
||||
margin-top: var(--size-1);
|
||||
}
|
||||
|
||||
.form-checkbox,
|
||||
.form-radio {
|
||||
appearance: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
background: var(--bg-0);
|
||||
border: var(--border-1);
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease, border-color 150ms ease;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.form-radio {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.form-checkbox:checked,
|
||||
.form-radio:checked {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-checkbox:checked {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='4'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
|
||||
background-size: 100%;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.form-radio:checked {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='4' fill='white'/%3E%3C/svg%3E");
|
||||
background-size: 100%;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.form-checkbox:disabled,
|
||||
.form-radio:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-range {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
appearance: none;
|
||||
background: var(--bg-2);
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-range::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background: var(--accent);
|
||||
border-radius: 9999px;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.form-range::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.form-range::-moz-range-thumb {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-group > .form-input,
|
||||
.form-group > .form-select {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.form-group > *:first-child,
|
||||
.form-group > *:first-child .form-input,
|
||||
.form-group > *:first-child .form-select {
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.form-group > *:last-child,
|
||||
.form-group > *:last-child .form-input,
|
||||
.form-group > *:last-child .form-select {
|
||||
border-top-right-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.form-group > *:not(:first-child) {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.form-group-addon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--size-2) var(--size-3);
|
||||
font-size: var(--font-sm);
|
||||
background: var(--bg-2);
|
||||
border: var(--border-1);
|
||||
color: var(--fg-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
51
src/ui/grid.css
Normal file
51
src/ui/grid.css
Normal file
@@ -0,0 +1,51 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.col { grid-column-end: span 12; }
|
||||
.col-1 { grid-column-end: span 1; }
|
||||
.col-2 { grid-column-end: span 2; }
|
||||
.col-3 { grid-column-end: span 3; }
|
||||
.col-4 { grid-column-end: span 4; }
|
||||
.col-5 { grid-column-end: span 5; }
|
||||
.col-6 { grid-column-end: span 6; }
|
||||
.col-7 { grid-column-end: span 7; }
|
||||
.col-8 { grid-column-end: span 8; }
|
||||
.col-9 { grid-column-end: span 9; }
|
||||
.col-10 { grid-column-end: span 10; }
|
||||
.col-11 { grid-column-end: span 11; }
|
||||
.col-12 { grid-column-end: span 12; }
|
||||
|
||||
.offset-1 { grid-column-start: 2; }
|
||||
.offset-2 { grid-column-start: 3; }
|
||||
.offset-3 { grid-column-start: 4; }
|
||||
.offset-4 { grid-column-start: 5; }
|
||||
.offset-5 { grid-column-start: 6; }
|
||||
.offset-6 { grid-column-start: 7; }
|
||||
|
||||
.col-end {
|
||||
grid-column-end: -1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.row {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
.col, [class*="col-"] {
|
||||
grid-column-end: span 4;
|
||||
}
|
||||
[class*="offset-"] {
|
||||
grid-column-start: auto;
|
||||
}
|
||||
}
|
||||
70
src/ui/pagination.cljc
Normal file
70
src/ui/pagination.cljc
Normal file
@@ -0,0 +1,70 @@
|
||||
(ns ui.pagination
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
(defn pagination-item-class-list
|
||||
"Generate a vector of CSS class strings for a pagination item."
|
||||
[{:keys [active disabled]}]
|
||||
(cond-> ["pagination-item"]
|
||||
active (conj "pagination-item--active")
|
||||
disabled (conj "pagination-item--disabled")))
|
||||
|
||||
(defn pagination-item-classes
|
||||
"Generate CSS class string for a pagination item."
|
||||
[opts]
|
||||
(str/join " " (pagination-item-class-list opts)))
|
||||
|
||||
(defn pagination
|
||||
"Render a pagination nav.
|
||||
|
||||
Props:
|
||||
:current - current page number (1-based)
|
||||
:total - total number of pages
|
||||
:href-fn - fn of page number → URL string (for :clj)
|
||||
:on-click - fn of page number → handler (for :cljs/:squint)
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [current total href-fn on-click class attrs] :as _props}]
|
||||
(let [pages (range 1 (inc total))
|
||||
prev-disabled (= current 1)
|
||||
next-disabled (= current total)]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "pagination" class (str " " class))
|
||||
make-item (fn [page label active disabled]
|
||||
[:li {:class (pagination-item-classes {:active active :disabled disabled})}
|
||||
[:a (cond-> {:href (if href-fn (href-fn page) "#")}
|
||||
(and on-click (not disabled))
|
||||
(assoc :on-click (fn [e] (.preventDefault e) (on-click page))))
|
||||
label]])]
|
||||
[:nav {:aria-label "Pagination"}
|
||||
(into [:ol (merge {:class classes} attrs)]
|
||||
(concat
|
||||
[(make-item (dec current) "← Previous" false prev-disabled)]
|
||||
(map (fn [p] (make-item p (str p) (= p current) false)) pages)
|
||||
[(make-item (inc current) "Next →" false next-disabled)]))])
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["pagination"] class (conj class))
|
||||
make-item (fn [page label active disabled]
|
||||
[:li {:class (pagination-item-class-list {:active active :disabled disabled})}
|
||||
[:a (cond-> {:href (if href-fn (href-fn page) "#")}
|
||||
(and on-click (not disabled))
|
||||
(assoc-in [:on :click] (fn [e] (.preventDefault e) (on-click page))))
|
||||
label]])]
|
||||
[:nav {:aria-label "Pagination"}
|
||||
(into [:ol (merge {:class cls} attrs)]
|
||||
(concat
|
||||
[(make-item (dec current) "← Previous" false prev-disabled)]
|
||||
(map (fn [p] (make-item p (str p) (= p current) false)) pages)
|
||||
[(make-item (inc current) "Next →" false next-disabled)]))])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "pagination" class (str " " class))
|
||||
make-item (fn [page label active disabled]
|
||||
[:li {:class (pagination-item-classes {:active active :disabled disabled})}
|
||||
[:a {:href (if href-fn (href-fn page) "#")} label]])]
|
||||
[:nav {:aria-label "Pagination"}
|
||||
(into [:ol (merge {:class classes} attrs)]
|
||||
(concat
|
||||
[(make-item (dec current) "← Previous" false prev-disabled)]
|
||||
(map (fn [p] (make-item p (str p) (= p current) false)) pages)
|
||||
[(make-item (inc current) "Next →" false next-disabled)]))]))))
|
||||
41
src/ui/pagination.css
Normal file
41
src/ui/pagination.css
Normal file
@@ -0,0 +1,41 @@
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: var(--size-1);
|
||||
}
|
||||
|
||||
.pagination-item a,
|
||||
.pagination-item span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0 var(--size-2);
|
||||
font-size: var(--font-sm);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--fg-1);
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.pagination-item a:hover {
|
||||
background: var(--bg-1);
|
||||
color: var(--fg-0);
|
||||
}
|
||||
|
||||
.pagination-item--active a,
|
||||
.pagination-item--active span {
|
||||
background: var(--accent);
|
||||
color: var(--fg-on-accent);
|
||||
}
|
||||
|
||||
.pagination-item--disabled a,
|
||||
.pagination-item--disabled span {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
52
src/ui/progress.cljc
Normal file
52
src/ui/progress.cljc
Normal file
@@ -0,0 +1,52 @@
|
||||
(ns ui.progress
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
#?(:squint (defn- kw-name [s] s)
|
||||
:cljs (defn- kw-name [s] (name s))
|
||||
:clj (defn- kw-name [s] (name s)))
|
||||
|
||||
(defn progress-bar-class-list
|
||||
"Generate a vector of CSS class strings for the progress bar fill.
|
||||
Variants: nil (default/accent), :success, :warning, :danger."
|
||||
[{:keys [variant]}]
|
||||
(cond-> ["progress-bar"]
|
||||
variant (conj (str "progress-bar--" (kw-name variant)))))
|
||||
|
||||
(defn progress-bar-classes
|
||||
"Generate CSS class string for the progress bar fill."
|
||||
[opts]
|
||||
(str/join " " (progress-bar-class-list opts)))
|
||||
|
||||
(defn progress
|
||||
"Render a progress bar.
|
||||
|
||||
Props:
|
||||
:value - number 0-100
|
||||
:variant - nil, :success, :warning, :danger
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [value variant class attrs] :as _props}]
|
||||
(let [pct (str (or value 0) "%")]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "progress" class (str " " class))
|
||||
bar-cls (progress-bar-classes {:variant variant})]
|
||||
[:div (merge {:class classes :role "progressbar"
|
||||
:aria-valuenow (str (or value 0))
|
||||
:aria-valuemin "0" :aria-valuemax "100"} attrs)
|
||||
[:div {:class bar-cls :style {"width" pct}}]])
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["progress"] class (conj class))
|
||||
bar-cls (progress-bar-class-list {:variant variant})]
|
||||
[:div (merge {:class cls :role "progressbar"
|
||||
:aria-valuenow (str (or value 0))
|
||||
:aria-valuemin "0" :aria-valuemax "100"} attrs)
|
||||
[:div {:class bar-cls :style {:width pct}}]])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "progress" class (str " " class))
|
||||
bar-cls (progress-bar-classes {:variant variant})]
|
||||
[:div (merge {:class classes :role "progressbar"
|
||||
:aria-valuenow (str (or value 0))
|
||||
:aria-valuemin "0" :aria-valuemax "100"} attrs)
|
||||
[:div {:class bar-cls :style (str "width: " pct)}]]))))
|
||||
26
src/ui/progress.css
Normal file
26
src/ui/progress.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.progress {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-2);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 9999px;
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
|
||||
.progress-bar--success {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.progress-bar--warning {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.progress-bar--danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
44
src/ui/skeleton.cljc
Normal file
44
src/ui/skeleton.cljc
Normal file
@@ -0,0 +1,44 @@
|
||||
(ns ui.skeleton
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
#?(:squint (defn- kw-name [s] s)
|
||||
:cljs (defn- kw-name [s] (name s))
|
||||
:clj (defn- kw-name [s] (name s)))
|
||||
|
||||
(defn skeleton-class-list
|
||||
"Generate a vector of CSS class strings for a skeleton placeholder.
|
||||
Variants: :line (text line), :box (square), :circle, :heading."
|
||||
[{:keys [variant]}]
|
||||
(cond-> ["skeleton"]
|
||||
variant (conj (str "skeleton--" (kw-name variant)))))
|
||||
|
||||
(defn skeleton-classes
|
||||
"Generate CSS class string for a skeleton."
|
||||
[opts]
|
||||
(str/join " " (skeleton-class-list opts)))
|
||||
|
||||
(defn skeleton
|
||||
"Render a skeleton loading placeholder.
|
||||
|
||||
Props:
|
||||
:variant - :line, :box, :circle, :heading
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [variant class attrs] :as _props}]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (skeleton-classes {:variant variant})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes :role "status" :aria-label "Loading"} attrs)]
|
||||
[:div base-attrs])
|
||||
|
||||
:cljs
|
||||
(let [cls (skeleton-class-list {:variant variant})
|
||||
classes (cond-> cls class (conj class))
|
||||
base-attrs (merge {:class classes :role "status" :aria-label "Loading"} attrs)]
|
||||
[:div base-attrs])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (skeleton-classes {:variant variant})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes :role "status" :aria-label "Loading"} attrs)]
|
||||
[:div base-attrs])))
|
||||
42
src/ui/skeleton.css
vendored
Normal file
42
src/ui/skeleton.css
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
.skeleton {
|
||||
background: var(--bg-2);
|
||||
border-radius: var(--radius-md);
|
||||
animation: skeleton-pulse 2s infinite;
|
||||
background-size: 200% 100%;
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-2) 0%,
|
||||
var(--bg-1) 50%,
|
||||
var(--bg-2) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.skeleton + .skeleton {
|
||||
margin-top: var(--size-3);
|
||||
}
|
||||
|
||||
.skeleton--line {
|
||||
height: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skeleton--box {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.skeleton--circle {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.skeleton--heading {
|
||||
height: 1.5rem;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
from { background-position: 200% 0; }
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
48
src/ui/spinner.cljc
Normal file
48
src/ui/spinner.cljc
Normal file
@@ -0,0 +1,48 @@
|
||||
(ns ui.spinner
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
#?(:squint (defn- kw-name [s] s)
|
||||
:cljs (defn- kw-name [s] (name s))
|
||||
:clj (defn- kw-name [s] (name s)))
|
||||
|
||||
(def default-size "md")
|
||||
|
||||
(defn spinner-class-list
|
||||
"Generate a vector of CSS class strings for a spinner.
|
||||
Sizes: :sm, :md (default), :lg."
|
||||
[{:keys [size]}]
|
||||
(let [s (or (some-> size kw-name) default-size)]
|
||||
(cond-> ["spinner"]
|
||||
(= s "sm") (conj "spinner-sm")
|
||||
(= s "lg") (conj "spinner-lg"))))
|
||||
|
||||
(defn spinner-classes
|
||||
"Generate CSS class string for a spinner."
|
||||
[opts]
|
||||
(str/join " " (spinner-class-list opts)))
|
||||
|
||||
(defn spinner
|
||||
"Render a spinner element.
|
||||
|
||||
Props:
|
||||
:size - :sm, :md, :lg
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [size class attrs] :as _props}]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (spinner-classes {:size size})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes :role "status" :aria-label "Loading"} attrs)]
|
||||
[:span base-attrs])
|
||||
|
||||
:cljs
|
||||
(let [cls (spinner-class-list {:size size})
|
||||
classes (cond-> cls class (conj class))
|
||||
base-attrs (merge {:class classes :role "status" :aria-label "Loading"} attrs)]
|
||||
[:span base-attrs])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (spinner-classes {:size size})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes :role "status" :aria-label "Loading"} attrs)]
|
||||
[:span base-attrs])))
|
||||
49
src/ui/spinner.css
Normal file
49
src/ui/spinner.css
Normal file
@@ -0,0 +1,49 @@
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 2px solid var(--bg-2);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 9999px;
|
||||
animation: spinner-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.spinner-lg {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.spinner-overlay {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.spinner-overlay > * {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spinner-overlay::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
z-index: 1;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 2px solid var(--bg-2);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 9999px;
|
||||
animation: spinner-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spinner-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
65
src/ui/switch.cljc
Normal file
65
src/ui/switch.cljc
Normal file
@@ -0,0 +1,65 @@
|
||||
(ns ui.switch
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
(defn switch-class-list
|
||||
"Generate a vector of CSS class strings for a switch."
|
||||
[{:keys [disabled]}]
|
||||
(cond-> ["switch"]
|
||||
disabled (conj "switch--disabled")))
|
||||
|
||||
(defn switch-classes
|
||||
"Generate CSS class string for a switch."
|
||||
[opts]
|
||||
(str/join " " (switch-class-list opts)))
|
||||
|
||||
(defn switch-toggle
|
||||
"Render a toggle switch element.
|
||||
|
||||
Props:
|
||||
:checked - boolean, whether the switch is on
|
||||
:disabled - boolean
|
||||
:on-change - change handler (ignored in :clj target)
|
||||
:label - label text
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [checked disabled on-change label class attrs] :as _props}]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (switch-classes {:disabled disabled})
|
||||
class (str " " class))
|
||||
track-cls (cond-> "switch-track"
|
||||
checked (str " switch-track--checked"))]
|
||||
[:label (merge {:class classes} attrs)
|
||||
[:input {:class "switch-input" :type "checkbox"
|
||||
:checked (boolean checked)
|
||||
:disabled (boolean disabled)
|
||||
:on-change on-change}]
|
||||
[:span {:class track-cls}
|
||||
[:span {:class "switch-thumb"}]]
|
||||
(when label [:span label])])
|
||||
|
||||
:cljs
|
||||
(let [cls (switch-class-list {:disabled disabled})
|
||||
classes (cond-> cls class (conj class))
|
||||
track-cls (cond-> ["switch-track"]
|
||||
checked (conj "switch-track--checked"))]
|
||||
[:label (merge {:class classes} attrs)
|
||||
[:input (cond-> {:class ["switch-input"] :type "checkbox"
|
||||
:checked (boolean checked)
|
||||
:disabled (boolean disabled)}
|
||||
on-change (assoc-in [:on :change] on-change))]
|
||||
[:span {:class track-cls}
|
||||
[:span {:class ["switch-thumb"]}]]
|
||||
(when label [:span label])])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (switch-classes {:disabled disabled})
|
||||
class (str " " class))
|
||||
track-cls (cond-> "switch-track"
|
||||
checked (str " switch-track--checked"))]
|
||||
[:label (merge {:class classes} attrs)
|
||||
[:input {:class "switch-input" :type "checkbox"
|
||||
:checked (boolean checked)
|
||||
:disabled (boolean disabled)}]
|
||||
[:span {:class track-cls}
|
||||
[:span {:class "switch-thumb"}]]
|
||||
(when label [:span label])])))
|
||||
52
src/ui/switch.css
Normal file
52
src/ui/switch.css
Normal file
@@ -0,0 +1,52 @@
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--size-2);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--fg-0);
|
||||
}
|
||||
|
||||
.switch-track {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.375rem;
|
||||
background: var(--bg-2);
|
||||
border-radius: 9999px;
|
||||
transition: background-color 200ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switch-track--checked {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: calc(1.375rem - 4px);
|
||||
height: calc(1.375rem - 4px);
|
||||
background: var(--bg-0);
|
||||
border-radius: 9999px;
|
||||
box-shadow: var(--shadow-0);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.switch-track--checked .switch-thumb {
|
||||
transform: translateX(calc(2.5rem - 1.375rem));
|
||||
}
|
||||
|
||||
.switch--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switch-input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
76
src/ui/table.cljc
Normal file
76
src/ui/table.cljc
Normal file
@@ -0,0 +1,76 @@
|
||||
(ns ui.table
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
#?(:squint (defn- kw-name [s] s)
|
||||
:cljs (defn- kw-name [s] (name s))
|
||||
:clj (defn- kw-name [s] (name s)))
|
||||
|
||||
(defn table-class-list
|
||||
"Generate a vector of CSS class strings for a table.
|
||||
Variants: nil (default), :striped, :bordered."
|
||||
[{:keys [variant]}]
|
||||
(cond-> ["table"]
|
||||
variant (conj (str "table--" (kw-name variant)))))
|
||||
|
||||
(defn table-classes
|
||||
"Generate CSS class string for a table."
|
||||
[opts]
|
||||
(str/join " " (table-class-list opts)))
|
||||
|
||||
(defn table
|
||||
"Render a table element.
|
||||
|
||||
Props:
|
||||
:headers - vector of column header strings
|
||||
:rows - vector of row vectors (each row is a vector of cell values)
|
||||
:variant - nil, :striped, :bordered
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [headers rows variant class attrs] :as _props}]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (table-classes {:variant variant})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
[:div {:class "table-wrapper"}
|
||||
[:table base-attrs
|
||||
(when (seq headers)
|
||||
[:thead
|
||||
(into [:tr]
|
||||
(map (fn [h] [:th h]) headers))])
|
||||
(into [:tbody]
|
||||
(map (fn [row]
|
||||
(into [:tr]
|
||||
(map (fn [cell] [:td cell]) row)))
|
||||
rows))]])
|
||||
|
||||
:cljs
|
||||
(let [cls (table-class-list {:variant variant})
|
||||
classes (cond-> cls class (conj class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
[:div {:class ["table-wrapper"]}
|
||||
[:table base-attrs
|
||||
(when (seq headers)
|
||||
[:thead
|
||||
(into [:tr]
|
||||
(map (fn [h] [:th h]) headers))])
|
||||
(into [:tbody]
|
||||
(map (fn [row]
|
||||
(into [:tr]
|
||||
(map (fn [cell] [:td cell]) row)))
|
||||
rows))]])
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (table-classes {:variant variant})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes} attrs)]
|
||||
[:div {:class "table-wrapper"}
|
||||
[:table base-attrs
|
||||
(when (seq headers)
|
||||
[:thead
|
||||
(into [:tr]
|
||||
(map (fn [h] [:th h]) headers))])
|
||||
(into [:tbody]
|
||||
(map (fn [row]
|
||||
(into [:tr]
|
||||
(map (fn [cell] [:td cell]) row)))
|
||||
rows))]])))
|
||||
54
src/ui/table.css
Normal file
54
src/ui/table.css
Normal file
@@ -0,0 +1,54 @@
|
||||
.table-wrapper {
|
||||
min-width: 320px;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.table thead {
|
||||
border-bottom: var(--border-1);
|
||||
}
|
||||
|
||||
.table th {
|
||||
padding: var(--size-3) var(--size-2);
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: var(--size-3) var(--size-2);
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
border-bottom: var(--border-0);
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.table tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: var(--bg-1);
|
||||
}
|
||||
|
||||
.table--striped tbody tr:nth-child(even) {
|
||||
background: var(--bg-1);
|
||||
}
|
||||
|
||||
.table--bordered {
|
||||
border: var(--border-0);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table--bordered th,
|
||||
.table--bordered td {
|
||||
border: var(--border-0);
|
||||
}
|
||||
25
src/ui/tooltip.cljc
Normal file
25
src/ui/tooltip.cljc
Normal file
@@ -0,0 +1,25 @@
|
||||
(ns ui.tooltip
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
(defn tooltip
|
||||
"Render a tooltip wrapper. Shows tooltip text on hover.
|
||||
|
||||
Props:
|
||||
:text - tooltip text to display
|
||||
:class - additional CSS classes
|
||||
:attrs - additional HTML attributes"
|
||||
[{:keys [text class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> "tooltip" class (str " " class))
|
||||
base-attrs (merge {:class classes :data-tooltip text} attrs)]
|
||||
(into [:span base-attrs] children))
|
||||
|
||||
:cljs
|
||||
(let [cls (cond-> ["tooltip"] class (conj class))
|
||||
base-attrs (merge {:class cls :data-tooltip text} attrs)]
|
||||
(into [:span base-attrs] children))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> "tooltip" class (str " " class))
|
||||
base-attrs (merge {:class classes :data-tooltip text} attrs)]
|
||||
(into [:span base-attrs] children))))
|
||||
43
src/ui/tooltip.css
Normal file
43
src/ui/tooltip.css
Normal file
@@ -0,0 +1,43 @@
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip::before,
|
||||
.tooltip::after {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 150ms ease, transform 150ms ease, visibility 150ms ease;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: attr(data-tooltip);
|
||||
bottom: calc(100% + 10px);
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
padding: var(--size-2) var(--size-3);
|
||||
font-size: var(--font-xs);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
background: var(--fg-0);
|
||||
color: var(--bg-0);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.tooltip::before {
|
||||
content: "";
|
||||
bottom: calc(100% - 5px);
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: var(--fg-0);
|
||||
}
|
||||
|
||||
.tooltip:hover::before,
|
||||
.tooltip:hover::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
51
src/ui/utilities.css
Normal file
51
src/ui/utilities.css
Normal file
@@ -0,0 +1,51 @@
|
||||
.align-left { text-align: start; }
|
||||
.align-center { text-align: center; }
|
||||
.align-right { text-align: end; }
|
||||
.text-muted { color: var(--fg-1); }
|
||||
.text-faint { color: var(--fg-2); }
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-center { justify-content: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-end { justify-content: flex-end; }
|
||||
|
||||
.hstack {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vstack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-3);
|
||||
}
|
||||
|
||||
.gap-1 { gap: var(--size-1); }
|
||||
.gap-2 { gap: var(--size-2); }
|
||||
.gap-3 { gap: var(--size-3); }
|
||||
.gap-4 { gap: var(--size-4); }
|
||||
|
||||
.mt-2 { margin-top: var(--size-2); }
|
||||
.mt-4 { margin-top: var(--size-4); }
|
||||
.mt-6 { margin-top: var(--size-6); }
|
||||
.mb-2 { margin-bottom: var(--size-2); }
|
||||
.mb-4 { margin-bottom: var(--size-4); }
|
||||
.mb-6 { margin-bottom: var(--size-6); }
|
||||
.p-4 { padding: var(--size-4); }
|
||||
|
||||
.w-full { width: 100%; }
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
Reference in New Issue
Block a user