feat(card): add card-list component with full and inset dividers

Adds `card-list` and `card-list-item` to the card module for rendering
a list of items inside a single card background with border separators.

The `:divider` prop controls separator style:
- `:full` (default) — borders go edge-to-edge
- `:inset` — borders are inset from edges by `--size-4`h
This commit is contained in:
Florian Schroedl
2026-03-11 18:29:39 +01:00
parent e356bc2e6a
commit 64bf5e029c
6 changed files with 180 additions and 3 deletions

View File

@@ -124,7 +124,21 @@
(card/card-body {} [:p "This is the card content. It can contain any HTML."]) (card/card-body {} [:p "This is the card content. It can contain any HTML."])
(card/card-footer {} (card/card-footer {}
(button/button {:variant :secondary :size :sm} "Cancel") (button/button {:variant :secondary :size :sm} "Cancel")
(button/button {:variant :primary :size :sm} "Save"))))) (button/button {:variant :primary :size :sm} "Save")))
[:h5 "Card List (full dividers)"]
(card/card-list {}
(card/card-list-item {} "Notifications")
(card/card-list-item {} "Privacy")
(card/card-list-item {} "Appearance")
(card/card-list-item {} "Accessibility"))
[:h5 "Card List (inset dividers)"]
(card/card-list {:divider :inset}
(card/card-list-item {} "Notifications")
(card/card-list-item {} "Privacy")
(card/card-list-item {} "Appearance")
(card/card-list-item {} "Accessibility"))))
(defn accordion-demo [] (defn accordion-demo []
(section "Accordion" (section "Accordion"

View File

@@ -86,7 +86,21 @@
(card/card-body {} [:p "This is the card content. It can contain any HTML."]) (card/card-body {} [:p "This is the card content. It can contain any HTML."])
(card/card-footer {} (card/card-footer {}
(button/button {:variant :secondary :size :sm} "Cancel") (button/button {:variant :secondary :size :sm} "Cancel")
(button/button {:variant :primary :size :sm} "Save"))))) (button/button {:variant :primary :size :sm} "Save")))
[:h5 "Card List (full dividers)"]
(card/card-list {}
(card/card-list-item {} "Notifications")
(card/card-list-item {} "Privacy")
(card/card-list-item {} "Appearance")
(card/card-list-item {} "Accessibility"))
[:h5 "Card List (inset dividers)"]
(card/card-list {:divider :inset}
(card/card-list-item {} "Notifications")
(card/card-list-item {} "Privacy")
(card/card-list-item {} "Appearance")
(card/card-list-item {} "Accessibility"))))
(defn accordion-demo [] (defn accordion-demo []
(section "Accordion" (section "Accordion"

View File

@@ -104,7 +104,21 @@
(card/card-body {} [:p "This is the card content. It can contain any HTML."]) (card/card-body {} [:p "This is the card content. It can contain any HTML."])
(card/card-footer {} (card/card-footer {}
(button/button {:variant "secondary" :size "sm"} "Cancel") (button/button {:variant "secondary" :size "sm"} "Cancel")
(button/button {:variant "primary" :size "sm"} "Save"))))) (button/button {:variant "primary" :size "sm"} "Save")))
[:h5 "Card List (full dividers)"]
(card/card-list {}
(card/card-list-item {} "Notifications")
(card/card-list-item {} "Privacy")
(card/card-list-item {} "Appearance")
(card/card-list-item {} "Accessibility"))
[:h5 "Card List (inset dividers)"]
(card/card-list {:divider "inset"}
(card/card-list-item {} "Notifications")
(card/card-list-item {} "Privacy")
(card/card-list-item {} "Appearance")
(card/card-list-item {} "Accessibility"))))
(defn accordion-demo [] (defn accordion-demo []
(section "Accordion" (section "Accordion"

View File

@@ -1,6 +1,11 @@
(ns ui.card (ns ui.card
(:require [clojure.string :as str])) (:require [clojure.string :as str]))
;; In squint, keywords are strings — name is identity
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
:clj (defn- kw-name [s] (name s)))
(defn card-class-list (defn card-class-list
"Generate a vector of CSS class strings for a card." "Generate a vector of CSS class strings for a card."
[_opts] [_opts]
@@ -65,3 +70,62 @@
(into [:footer (merge {:class (cond-> ["card-footer"] class (conj class))} attrs)] children) (into [:footer (merge {:class (cond-> ["card-footer"] class (conj class))} attrs)] children)
:clj :clj
(into [:footer (merge {:class (cond-> "card-footer" class (str " " class))} attrs)] children))) (into [:footer (merge {:class (cond-> "card-footer" class (str " " class))} attrs)] children)))
;; ─── Card List ─────────────────────────────────────────────────────
(defn card-list-class-list
"Generate a vector of CSS class strings for a card-list.
Options:
:divider - :full (default) or :inset (borders have horizontal spacing from edges)"
[{:keys [divider]}]
(let [d (or (some-> divider kw-name) "full")]
(cond-> ["card-list"]
(= d "inset") (conj "card-list-inset"))))
(defn card-list-classes
"Generate CSS class string for a card-list."
[opts]
(str/join " " (card-list-class-list opts)))
(defn card-list
"Render a card-list container — a card background with items separated by borders.
Props:
:divider - :full (default, borders go edge-to-edge) or :inset (borders inset from edges)
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [divider class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> (card-list-classes {:divider divider})
class (str " " class))
base-attrs (merge {:class classes :role "list"} attrs)]
(into [:div base-attrs] children))
:cljs
(let [cls (card-list-class-list {:divider divider})
classes (cond-> cls class (conj class))
base-attrs (merge {:class classes :role "list"} attrs)]
(into [:div base-attrs] children))
:clj
(let [classes (cond-> (card-list-classes {:divider divider})
class (str " " class))
base-attrs (merge {:class classes :role "list"} attrs)]
(into [:div base-attrs] children))))
(defn card-list-item
"Render an item inside a card-list.
Props:
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [class attrs] :as _props} & children]
#?(:squint
(into [:div (merge {:class (cond-> "card-list-item" class (str " " class))
:role "listitem"} attrs)] children)
:cljs
(into [:div (merge {:class (cond-> ["card-list-item"] class (conj class))
:role "listitem"} attrs)] children)
:clj
(into [:div (merge {:class (cond-> "card-list-item" class (str " " class))
:role "listitem"} attrs)] children)))

