From e9e0b15e16088463e2f28f1fbc7966eb111a0e25 Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Wed, 11 Mar 2026 18:38:20 +0100 Subject: [PATCH] feat: add separator component Horizontal and vertical separator (divider) with CSS classes, ARIA role="none", and data-orientation attribute. Includes dark mode support, unit tests, and demos in all three dev targets. --- bb.edn | 2 ++ dev/hiccup/src/dev/hiccup.clj | 24 ++++++++++++- dev/replicant/src/dev/replicant.cljs | 24 ++++++++++++- dev/squint/src/dev/squint.cljs | 24 ++++++++++++- src/ui/separator.cljc | 53 ++++++++++++++++++++++++++++ src/ui/separator.css | 27 ++++++++++++++ test/ui/separator_test.clj | 39 ++++++++++++++++++++ 7 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 src/ui/separator.cljc create mode 100644 src/ui/separator.css create mode 100644 test/ui/separator_test.clj diff --git a/bb.edn b/bb.edn index 5fbd5bd..adc7a02 100644 --- a/bb.edn +++ b/bb.edn @@ -39,6 +39,7 @@ [ui.icon-test] [ui.sidebar-test] [ui.chip-test] + [ui.separator-test] [ui.theme-test]) :task (let [{:keys [fail error]} (t/run-tests 'ui.button-test @@ -59,6 +60,7 @@ 'ui.icon-test 'ui.sidebar-test 'ui.chip-test + 'ui.separator-test 'ui.theme-test)] (when (pos? (+ fail error)) (System/exit 1)))} diff --git a/dev/hiccup/src/dev/hiccup.clj b/dev/hiccup/src/dev/hiccup.clj index d3026d1..da9d0f2 100644 --- a/dev/hiccup/src/dev/hiccup.clj +++ b/dev/hiccup/src/dev/hiccup.clj @@ -18,7 +18,8 @@ [ui.pagination :as pagination] [ui.form :as form] [ui.sidebar :as sidebar] - [ui.icon :as icon])) + [ui.icon :as icon] + [ui.separator :as separator])) ;; ── Query Params ──────────────────────────────────────────────────── @@ -228,6 +229,24 @@ (pagination/pagination {:current 3 :total 5 :href-fn (fn [p] (str "#page-" p))}))) +(defn separator-demo [] + (section "Separator" + ;; Basic horizontal + [:div {:style "max-width: 24rem;"} + [:div {:style "display: flex; flex-direction: column; gap: 0.375rem;"} + [:div {:style "font-weight: 500; line-height: 1;"} "Clojure UI"] + [:div {:style "color: var(--fg-2); font-size: var(--font-sm);"} "A cross-target component library"]] + [:div {:style "margin: 1rem 0;"} + (separator/separator {})] + [:p {:style "font-size: var(--font-sm);"} "Build once, render everywhere — Hiccup, Replicant, and Squint."]] + ;; Vertical separator + [:div {:style "display: flex; align-items: center; gap: 1rem; height: 1.25rem;"} + [:span {:style "font-size: var(--font-sm);"} "Blog"] + (separator/separator {:orientation :vertical}) + [:span {:style "font-size: var(--font-sm);"} "Docs"] + (separator/separator {:orientation :vertical}) + [:span {:style "font-size: var(--font-sm);"} "Source"]])) + (defn form-demo [] (section "Form" [:form {:style "max-width: 480px;"} @@ -297,6 +316,7 @@ (tooltip-demo) (breadcrumb-demo) (pagination-demo) + (separator-demo) (form-demo)]) (def icon-categories @@ -403,6 +423,8 @@ {:label "Spinner" :anchor "spinner"} {:label "Skeleton" :anchor "skeleton"} {:label "Tooltip" :anchor "tooltip"}]} + {:title "Layout" + :items [{:label "Separator" :anchor "separator"}]} {:title "Navigation" :items [{:label "Breadcrumb" :anchor "breadcrumb"} {:label "Pagination" :anchor "pagination"}]}]) diff --git a/dev/replicant/src/dev/replicant.cljs b/dev/replicant/src/dev/replicant.cljs index d79db88..c2c4e89 100644 --- a/dev/replicant/src/dev/replicant.cljs +++ b/dev/replicant/src/dev/replicant.cljs @@ -17,7 +17,8 @@ [ui.pagination :as pagination] [ui.form :as form] [ui.sidebar :as sidebar] - [ui.icon :as icon])) + [ui.icon :as icon] + [ui.separator :as separator])) ;; ── State ─────────────────────────────────────────────────────────── @@ -192,6 +193,24 @@ (pagination/pagination {:current 3 :total 5 :on-click (fn [p] (js/console.log (str "Page: " p)))}))) +(defn separator-demo [] + (section "Separator" + ;; Basic horizontal + [:div {:style {:max-width "24rem"}} + [:div {:style {:display "flex" :flex-direction "column" :gap "0.375rem"}} + [:div {:style {:font-weight "500" :line-height "1"}} "Clojure UI"] + [:div {:style {:color "var(--fg-2)" :font-size "var(--font-sm)"}} "A cross-target component library"]] + [:div {:style {:margin "1rem 0"}} + (separator/separator {})] + [:p {:style {:font-size "var(--font-sm)"}} "Build once, render everywhere — Hiccup, Replicant, and Squint."]] + ;; Vertical separator + [:div {:style {:display "flex" :align-items "center" :gap "1rem" :height "1.25rem"}} + [:span {:style {:font-size "var(--font-sm)"}} "Blog"] + (separator/separator {:orientation :vertical}) + [:span {:style {:font-size "var(--font-sm)"}} "Docs"] + (separator/separator {:orientation :vertical}) + [:span {:style {:font-size "var(--font-sm)"}} "Source"]])) + (defn form-demo [] (section "Form" [:form {:style {:max-width "480px"}} @@ -255,6 +274,7 @@ (tooltip-demo) (breadcrumb-demo) (pagination-demo) + (separator-demo) (form-demo)]) (def icon-categories @@ -360,6 +380,8 @@ {:label "Spinner" :anchor "spinner"} {:label "Skeleton" :anchor "skeleton"} {:label "Tooltip" :anchor "tooltip"}]} + {:title "Layout" + :items [{:label "Separator" :anchor "separator"}]} {:title "Navigation" :items [{:label "Breadcrumb" :anchor "breadcrumb"} {:label "Pagination" :anchor "pagination"}]}]) diff --git a/dev/squint/src/dev/squint.cljs b/dev/squint/src/dev/squint.cljs index 587dcce..5492dd1 100644 --- a/dev/squint/src/dev/squint.cljs +++ b/dev/squint/src/dev/squint.cljs @@ -16,7 +16,8 @@ [ui.pagination :as pagination] [ui.form :as form] [ui.sidebar :as sidebar] - [ui.icon :as icon])) + [ui.icon :as icon] + [ui.separator :as separator])) ;; ── State ─────────────────────────────────────────────────────────── @@ -210,6 +211,24 @@ (pagination/pagination {:current 3 :total 5 :on-click (fn [p] (js/console.log (str "Page: " p)))}))) +(defn separator-demo [] + (section "Separator" + ;; Basic horizontal + [:div {:style {"max-width" "24rem"}} + [:div {:style {"display" "flex" "flex-direction" "column" "gap" "0.375rem"}} + [:div {:style {"font-weight" "500" "line-height" "1"}} "Clojure UI"] + [:div {:style {"color" "var(--fg-2)" "font-size" "var(--font-sm)"}} "A cross-target component library"]] + [:div {:style {"margin" "1rem 0"}} + (separator/separator {})] + [:p {:style {"font-size" "var(--font-sm)"}} "Build once, render everywhere — Hiccup, Replicant, and Squint."]] + ;; Vertical separator + [:div {:style {"display" "flex" "align-items" "center" "gap" "1rem" "height" "1.25rem"}} + [:span {:style {"font-size" "var(--font-sm)"}} "Blog"] + (separator/separator {:orientation "vertical"}) + [:span {:style {"font-size" "var(--font-sm)"}} "Docs"] + (separator/separator {:orientation "vertical"}) + [:span {:style {"font-size" "var(--font-sm)"}} "Source"]])) + (defn form-demo [] (section "Form" [:form {:style {"max-width" "480px"}} @@ -273,6 +292,7 @@ (tooltip-demo) (breadcrumb-demo) (pagination-demo) + (separator-demo) (form-demo)]) (def icon-categories @@ -386,6 +406,8 @@ {:label "Spinner" :anchor "spinner"} {:label "Skeleton" :anchor "skeleton"} {:label "Tooltip" :anchor "tooltip"}]} + {:title "Layout" + :items [{:label "Separator" :anchor "separator"}]} {:title "Navigation" :items [{:label "Breadcrumb" :anchor "breadcrumb"} {:label "Pagination" :anchor "pagination"}]}]) diff --git a/src/ui/separator.cljc b/src/ui/separator.cljc new file mode 100644 index 0000000..735f368 --- /dev/null +++ b/src/ui/separator.cljc @@ -0,0 +1,53 @@ +(ns ui.separator + (: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 separator-class-list + "Generate a vector of CSS class strings for a separator. + Orientation: :horizontal (default), :vertical." + [{:keys [orientation]}] + (let [o (or (some-> orientation kw-name) "horizontal")] + ["separator" (str "separator-" o)])) + +(defn separator-classes + "Generate CSS class string for a separator." + [opts] + (str/join " " (separator-class-list opts))) + +(defn separator + "Render a separator element. Works across all targets via reader conditionals. + + Props: + :orientation - :horizontal (default), :vertical + :class - additional CSS classes + :attrs - additional HTML attributes map" + [{:keys [orientation class attrs] :as _props}] + #?(:squint + (let [classes (cond-> (separator-classes {:orientation orientation}) + class (str " " class)) + base-attrs (merge {:class classes + :role "none" + :data-orientation (or (some-> orientation kw-name) "horizontal")} + attrs)] + [:div base-attrs]) + + :cljs + (let [cls (separator-class-list {:orientation orientation}) + classes (cond-> cls class (conj class)) + base-attrs (merge {:class classes + :role "none" + :data-orientation (or (some-> orientation kw-name) "horizontal")} + attrs)] + [:div base-attrs]) + + :clj + (let [classes (cond-> (separator-classes {:orientation orientation}) + class (str " " class)) + base-attrs (merge {:class classes + :role "none" + :data-orientation (or (some-> orientation kw-name) "horizontal")} + attrs)] + [:div base-attrs]))) diff --git a/src/ui/separator.css b/src/ui/separator.css new file mode 100644 index 0000000..bf0fe95 --- /dev/null +++ b/src/ui/separator.css @@ -0,0 +1,27 @@ +/* Separator — visually or semantically separates content */ + +.separator { + flex-shrink: 0; + background: var(--gray-200); +} + +.separator-horizontal { + height: 1px; + width: 100%; +} + +.separator-vertical { + width: 1px; + align-self: stretch; +} + +/* Dark mode: use a lighter border shade */ +[data-theme="dark"] .separator { + background: var(--gray-800); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .separator { + background: var(--gray-800); + } +} diff --git a/test/ui/separator_test.clj b/test/ui/separator_test.clj new file mode 100644 index 0000000..89a0709 --- /dev/null +++ b/test/ui/separator_test.clj @@ -0,0 +1,39 @@ +(ns ui.separator-test + (:require [clojure.test :refer [deftest is testing]] + [ui.separator :as separator])) + +(deftest separator-class-list-test + (testing "default (horizontal)" + (is (= ["separator" "separator-horizontal"] (separator/separator-class-list {})))) + + (testing "explicit horizontal" + (is (= ["separator" "separator-horizontal"] (separator/separator-class-list {:orientation :horizontal})))) + + (testing "vertical" + (is (= ["separator" "separator-vertical"] (separator/separator-class-list {:orientation :vertical}))))) + +(deftest separator-classes-test + (testing "returns space-joined string" + (is (= "separator separator-horizontal" (separator/separator-classes {}))) + (is (= "separator separator-vertical" (separator/separator-classes {:orientation :vertical}))))) + +(deftest separator-component-test + (testing "renders a div with role=none and data-orientation" + (let [result (separator/separator {})] + (is (= :div (first result))) + (is (= "separator separator-horizontal" (get-in result [1 :class]))) + (is (= "none" (get-in result [1 :role]))) + (is (= "horizontal" (get-in result [1 :data-orientation]))))) + + (testing "vertical orientation" + (let [result (separator/separator {:orientation :vertical})] + (is (= "separator separator-vertical" (get-in result [1 :class]))) + (is (= "vertical" (get-in result [1 :data-orientation]))))) + + (testing "extra class is appended" + (let [result (separator/separator {:class "my-sep"})] + (is (= "separator separator-horizontal my-sep" (get-in result [1 :class]))))) + + (testing "attrs are merged" + (let [result (separator/separator {:attrs {:id "sep-1"}})] + (is (= "sep-1" (get-in result [1 :id]))))))