From 64bf5e029c196bdcab7d423f26d618bbc95c520f Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Wed, 11 Mar 2026 18:29:39 +0100 Subject: [PATCH] feat(card): add card-list component with full and inset dividers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- dev/hiccup/src/dev/hiccup.clj | 16 ++++++- dev/replicant/src/dev/replicant.cljs | 16 ++++++- dev/squint/src/dev/squint.cljs | 16 ++++++- src/ui/card.cljc | 64 ++++++++++++++++++++++++++++ src/ui/card.css | 36 ++++++++++++++++ test/ui/card_test.clj | 35 +++++++++++++++ 6 files changed, 180 insertions(+), 3 deletions(-) diff --git a/dev/hiccup/src/dev/hiccup.clj b/dev/hiccup/src/dev/hiccup.clj index abebf04..d3026d1 100644 --- a/dev/hiccup/src/dev/hiccup.clj +++ b/dev/hiccup/src/dev/hiccup.clj @@ -124,7 +124,21 @@ (card/card-body {} [:p "This is the card content. It can contain any HTML."]) (card/card-footer {} (button/button {:variant :secondary :size :sm} "Cancel") - (button/button {:variant :primary :size :sm} "Save"))))) + (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 [] (section "Accordion" diff --git a/dev/replicant/src/dev/replicant.cljs b/dev/replicant/src/dev/replicant.cljs index d10fb93..d79db88 100644 --- a/dev/replicant/src/dev/replicant.cljs +++ b/dev/replicant/src/dev/replicant.cljs @@ -86,7 +86,21 @@ (card/card-body {} [:p "This is the card content. It can contain any HTML."]) (card/card-footer {} (button/button {:variant :secondary :size :sm} "Cancel") - (button/button {:variant :primary :size :sm} "Save"))))) + (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 [] (section "Accordion" diff --git a/dev/squint/src/dev/squint.cljs b/dev/squint/src/dev/squint.cljs index 0c9c31b..587dcce 100644 --- a/dev/squint/src/dev/squint.cljs +++ b/dev/squint/src/dev/squint.cljs @@ -104,7 +104,21 @@ (card/card-body {} [:p "This is the card content. It can contain any HTML."]) (card/card-footer {} (button/button {:variant "secondary" :size "sm"} "Cancel") - (button/button {:variant "primary" :size "sm"} "Save"))))) + (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 [] (section "Accordion" diff --git a/src/ui/card.cljc b/src/ui/card.cljc index ff394a0..a043f53 100644 --- a/src/ui/card.cljc +++ b/src/ui/card.cljc @@ -1,6 +1,11 @@ (ns ui.card (: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 "Generate a vector of CSS class strings for a card." [_opts] @@ -65,3 +70,62 @@ (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))) + +;; ─── 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))) diff --git a/src/ui/card.css b/src/ui/card.css index bc85fe1..27d2894 100644 --- a/src/ui/card.css +++ b/src/ui/card.css @@ -63,3 +63,39 @@ font-size: var(--font-base); 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); +} diff --git a/test/ui/card_test.clj b/test/ui/card_test.clj index d41a5a1..6c6f0b0 100644 --- a/test/ui/card_test.clj +++ b/test/ui/card_test.clj @@ -34,3 +34,38 @@ (let [result (card/card-footer {} "Actions")] (is (= :footer (first result))) (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]))))))