View File

@@ -63,3 +63,39 @@
font-size: var(--font-base); font-size: var(--font-base);
line-height: var(--size-5); line-height: var(--size-5);
} }
/* ─── Card List ───────────────────────────────────────────────── */
.card-list {
display: flex;
flex-direction: column;
background: var(--bg-1);
color: var(--fg-0);
border: var(--border-0);
border-radius: var(--radius-md);
overflow: hidden;
}
.card-list-item {
padding: var(--size-3) var(--size-4);
}
/* Full-width dividers (default) */
.card-list-item + .card-list-item {
border-top: var(--border-0);
}
/* Inset dividers — border is inset from edges via pseudo-element */
.card-list-inset .card-list-item + .card-list-item {
border-top: none;
position: relative;
}
.card-list-inset .card-list-item + .card-list-item::before {
content: "";
position: absolute;
top: 0;
left: var(--size-4);
right: var(--size-4);
border-top: var(--border-0);
}

View File

@@ -34,3 +34,38 @@
(let [result (card/card-footer {} "Actions")] (let [result (card/card-footer {} "Actions")]
(is (= :footer (first result))) (is (= :footer (first result)))
(is (= "card-footer" (get-in result [1 :class])))))) (is (= "card-footer" (get-in result [1 :class]))))))
;; ─── Card List ─────────────────────────────────────────────────
(deftest card-list-class-list-test
(testing "default divider is full"
(is (= ["card-list"] (card/card-list-class-list {}))))
(testing "explicit full divider"
(is (= ["card-list"] (card/card-list-class-list {:divider :full}))))
(testing "inset divider adds modifier"
(is (= ["card-list" "card-list-inset"] (card/card-list-class-list {:divider :inset})))))
(deftest card-list-test
(testing "renders a div with role list"
(let [result (card/card-list {} "Item")]
(is (= :div (first result)))
(is (= "card-list" (get-in result [1 :class])))
(is (= "list" (get-in result [1 :role])))
(is (= "Item" (nth result 2)))))
(testing "inset divider class"
(let [result (card/card-list {:divider :inset} "Item")]
(is (= "card-list card-list-inset" (get-in result [1 :class])))))
(testing "extra class gets appended"
(let [result (card/card-list {:class "extra"} "Item")]
(is (= "card-list extra" (get-in result [1 :class]))))))
(deftest card-list-item-test
(testing "renders a div with role listitem"
(let [result (card/card-list-item {} "Content")]
(is (= :div (first result)))
(is (= "card-list-item" (get-in result [1 :class])))
(is (= "listitem" (get-in result [1 :role])))
(is (= "Content" (nth result 2)))))
(testing "extra class gets appended"
(let [result (card/card-list-item {:class "extra"} "Content")]
(is (= "card-list-item extra" (get-in result [1 :class]))))))