diff --git a/bb.edn b/bb.edn index d27f60a..b7d48e2 100644 --- a/bb.edn +++ b/bb.edn @@ -17,8 +17,36 @@ {:doc "Run all unit tests" :requires ([clojure.test :as t] [ui.button-test] + [ui.alert-test] + [ui.badge-test] + [ui.card-test] + [ui.accordion-test] + [ui.table-test] + [ui.dialog-test] + [ui.spinner-test] + [ui.skeleton-test] + [ui.progress-test] + [ui.switch-test] + [ui.tooltip-test] + [ui.breadcrumb-test] + [ui.pagination-test] [ui.theme-test]) - :task (let [{:keys [fail error]} (t/run-tests 'ui.button-test 'ui.theme-test)] + :task (let [{:keys [fail error]} (t/run-tests + 'ui.button-test + 'ui.alert-test + 'ui.badge-test + 'ui.card-test + 'ui.accordion-test + 'ui.table-test + 'ui.dialog-test + 'ui.spinner-test + 'ui.skeleton-test + 'ui.progress-test + 'ui.switch-test + 'ui.tooltip-test + 'ui.breadcrumb-test + 'ui.pagination-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 55f9414..39c0980 100644 --- a/dev/hiccup/src/dev/hiccup.clj +++ b/dev/hiccup/src/dev/hiccup.clj @@ -1,35 +1,170 @@ (ns dev.hiccup (:require [org.httpkit.server :as http] [hiccup2.core :as h] - [ui.button :as button])) + [ui.button :as button] + [ui.alert :as alert] + [ui.badge :as badge] + [ui.card :as card] + [ui.accordion :as accordion] + [ui.table :as table] + [ui.dialog :as dialog] + [ui.spinner :as spinner] + [ui.skeleton :as skeleton] + [ui.progress :as progress] + [ui.switch :as switch] + [ui.tooltip :as tooltip] + [ui.breadcrumb :as breadcrumb] + [ui.pagination :as pagination])) -(def variants [:primary :secondary :ghost :danger]) -(def sizes [:sm :md :lg]) +(defn section [title & children] + [:section {:style "margin-bottom: 2.5rem;"} + [:h3 {:style "color: var(--fg-1); margin-bottom: 1rem; border-bottom: var(--border-0); padding-bottom: 0.5rem;"} title] + (into [:div {:style "display: flex; flex-direction: column; gap: 1rem;"}] children)]) -(defn button-grid [] - [:div {:style "display: grid; grid-template-columns: repeat(4, auto); gap: 1rem; align-items: center;"} - ;; Header row - [:div] - (for [size sizes] - [:div {:style "font-weight: 600; text-align: center; color: var(--fg-1); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em;"} - (name size)]) +;; ── Button ────────────────────────────────────────────────────────── +(def button-variants [:primary :secondary :ghost :danger]) +(def button-sizes [:sm :md :lg]) - ;; Variant rows - (for [variant variants] - (list - [:div {:style "font-weight: 600; color: var(--fg-1); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em;"} - (name variant)] - (for [size sizes] - [:div {:style "text-align: center;"} - (button/button {:variant variant :size size} - (str (name variant) " " (name size)))])))]) +(defn button-demo [] + (section "Button" + [:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"} + (for [v button-variants] + (button/button {:variant v} (name v)))] + [:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"} + (for [s button-sizes] + (button/button {:variant :primary :size s} (str "size " (name s))))] + [:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center;"} + (for [v button-variants] + (button/button {:variant v :disabled true} (str (name v) " disabled")))])) -(defn disabled-row [] - [:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap;"} - (for [variant variants] - (button/button {:variant variant :disabled true} - (str (name variant) " disabled")))]) +;; ── Alert ─────────────────────────────────────────────────────────── +(defn alert-demo [] + (section "Alert" + (alert/alert {:variant :success :title "Success!"} "Your changes have been saved.") + (alert/alert {:variant :warning :title "Warning!"} "Please review before continuing.") + (alert/alert {:variant :danger :title "Error!"} "Something went wrong.") + (alert/alert {:variant :info :title "Info"} "This is an informational alert.") + (alert/alert {:title "Neutral"} "A neutral alert with no variant."))) +;; ── Badge ─────────────────────────────────────────────────────────── +(defn badge-demo [] + (section "Badge" + [:div {:style "display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;"} + (badge/badge {} "Default") + (badge/badge {:variant :secondary} "Secondary") + (badge/badge {:variant :outline} "Outline") + (badge/badge {:variant :success} "Success") + (badge/badge {:variant :warning} "Warning") + (badge/badge {:variant :danger} "Danger")])) + +;; ── Card ──────────────────────────────────────────────────────────── +(defn card-demo [] + (section "Card" + (card/card {} + (card/card-header {} [:h4 "Card Title"] [:p "Card description goes here."]) + (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"))))) + +;; ── Accordion ─────────────────────────────────────────────────────── +(defn accordion-demo [] + (section "Accordion" + (accordion/accordion {:title "What is this framework?"} "A cross-target component library for Clojure, ClojureScript, and Squint.") + (accordion/accordion {:title "How do I use it?" :open true} "Just require the namespace and call the component functions.") + (accordion/accordion {:title "Is it accessible?"} "Yes, components follow ARIA best practices."))) + +;; ── Table ─────────────────────────────────────────────────────────── +(defn table-demo [] + (section "Table" + (table/table {:headers ["Name" "Email" "Role" "Status"] + :rows [["Alice Johnson" "alice@example.com" "Admin" "Active"] + ["Bob Smith" "bob@example.com" "Editor" "Active"] + ["Carol White" "carol@example.com" "Viewer" "Pending"]]}))) + +;; ── Dialog ────────────────────────────────────────────────────────── +(defn dialog-demo [] + (section "Dialog" + [:p {:style "color: var(--fg-2); font-size: var(--font-sm);"} "Click button to open dialog."] + (button/button {:variant :primary + :attrs {:onclick "document.getElementById('demo-dialog').showModal()"}} + "Open dialog") + (dialog/dialog {:id "demo-dialog"} + (dialog/dialog-header {} [:h3 "Dialog Title"] [:p "Are you sure you want to continue?"]) + (dialog/dialog-body {} [:p "This action cannot be undone."]) + (dialog/dialog-footer {} + (button/button {:variant :secondary :size :sm + :attrs {:onclick "document.getElementById('demo-dialog').close()"}} + "Cancel") + (button/button {:variant :primary :size :sm + :attrs {:onclick "document.getElementById('demo-dialog').close()"}} + "Confirm"))))) + +;; ── Spinner ───────────────────────────────────────────────────────── +(defn spinner-demo [] + (section "Spinner" + [:div {:style "display: flex; gap: 1.5rem; align-items: center;"} + (spinner/spinner {:size :sm}) + (spinner/spinner {}) + (spinner/spinner {:size :lg})])) + +;; ── Skeleton ──────────────────────────────────────────────────────── +(defn skeleton-demo [] + (section "Skeleton" + [:div {:style "max-width: 400px;"} + (skeleton/skeleton {:variant :heading}) + (skeleton/skeleton {:variant :line}) + (skeleton/skeleton {:variant :line}) + [:div {:style "display: flex; gap: 1rem; margin-top: var(--size-3);"} + (skeleton/skeleton {:variant :circle}) + [:div {:style "flex: 1;"} + (skeleton/skeleton {:variant :line}) + (skeleton/skeleton {:variant :line})]]])) + +;; ── Progress ──────────────────────────────────────────────────────── +(defn progress-demo [] + (section "Progress" + (progress/progress {:value 25}) + (progress/progress {:value 50 :variant :success}) + (progress/progress {:value 75 :variant :warning}) + (progress/progress {:value 90 :variant :danger}))) + +;; ── Switch ────────────────────────────────────────────────────────── +(defn switch-demo [] + (section "Switch" + [:div {:style "display: flex; flex-direction: column; gap: 0.75rem;"} + (switch/switch-toggle {:label "Notifications" :checked false}) + (switch/switch-toggle {:label "Dark mode" :checked true}) + (switch/switch-toggle {:label "Disabled off" :disabled true}) + (switch/switch-toggle {:label "Disabled on" :checked true :disabled true})])) + +;; ── Tooltip ───────────────────────────────────────────────────────── +(defn tooltip-demo [] + (section "Tooltip" + [:div {:style "display: flex; gap: 1.5rem; padding-top: 2rem;"} + (tooltip/tooltip {:text "Save your changes"} + (button/button {:variant :primary} "Save")) + (tooltip/tooltip {:text "Delete this item"} + (button/button {:variant :danger} "Delete")) + (tooltip/tooltip {:text "View profile"} + [:a {:href "#" :style "color: var(--accent);"} "Profile"])])) + +;; ── Breadcrumb ────────────────────────────────────────────────────── +(defn breadcrumb-demo [] + (section "Breadcrumb" + (breadcrumb/breadcrumb + {:items [{:label "Home" :href "#"} + {:label "Projects" :href "#"} + {:label "Oat Docs" :href "#"} + {:label "Components"}]}))) + +;; ── Pagination ────────────────────────────────────────────────────── +(defn pagination-demo [] + (section "Pagination" + (pagination/pagination {:current 3 :total 5 + :href-fn (fn [p] (str "#page-" p))}))) + +;; ── Page ──────────────────────────────────────────────────────────── (defn page [] (str "\n" @@ -47,10 +182,20 @@ [:button {:onclick "document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'" :style "padding: 0.5rem 1rem; cursor: pointer; border-radius: var(--radius-md); border: var(--border-0); background: var(--bg-1); color: var(--fg-0);"} "Toggle Dark Mode"]] - [:h3 {:style "color: var(--fg-1); margin-bottom: 1rem;"} "Button Grid"] - (button-grid) - [:h3 {:style "color: var(--fg-1); margin: 2rem 0 1rem;"} "Disabled States"] - (disabled-row)]]]))) + (button-demo) + (alert-demo) + (badge-demo) + (card-demo) + (accordion-demo) + (table-demo) + (dialog-demo) + (spinner-demo) + (skeleton-demo) + (progress-demo) + (switch-demo) + (tooltip-demo) + (breadcrumb-demo) + (pagination-demo)]]]))) (defn handler [{:keys [uri]}] (case uri diff --git a/dev/replicant/src/dev/replicant.cljs b/dev/replicant/src/dev/replicant.cljs index 979fbc4..e308061 100644 --- a/dev/replicant/src/dev/replicant.cljs +++ b/dev/replicant/src/dev/replicant.cljs @@ -1,43 +1,180 @@ (ns dev.replicant (:require [replicant.dom :as d] - [ui.button :as button])) + [ui.button :as button] + [ui.alert :as alert] + [ui.badge :as badge] + [ui.card :as card] + [ui.accordion :as accordion] + [ui.table :as table] + [ui.dialog :as dialog] + [ui.spinner :as spinner] + [ui.skeleton :as skeleton] + [ui.progress :as progress] + [ui.switch :as switch] + [ui.tooltip :as tooltip] + [ui.breadcrumb :as breadcrumb] + [ui.pagination :as pagination])) -(def variants [:primary :secondary :ghost :danger]) -(def sizes [:sm :md :lg]) +(defn section [title & children] + [:section {:style {:margin-bottom "2.5rem"}} + [:h3 {:style {:color "var(--fg-1)" :margin-bottom "1rem" + :border-bottom "var(--border-0)" :padding-bottom "0.5rem"}} title] + (into [:div {:style {:display "flex" :flex-direction "column" :gap "1rem"}}] children)]) -(defn button-grid [] - [:div {:style {:display "grid" - :grid-template-columns "repeat(4, auto)" - :gap "1rem" - :align-items "center"}} - [:div] - (for [size sizes] - [:div {:style {:font-weight "600" :text-align "center" :color "var(--fg-1)" - :font-size "0.75rem" :text-transform "uppercase" :letter-spacing "0.05em"}} - (name size)]) - (for [variant variants] - (list - [:div {:style {:font-weight "600" :color "var(--fg-1)" - :font-size "0.75rem" :text-transform "uppercase" :letter-spacing "0.05em"}} - (name variant)] - (for [size sizes] - [:div {:style {:text-align "center"}} - (button/button {:variant variant :size size - :on-click (fn [_] (js/console.log (str "Clicked: " (name variant) " " (name size))))} - (str (name variant) " " (name size)))])))]) +;; ── Button ────────────────────────────────────────────────────────── +(def button-variants [:primary :secondary :ghost :danger]) +(def button-sizes [:sm :md :lg]) -(defn disabled-row [] - [:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap"}} - (for [variant variants] - (button/button {:variant variant :disabled true} - (str (name variant) " disabled")))]) +(defn button-demo [] + (section "Button" + [:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}} + (for [v button-variants] + (button/button {:variant v :on-click (fn [_] (js/console.log (str "Clicked: " (name v))))} + (name v)))] + [:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}} + (for [s button-sizes] + (button/button {:variant :primary :size s} (str "size " (name s))))] + [:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap" :align-items "center"}} + (for [v button-variants] + (button/button {:variant v :disabled true} (str (name v) " disabled")))])) +;; ── Alert ─────────────────────────────────────────────────────────── +(defn alert-demo [] + (section "Alert" + (alert/alert {:variant :success :title "Success!"} "Your changes have been saved.") + (alert/alert {:variant :warning :title "Warning!"} "Please review before continuing.") + (alert/alert {:variant :danger :title "Error!"} "Something went wrong.") + (alert/alert {:variant :info :title "Info"} "This is an informational alert.") + (alert/alert {:title "Neutral"} "A neutral alert with no variant."))) + +;; ── Badge ─────────────────────────────────────────────────────────── +(defn badge-demo [] + (section "Badge" + [:div {:style {:display "flex" :gap "0.5rem" :flex-wrap "wrap" :align-items "center"}} + (badge/badge {} "Default") + (badge/badge {:variant :secondary} "Secondary") + (badge/badge {:variant :outline} "Outline") + (badge/badge {:variant :success} "Success") + (badge/badge {:variant :warning} "Warning") + (badge/badge {:variant :danger} "Danger")])) + +;; ── Card ──────────────────────────────────────────────────────────── +(defn card-demo [] + (section "Card" + (card/card {} + (card/card-header {} [:h4 "Card Title"] [:p "Card description goes here."]) + (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"))))) + +;; ── Accordion ─────────────────────────────────────────────────────── +(defn accordion-demo [] + (section "Accordion" + (accordion/accordion {:title "What is this framework?"} "A cross-target component library.") + (accordion/accordion {:title "How do I use it?" :open true} "Just require the namespace and call functions.") + (accordion/accordion {:title "Is it accessible?"} "Yes, follows ARIA best practices."))) + +;; ── Table ─────────────────────────────────────────────────────────── +(defn table-demo [] + (section "Table" + (table/table {:headers ["Name" "Email" "Role" "Status"] + :rows [["Alice Johnson" "alice@example.com" "Admin" "Active"] + ["Bob Smith" "bob@example.com" "Editor" "Active"] + ["Carol White" "carol@example.com" "Viewer" "Pending"]]}))) + +;; ── Dialog ────────────────────────────────────────────────────────── +(defn dialog-demo [] + (section "Dialog" + [:p {:style {:color "var(--fg-2)" :font-size "var(--font-sm)"}} "Click button to open dialog."] + (button/button {:variant :primary + :on-click (fn [_] + (when-let [el (.getElementById js/document "demo-dialog")] + (.showModal el)))} + "Open dialog") + (dialog/dialog {:id "demo-dialog"} + (dialog/dialog-header {} [:h3 "Dialog Title"] [:p "Are you sure you want to continue?"]) + (dialog/dialog-body {} [:p "This action cannot be undone."]) + (dialog/dialog-footer {} + (button/button {:variant :secondary :size :sm + :on-click (fn [_] (.close (.getElementById js/document "demo-dialog")))} + "Cancel") + (button/button {:variant :primary :size :sm + :on-click (fn [_] (.close (.getElementById js/document "demo-dialog")))} + "Confirm"))))) + +;; ── Spinner ───────────────────────────────────────────────────────── +(defn spinner-demo [] + (section "Spinner" + [:div {:style {:display "flex" :gap "1.5rem" :align-items "center"}} + (spinner/spinner {:size :sm}) + (spinner/spinner {}) + (spinner/spinner {:size :lg})])) + +;; ── Skeleton ──────────────────────────────────────────────────────── +(defn skeleton-demo [] + (section "Skeleton" + [:div {:style {:max-width "400px"}} + (skeleton/skeleton {:variant :heading}) + (skeleton/skeleton {:variant :line}) + (skeleton/skeleton {:variant :line}) + [:div {:style {:display "flex" :gap "1rem" :margin-top "var(--size-3)"}} + (skeleton/skeleton {:variant :circle}) + [:div {:style {:flex "1"}} + (skeleton/skeleton {:variant :line}) + (skeleton/skeleton {:variant :line})]]])) + +;; ── Progress ──────────────────────────────────────────────────────── +(defn progress-demo [] + (section "Progress" + (progress/progress {:value 25}) + (progress/progress {:value 50 :variant :success}) + (progress/progress {:value 75 :variant :warning}) + (progress/progress {:value 90 :variant :danger}))) + +;; ── Switch ────────────────────────────────────────────────────────── +(defn switch-demo [] + (section "Switch" + [:div {:style {:display "flex" :flex-direction "column" :gap "0.75rem"}} + (switch/switch-toggle {:label "Notifications" :checked false}) + (switch/switch-toggle {:label "Dark mode" :checked true}) + (switch/switch-toggle {:label "Disabled off" :disabled true}) + (switch/switch-toggle {:label "Disabled on" :checked true :disabled true})])) + +;; ── Tooltip ───────────────────────────────────────────────────────── +(defn tooltip-demo [] + (section "Tooltip" + [:div {:style {:display "flex" :gap "1.5rem" :padding-top "2rem"}} + (tooltip/tooltip {:text "Save your changes"} + (button/button {:variant :primary} "Save")) + (tooltip/tooltip {:text "Delete this item"} + (button/button {:variant :danger} "Delete")) + (tooltip/tooltip {:text "View profile"} + [:a {:href "#" :style {:color "var(--accent)"}} "Profile"])])) + +;; ── Breadcrumb ────────────────────────────────────────────────────── +(defn breadcrumb-demo [] + (section "Breadcrumb" + (breadcrumb/breadcrumb + {:items [{:label "Home" :href "#"} + {:label "Projects" :href "#"} + {:label "Oat Docs" :href "#"} + {:label "Components"}]}))) + +;; ── Pagination ────────────────────────────────────────────────────── +(defn pagination-demo [] + (section "Pagination" + (pagination/pagination {:current 3 :total 5 + :on-click (fn [p] (js/console.log (str "Page: " p)))}))) + +;; ── Theme toggle ──────────────────────────────────────────────────── (defn toggle-theme! [_e] (let [el (.-documentElement js/document) current (.. el -dataset -theme)] (set! (.. el -dataset -theme) (if (= current "dark") "light" "dark")))) +;; ── App ───────────────────────────────────────────────────────────── (defn app [] [:div {:style {:max-width "800px" :margin "0 auto"}} [:div {:style {:display "flex" :justify-content "space-between" :align-items "center" :margin-bottom "2rem"}} @@ -46,10 +183,20 @@ :style {:padding "0.5rem 1rem" :cursor "pointer" :border-radius "var(--radius-md)" :border "var(--border-0)" :background "var(--bg-1)" :color "var(--fg-0)"}} "Toggle Dark Mode"]] - [:h3 {:style {:color "var(--fg-1)" :margin-bottom "1rem"}} "Button Grid"] - (button-grid) - [:h3 {:style {:color "var(--fg-1)" :margin "2rem 0 1rem"}} "Disabled States"] - (disabled-row)]) + (button-demo) + (alert-demo) + (badge-demo) + (card-demo) + (accordion-demo) + (table-demo) + (dialog-demo) + (spinner-demo) + (skeleton-demo) + (progress-demo) + (switch-demo) + (tooltip-demo) + (breadcrumb-demo) + (pagination-demo)]) (defn ^:export init! [] (d/set-dispatch! (fn [_ _])) diff --git a/dev/squint/src/dev/squint.cljs b/dev/squint/src/dev/squint.cljs index 3efb764..95593a8 100644 --- a/dev/squint/src/dev/squint.cljs +++ b/dev/squint/src/dev/squint.cljs @@ -1,9 +1,19 @@ (ns dev.squint (:require ["eucalypt" :as eu] - [ui.button :as button])) - -(def variants ["primary" "secondary" "ghost" "danger"]) -(def sizes ["sm" "md" "lg"]) + [ui.button :as button] + [ui.alert :as alert] + [ui.badge :as badge] + [ui.card :as card] + [ui.accordion :as accordion] + [ui.table :as table] + [ui.dialog :as dialog] + [ui.spinner :as spinner] + [ui.skeleton :as skeleton] + [ui.progress :as progress] + [ui.switch :as switch] + [ui.tooltip :as tooltip] + [ui.breadcrumb :as breadcrumb] + [ui.pagination :as pagination])) (defn toggle-theme! [_e] (let [el (.-documentElement js/document) @@ -11,46 +21,161 @@ (set! (.. el -dataset -theme) (if (= current "dark") "light" "dark")))) -(def label-style {"font-weight" "600" - "color" "var(--fg-1)" - "font-size" "0.75rem" - "text-transform" "uppercase" - "letter-spacing" "0.05em"}) +(defn section [title & children] + (into [:section {:style {"margin-bottom" "2.5rem"}} + [:h3 {:style {"color" "var(--fg-1)" "margin-bottom" "1rem" + "border-bottom" "var(--border-0)" "padding-bottom" "0.5rem"}} title]] + children)) -(defn button-grid [] - (into - [:div {:style {"display" "grid" - "grid-template-columns" "repeat(4, auto)" - "gap" "1rem" - "align-items" "center"}} - [:div] - [:div {:style (merge label-style {"text-align" "center"})} "sm"] - [:div {:style (merge label-style {"text-align" "center"})} "md"] - [:div {:style (merge label-style {"text-align" "center"})} "lg"]] - (mapcat (fn [variant] - [[:div {:style label-style} variant] - [:div {:style {"text-align" "center"}} - (button/button {:variant variant :size "sm" - :on-click (fn [_] (js/console.log (str "Clicked: " variant " sm")))} - (str variant " sm"))] - [:div {:style {"text-align" "center"}} - (button/button {:variant variant :size "md" - :on-click (fn [_] (js/console.log (str "Clicked: " variant " md")))} - (str variant " md"))] - [:div {:style {"text-align" "center"}} - (button/button {:variant variant :size "lg" - :on-click (fn [_] (js/console.log (str "Clicked: " variant " lg")))} - (str variant " lg"))]]) - variants))) +;; ── Button ────────────────────────────────────────────────────────── +(def button-variants ["primary" "secondary" "ghost" "danger"]) +(def button-sizes ["sm" "md" "lg"]) -(defn disabled-row [] - (into - [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap"}}] - (map (fn [variant] - (button/button {:variant variant :disabled true} - (str variant " disabled"))) - variants))) +(defn button-demo [] + (section "Button" + (into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}] + (map (fn [v] + (button/button {:variant v :on-click (fn [_] (js/console.log (str "Clicked: " v)))} v)) + button-variants)) + (into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}] + (map (fn [s] + (button/button {:variant "primary" :size s} (str "size " s))) + button-sizes)) + (into [:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap" "align-items" "center"}}] + (map (fn [v] + (button/button {:variant v :disabled true} (str v " disabled"))) + button-variants)))) +;; ── Alert ─────────────────────────────────────────────────────────── +(defn alert-demo [] + (section "Alert" + (alert/alert {:variant "success" :title "Success!"} "Your changes have been saved.") + (alert/alert {:variant "warning" :title "Warning!"} "Please review before continuing.") + (alert/alert {:variant "danger" :title "Error!"} "Something went wrong.") + (alert/alert {:variant "info" :title "Info"} "This is an informational alert.") + (alert/alert {:title "Neutral"} "A neutral alert with no variant."))) + +;; ── Badge ─────────────────────────────────────────────────────────── +(defn badge-demo [] + (section "Badge" + (into [:div {:style {"display" "flex" "gap" "0.5rem" "flex-wrap" "wrap" "align-items" "center"}}] + [(badge/badge {} "Default") + (badge/badge {:variant "secondary"} "Secondary") + (badge/badge {:variant "outline"} "Outline") + (badge/badge {:variant "success"} "Success") + (badge/badge {:variant "warning"} "Warning") + (badge/badge {:variant "danger"} "Danger")]))) + +;; ── Card ──────────────────────────────────────────────────────────── +(defn card-demo [] + (section "Card" + (card/card {} + (card/card-header {} [:h4 "Card Title"] [:p "Card description goes here."]) + (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"))))) + +;; ── Accordion ─────────────────────────────────────────────────────── +(defn accordion-demo [] + (section "Accordion" + (accordion/accordion {:title "What is this framework?"} "A cross-target component library.") + (accordion/accordion {:title "How do I use it?" :open true} "Just require the namespace and call functions.") + (accordion/accordion {:title "Is it accessible?"} "Yes, follows ARIA best practices."))) + +;; ── Table ─────────────────────────────────────────────────────────── +(defn table-demo [] + (section "Table" + (table/table {:headers ["Name" "Email" "Role" "Status"] + :rows [["Alice Johnson" "alice@example.com" "Admin" "Active"] + ["Bob Smith" "bob@example.com" "Editor" "Active"] + ["Carol White" "carol@example.com" "Viewer" "Pending"]]}))) + +;; ── Dialog ────────────────────────────────────────────────────────── +(defn dialog-demo [] + (section "Dialog" + [:p {:style {"color" "var(--fg-2)" "font-size" "var(--font-sm)"}} "Click button to open dialog."] + (button/button {:variant "primary" + :on-click (fn [_] + (when-let [el (js/document.getElementById "demo-dialog-sq")] + (.showModal el)))} + "Open dialog") + (dialog/dialog {:id "demo-dialog-sq"} + (dialog/dialog-header {} [:h3 "Dialog Title"] [:p "Are you sure you want to continue?"]) + (dialog/dialog-body {} [:p "This action cannot be undone."]) + (dialog/dialog-footer {} + (button/button {:variant "secondary" :size "sm" + :on-click (fn [_] (.close (js/document.getElementById "demo-dialog-sq")))} + "Cancel") + (button/button {:variant "primary" :size "sm" + :on-click (fn [_] (.close (js/document.getElementById "demo-dialog-sq")))} + "Confirm"))))) + +;; ── Spinner ───────────────────────────────────────────────────────── +(defn spinner-demo [] + (section "Spinner" + [:div {:style {"display" "flex" "gap" "1.5rem" "align-items" "center"}} + (spinner/spinner {:size "sm"}) + (spinner/spinner {}) + (spinner/spinner {:size "lg"})])) + +;; ── Skeleton ──────────────────────────────────────────────────────── +(defn skeleton-demo [] + (section "Skeleton" + [:div {:style {"max-width" "400px"}} + (skeleton/skeleton {:variant "heading"}) + (skeleton/skeleton {:variant "line"}) + (skeleton/skeleton {:variant "line"}) + [:div {:style {"display" "flex" "gap" "1rem" "margin-top" "var(--size-3)"}} + (skeleton/skeleton {:variant "circle"}) + [:div {:style {"flex" "1"}} + (skeleton/skeleton {:variant "line"}) + (skeleton/skeleton {:variant "line"})]]])) + +;; ── Progress ──────────────────────────────────────────────────────── +(defn progress-demo [] + (section "Progress" + (progress/progress {:value 25}) + (progress/progress {:value 50 :variant "success"}) + (progress/progress {:value 75 :variant "warning"}) + (progress/progress {:value 90 :variant "danger"}))) + +;; ── Switch ────────────────────────────────────────────────────────── +(defn switch-demo [] + (section "Switch" + [:div {:style {"display" "flex" "flex-direction" "column" "gap" "0.75rem"}} + (switch/switch-toggle {:label "Notifications" :checked false}) + (switch/switch-toggle {:label "Dark mode" :checked true}) + (switch/switch-toggle {:label "Disabled off" :disabled true}) + (switch/switch-toggle {:label "Disabled on" :checked true :disabled true})])) + +;; ── Tooltip ───────────────────────────────────────────────────────── +(defn tooltip-demo [] + (section "Tooltip" + [:div {:style {"display" "flex" "gap" "1.5rem" "padding-top" "2rem"}} + (tooltip/tooltip {:text "Save your changes"} + (button/button {:variant "primary"} "Save")) + (tooltip/tooltip {:text "Delete this item"} + (button/button {:variant "danger"} "Delete")) + (tooltip/tooltip {:text "View profile"} + [:a {:href "#" :style {"color" "var(--accent)"}} "Profile"])])) + +;; ── Breadcrumb ────────────────────────────────────────────────────── +(defn breadcrumb-demo [] + (section "Breadcrumb" + (breadcrumb/breadcrumb + {:items [{:label "Home" :href "#"} + {:label "Projects" :href "#"} + {:label "Oat Docs" :href "#"} + {:label "Components"}]}))) + +;; ── Pagination ────────────────────────────────────────────────────── +(defn pagination-demo [] + (section "Pagination" + (pagination/pagination {:current 3 :total 5 + :on-click (fn [p] (js/console.log (str "Page: " p)))}))) + +;; ── App ───────────────────────────────────────────────────────────── (defn app [] [:div {:style {"max-width" "800px" "margin" "0 auto"}} [:div {:style {"display" "flex" "justify-content" "space-between" "align-items" "center" "margin-bottom" "2rem"}} @@ -59,10 +184,20 @@ :style {"padding" "0.5rem 1rem" "cursor" "pointer" "border-radius" "var(--radius-md)" "border" "var(--border-0)" "background" "var(--bg-1)" "color" "var(--fg-0)"}} "Toggle Dark Mode"]] - [:h3 {:style {"color" "var(--fg-1)" "margin-bottom" "1rem"}} "Button Grid"] - (button-grid) - [:h3 {:style {"color" "var(--fg-1)" "margin" "2rem 0 1rem"}} "Disabled States"] - (disabled-row)]) + (button-demo) + (alert-demo) + (badge-demo) + (card-demo) + (accordion-demo) + (table-demo) + (dialog-demo) + (spinner-demo) + (skeleton-demo) + (progress-demo) + (switch-demo) + (tooltip-demo) + (breadcrumb-demo) + (pagination-demo)]) (defn init! [] (eu/render (app) (js/document.getElementById "app"))) diff --git a/src/theme/tokens.edn b/src/theme/tokens.edn index 80a933c..51d399d 100644 --- a/src/theme/tokens.edn +++ b/src/theme/tokens.edn @@ -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" diff --git a/src/ui/accordion.cljc b/src/ui/accordion.cljc new file mode 100644 index 0000000..aee9fc7 --- /dev/null +++ b/src/ui/accordion.cljc @@ -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)]]))))) diff --git a/src/ui/accordion.css b/src/ui/accordion.css new file mode 100644 index 0000000..4c1de29 --- /dev/null +++ b/src/ui/accordion.css @@ -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); +} diff --git a/src/ui/alert.cljc b/src/ui/alert.cljc new file mode 100644 index 0000000..b795c17 --- /dev/null +++ b/src/ui/alert.cljc @@ -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))))))) diff --git a/src/ui/alert.css b/src/ui/alert.css new file mode 100644 index 0000000..ba81506 --- /dev/null +++ b/src/ui/alert.css @@ -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)); +} diff --git a/src/ui/badge.cljc b/src/ui/badge.cljc new file mode 100644 index 0000000..50aff0f --- /dev/null +++ b/src/ui/badge.cljc @@ -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)))) diff --git a/src/ui/badge.css b/src/ui/badge.css new file mode 100644 index 0000000..f22819f --- /dev/null +++ b/src/ui/badge.css @@ -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)); +} diff --git a/src/ui/breadcrumb.cljc b/src/ui/breadcrumb.cljc new file mode 100644 index 0000000..aabc19e --- /dev/null +++ b/src/ui/breadcrumb.cljc @@ -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))])))) diff --git a/src/ui/breadcrumb.css b/src/ui/breadcrumb.css new file mode 100644 index 0000000..6ae1ebe --- /dev/null +++ b/src/ui/breadcrumb.css @@ -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; +} diff --git a/src/ui/card.cljc b/src/ui/card.cljc new file mode 100644 index 0000000..ff394a0 --- /dev/null +++ b/src/ui/card.cljc @@ -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))) diff --git a/src/ui/card.css b/src/ui/card.css new file mode 100644 index 0000000..247f56b --- /dev/null +++ b/src/ui/card.css @@ -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; +} diff --git a/src/ui/dialog.cljc b/src/ui/dialog.cljc new file mode 100644 index 0000000..c5a3da6 --- /dev/null +++ b/src/ui/dialog.cljc @@ -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))) diff --git a/src/ui/dialog.css b/src/ui/dialog.css new file mode 100644 index 0000000..dec105b --- /dev/null +++ b/src/ui/dialog.css @@ -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; +} diff --git a/src/ui/form.css b/src/ui/form.css new file mode 100644 index 0000000..2eda37c --- /dev/null +++ b/src/ui/form.css @@ -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; +} diff --git a/src/ui/grid.css b/src/ui/grid.css new file mode 100644 index 0000000..b4bc2b1 --- /dev/null +++ b/src/ui/grid.css @@ -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; + } +} diff --git a/src/ui/pagination.cljc b/src/ui/pagination.cljc new file mode 100644 index 0000000..3d28e03 --- /dev/null +++ b/src/ui/pagination.cljc @@ -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)]))])))) diff --git a/src/ui/pagination.css b/src/ui/pagination.css new file mode 100644 index 0000000..070a958 --- /dev/null +++ b/src/ui/pagination.css @@ -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; +} diff --git a/src/ui/progress.cljc b/src/ui/progress.cljc new file mode 100644 index 0000000..ffc5514 --- /dev/null +++ b/src/ui/progress.cljc @@ -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)}]])))) diff --git a/src/ui/progress.css b/src/ui/progress.css new file mode 100644 index 0000000..15834bf --- /dev/null +++ b/src/ui/progress.css @@ -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); +} diff --git a/src/ui/skeleton.cljc b/src/ui/skeleton.cljc new file mode 100644 index 0000000..4487301 --- /dev/null +++ b/src/ui/skeleton.cljc @@ -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]))) diff --git a/src/ui/skeleton.css b/src/ui/skeleton.css new file mode 100644 index 0000000..4c11de8 --- /dev/null +++ b/src/ui/skeleton.css @@ -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; } +} diff --git a/src/ui/spinner.cljc b/src/ui/spinner.cljc new file mode 100644 index 0000000..4a63c2a --- /dev/null +++ b/src/ui/spinner.cljc @@ -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]))) diff --git a/src/ui/spinner.css b/src/ui/spinner.css new file mode 100644 index 0000000..2e3f149 --- /dev/null +++ b/src/ui/spinner.css @@ -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); + } +} diff --git a/src/ui/switch.cljc b/src/ui/switch.cljc new file mode 100644 index 0000000..0279473 --- /dev/null +++ b/src/ui/switch.cljc @@ -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])]))) diff --git a/src/ui/switch.css b/src/ui/switch.css new file mode 100644 index 0000000..d901cf3 --- /dev/null +++ b/src/ui/switch.css @@ -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; +} diff --git a/src/ui/table.cljc b/src/ui/table.cljc new file mode 100644 index 0000000..afa424d --- /dev/null +++ b/src/ui/table.cljc @@ -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))]]))) diff --git a/src/ui/table.css b/src/ui/table.css new file mode 100644 index 0000000..98a0d5b --- /dev/null +++ b/src/ui/table.css @@ -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); +} diff --git a/src/ui/tooltip.cljc b/src/ui/tooltip.cljc new file mode 100644 index 0000000..cdfb8ec --- /dev/null +++ b/src/ui/tooltip.cljc @@ -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)))) diff --git a/src/ui/tooltip.css b/src/ui/tooltip.css new file mode 100644 index 0000000..9ad5371 --- /dev/null +++ b/src/ui/tooltip.css @@ -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); +} diff --git a/src/ui/utilities.css b/src/ui/utilities.css new file mode 100644 index 0000000..58f2543 --- /dev/null +++ b/src/ui/utilities.css @@ -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; +} diff --git a/test/ui/accordion_test.clj b/test/ui/accordion_test.clj new file mode 100644 index 0000000..958ad32 --- /dev/null +++ b/test/ui/accordion_test.clj @@ -0,0 +1,31 @@ +(ns ui.accordion-test + (:require [clojure.test :refer [deftest is testing]] + [ui.accordion :as accordion])) + +(deftest accordion-class-list-test + (testing "closed accordion" + (is (= ["accordion"] (accordion/accordion-class-list {}))) + (is (= ["accordion"] (accordion/accordion-class-list {:open false})))) + + (testing "open accordion" + (is (= ["accordion" "accordion--open"] (accordion/accordion-class-list {:open true}))))) + +(deftest accordion-classes-test + (testing "space-joined output" + (is (= "accordion" (accordion/accordion-classes {}))) + (is (= "accordion accordion--open" (accordion/accordion-classes {:open true}))))) + +(deftest accordion-component-test + (testing "closed accordion renders trigger only" + (let [result (accordion/accordion {:title "Question?"} "Answer.")] + (is (= :div (first result))) + (is (= "accordion" (get-in result [1 :class]))) + ;; trigger is present + (is (= "accordion-trigger" (get-in result [2 1 :class]))))) + + (testing "open accordion includes content" + (let [result (accordion/accordion {:title "Q?" :open true} "A.")] + (is (= "accordion accordion--open" (get-in result [1 :class]))) + ;; should contain accordion-content div + (is (some #(and (vector? %) (= "accordion-content" (get-in % [1 :class]))) + (rest (rest result))))))) diff --git a/test/ui/alert_test.clj b/test/ui/alert_test.clj new file mode 100644 index 0000000..6882b8e --- /dev/null +++ b/test/ui/alert_test.clj @@ -0,0 +1,35 @@ +(ns ui.alert-test + (:require [clojure.test :refer [deftest is testing]] + [ui.alert :as alert])) + +(deftest alert-class-list-test + (testing "neutral (no variant)" + (is (= ["alert"] (alert/alert-class-list {})))) + + (testing "explicit variants" + (is (= ["alert" "alert-success"] (alert/alert-class-list {:variant :success}))) + (is (= ["alert" "alert-warning"] (alert/alert-class-list {:variant :warning}))) + (is (= ["alert" "alert-danger"] (alert/alert-class-list {:variant :danger}))) + (is (= ["alert" "alert-info"] (alert/alert-class-list {:variant :info}))))) + +(deftest alert-classes-test + (testing "space-joined output" + (is (= "alert" (alert/alert-classes {}))) + (is (= "alert alert-success" (alert/alert-classes {:variant :success}))))) + +(deftest alert-component-test + (testing "basic alert renders correct hiccup" + (let [result (alert/alert {:variant :success :title "Done!"} "Saved.")] + (is (= :div (first result))) + (is (= "alert alert-success" (get-in result [1 :class]))) + (is (= "alert" (get-in result [1 :role]))))) + + (testing "alert with title includes title paragraph" + (let [result (alert/alert {:title "Title"} "Body")] + (is (some #(and (vector? %) (= "alert-title" (get-in % [1 :class]))) + (rest (rest result)))))) + + (testing "alert without title has no title paragraph" + (let [result (alert/alert {} "Body")] + (is (not (some #(and (vector? %) (= "alert-title" (get-in % [1 :class]))) + (rest (rest result)))))))) diff --git a/test/ui/badge_test.clj b/test/ui/badge_test.clj new file mode 100644 index 0000000..0978ab2 --- /dev/null +++ b/test/ui/badge_test.clj @@ -0,0 +1,31 @@ +(ns ui.badge-test + (:require [clojure.test :refer [deftest is testing]] + [ui.badge :as badge])) + +(deftest badge-class-list-test + (testing "default variant (primary)" + (is (= ["badge"] (badge/badge-class-list {})))) + + (testing "explicit variants" + (is (= ["badge"] (badge/badge-class-list {:variant :primary}))) + (is (= ["badge" "badge-secondary"] (badge/badge-class-list {:variant :secondary}))) + (is (= ["badge" "badge-outline"] (badge/badge-class-list {:variant :outline}))) + (is (= ["badge" "badge-success"] (badge/badge-class-list {:variant :success}))) + (is (= ["badge" "badge-warning"] (badge/badge-class-list {:variant :warning}))) + (is (= ["badge" "badge-danger"] (badge/badge-class-list {:variant :danger}))))) + +(deftest badge-classes-test + (testing "space-joined output" + (is (= "badge" (badge/badge-classes {}))) + (is (= "badge badge-danger" (badge/badge-classes {:variant :danger}))))) + +(deftest badge-component-test + (testing "renders a span" + (let [result (badge/badge {} "New")] + (is (= :span (first result))) + (is (= "badge" (get-in result [1 :class]))) + (is (= "New" (nth result 2))))) + + (testing "extra class gets appended" + (let [result (badge/badge {:class "extra"} "X")] + (is (= "badge extra" (get-in result [1 :class])))))) diff --git a/test/ui/breadcrumb_test.clj b/test/ui/breadcrumb_test.clj new file mode 100644 index 0000000..95f9946 --- /dev/null +++ b/test/ui/breadcrumb_test.clj @@ -0,0 +1,37 @@ +(ns ui.breadcrumb-test + (:require [clojure.test :refer [deftest is testing]] + [ui.breadcrumb :as breadcrumb])) + +(deftest breadcrumb-component-test + (testing "renders a nav with breadcrumb list" + (let [result (breadcrumb/breadcrumb + {:items [{:label "Home" :href "/"} + {:label "Projects" :href "/projects"} + {:label "Current"}]})] + (is (= :nav (first result))) + (is (= "Breadcrumb" (get-in result [1 :aria-label]))) + ;; ol is inside + (let [ol (nth result 2)] + (is (= :ol (first ol))) + (is (= "breadcrumb" (get-in ol [1 :class]))) + ;; 3 items + (is (= 3 (count (drop 2 ol))))))) + + (testing "last item is active" + (let [result (breadcrumb/breadcrumb + {:items [{:label "Home" :href "/"} + {:label "Active"}]}) + ol (nth result 2) + items (drop 2 ol) + last-item (last items)] + (is (clojure.string/includes? (get-in last-item [1 :class]) "breadcrumb-item--active"))))) + +(deftest breadcrumb-links-test + (testing "non-active items have links" + (let [result (breadcrumb/breadcrumb + {:items [{:label "Home" :href "/"} + {:label "End"}]}) + ol (nth result 2) + first-item (nth ol 2)] + ;; first item should have an anchor child + (is (= :a (first (nth first-item 2))))))) diff --git a/test/ui/card_test.clj b/test/ui/card_test.clj new file mode 100644 index 0000000..d41a5a1 --- /dev/null +++ b/test/ui/card_test.clj @@ -0,0 +1,36 @@ +(ns ui.card-test + (:require [clojure.test :refer [deftest is testing]] + [ui.card :as card])) + +(deftest card-class-list-test + (testing "always returns card class" + (is (= ["card"] (card/card-class-list {}))))) + +(deftest card-component-test + (testing "renders an article" + (let [result (card/card {} "Content")] + (is (= :article (first result))) + (is (= "card" (get-in result [1 :class]))) + (is (= "Content" (nth result 2))))) + + (testing "extra class gets appended" + (let [result (card/card {:class "extra"} "X")] + (is (= "card extra" (get-in result [1 :class])))))) + +(deftest card-header-test + (testing "renders header" + (let [result (card/card-header {} [:h3 "Title"])] + (is (= :header (first result))) + (is (= "card-header" (get-in result [1 :class])))))) + +(deftest card-body-test + (testing "renders body" + (let [result (card/card-body {} "Content")] + (is (= :div (first result))) + (is (= "card-body" (get-in result [1 :class])))))) + +(deftest card-footer-test + (testing "renders footer" + (let [result (card/card-footer {} "Actions")] + (is (= :footer (first result))) + (is (= "card-footer" (get-in result [1 :class])))))) diff --git a/test/ui/dialog_test.clj b/test/ui/dialog_test.clj new file mode 100644 index 0000000..116bd7a --- /dev/null +++ b/test/ui/dialog_test.clj @@ -0,0 +1,34 @@ +(ns ui.dialog-test + (:require [clojure.test :refer [deftest is testing]] + [ui.dialog :as dialog])) + +(deftest dialog-class-list-test + (testing "always returns dialog class" + (is (= ["dialog"] (dialog/dialog-class-list {}))))) + +(deftest dialog-component-test + (testing "renders a dialog element" + (let [result (dialog/dialog {:id "my-dialog"} "Content")] + (is (= :dialog (first result))) + (is (= "dialog" (get-in result [1 :class]))) + (is (= "my-dialog" (get-in result [1 :id]))))) + + (testing "open dialog has open attr" + (let [result (dialog/dialog {:open true} "Content")] + (is (true? (get-in result [1 :open])))))) + +(deftest dialog-sections-test + (testing "dialog-header renders header" + (let [result (dialog/dialog-header {} [:h3 "Title"])] + (is (= :header (first result))) + (is (= "dialog-header" (get-in result [1 :class]))))) + + (testing "dialog-body renders div" + (let [result (dialog/dialog-body {} "Body")] + (is (= :div (first result))) + (is (= "dialog-body" (get-in result [1 :class]))))) + + (testing "dialog-footer renders footer" + (let [result (dialog/dialog-footer {} "Footer")] + (is (= :footer (first result))) + (is (= "dialog-footer" (get-in result [1 :class])))))) diff --git a/test/ui/pagination_test.clj b/test/ui/pagination_test.clj new file mode 100644 index 0000000..af548e9 --- /dev/null +++ b/test/ui/pagination_test.clj @@ -0,0 +1,30 @@ +(ns ui.pagination-test + (:require [clojure.test :refer [deftest is testing]] + [ui.pagination :as pagination])) + +(deftest pagination-item-class-list-test + (testing "default item" + (is (= ["pagination-item"] (pagination/pagination-item-class-list {})))) + + (testing "active item" + (is (= ["pagination-item" "pagination-item--active"] + (pagination/pagination-item-class-list {:active true})))) + + (testing "disabled item" + (is (= ["pagination-item" "pagination-item--disabled"] + (pagination/pagination-item-class-list {:disabled true})))) + + (testing "active + disabled" + (is (= ["pagination-item" "pagination-item--active" "pagination-item--disabled"] + (pagination/pagination-item-class-list {:active true :disabled true}))))) + +(deftest pagination-component-test + (testing "renders nav with pagination list" + (let [result (pagination/pagination {:current 2 :total 5})] + (is (= :nav (first result))) + (is (= "Pagination" (get-in result [1 :aria-label]))) + (let [ol (nth result 2)] + (is (= :ol (first ol))) + (is (= "pagination" (get-in ol [1 :class]))) + ;; 5 page items + prev + next = 7 + (is (= 7 (count (drop 2 ol)))))))) diff --git a/test/ui/progress_test.clj b/test/ui/progress_test.clj new file mode 100644 index 0000000..2babc19 --- /dev/null +++ b/test/ui/progress_test.clj @@ -0,0 +1,24 @@ +(ns ui.progress-test + (:require [clojure.test :refer [deftest is testing]] + [ui.progress :as progress])) + +(deftest progress-bar-class-list-test + (testing "default (no variant)" + (is (= ["progress-bar"] (progress/progress-bar-class-list {})))) + + (testing "explicit variants" + (is (= ["progress-bar" "progress-bar--success"] (progress/progress-bar-class-list {:variant :success}))) + (is (= ["progress-bar" "progress-bar--warning"] (progress/progress-bar-class-list {:variant :warning}))) + (is (= ["progress-bar" "progress-bar--danger"] (progress/progress-bar-class-list {:variant :danger}))))) + +(deftest progress-component-test + (testing "renders progress with correct structure" + (let [result (progress/progress {:value 60})] + (is (= :div (first result))) + (is (= "progress" (get-in result [1 :class]))) + (is (= "progressbar" (get-in result [1 :role]))) + ;; inner bar + (let [bar (nth result 2)] + (is (= :div (first bar))) + (is (= "progress-bar" (get-in bar [1 :class]))) + (is (= "width: 60%" (get-in bar [1 :style]))))))) diff --git a/test/ui/skeleton_test.clj b/test/ui/skeleton_test.clj new file mode 100644 index 0000000..a7f64e4 --- /dev/null +++ b/test/ui/skeleton_test.clj @@ -0,0 +1,20 @@ +(ns ui.skeleton-test + (:require [clojure.test :refer [deftest is testing]] + [ui.skeleton :as skeleton])) + +(deftest skeleton-class-list-test + (testing "default (no variant)" + (is (= ["skeleton"] (skeleton/skeleton-class-list {})))) + + (testing "explicit variants" + (is (= ["skeleton" "skeleton--line"] (skeleton/skeleton-class-list {:variant :line}))) + (is (= ["skeleton" "skeleton--box"] (skeleton/skeleton-class-list {:variant :box}))) + (is (= ["skeleton" "skeleton--circle"] (skeleton/skeleton-class-list {:variant :circle}))) + (is (= ["skeleton" "skeleton--heading"] (skeleton/skeleton-class-list {:variant :heading}))))) + +(deftest skeleton-component-test + (testing "renders a div with role=status" + (let [result (skeleton/skeleton {:variant :line})] + (is (= :div (first result))) + (is (= "skeleton skeleton--line" (get-in result [1 :class]))) + (is (= "status" (get-in result [1 :role])))))) diff --git a/test/ui/spinner_test.clj b/test/ui/spinner_test.clj new file mode 100644 index 0000000..add52eb --- /dev/null +++ b/test/ui/spinner_test.clj @@ -0,0 +1,21 @@ +(ns ui.spinner-test + (:require [clojure.test :refer [deftest is testing]] + [ui.spinner :as spinner])) + +(deftest spinner-class-list-test + (testing "default size (md)" + (is (= ["spinner"] (spinner/spinner-class-list {}))) + (is (= ["spinner"] (spinner/spinner-class-list {:size :md})))) + + (testing "small size" + (is (= ["spinner" "spinner-sm"] (spinner/spinner-class-list {:size :sm})))) + + (testing "large size" + (is (= ["spinner" "spinner-lg"] (spinner/spinner-class-list {:size :lg}))))) + +(deftest spinner-component-test + (testing "renders a span" + (let [result (spinner/spinner {})] + (is (= :span (first result))) + (is (= "spinner" (get-in result [1 :class]))) + (is (= "status" (get-in result [1 :role])))))) diff --git a/test/ui/switch_test.clj b/test/ui/switch_test.clj new file mode 100644 index 0000000..eafaec7 --- /dev/null +++ b/test/ui/switch_test.clj @@ -0,0 +1,27 @@ +(ns ui.switch-test + (:require [clojure.test :refer [deftest is testing]] + [ui.switch :as switch])) + +(deftest switch-class-list-test + (testing "default switch" + (is (= ["switch"] (switch/switch-class-list {})))) + + (testing "disabled switch" + (is (= ["switch" "switch--disabled"] (switch/switch-class-list {:disabled true}))))) + +(deftest switch-component-test + (testing "renders a label" + (let [result (switch/switch-toggle {:label "Notifications"})] + (is (= :label (first result))) + (is (= "switch" (get-in result [1 :class]))))) + + (testing "checked switch has checked track class" + (let [result (switch/switch-toggle {:checked true :label "On"})] + ;; Find the track span + (let [track (nth result 3)] + (is (= "switch-track switch-track--checked" (get-in track [1 :class])))))) + + (testing "unchecked switch has no checked track class" + (let [result (switch/switch-toggle {:checked false :label "Off"})] + (let [track (nth result 3)] + (is (= "switch-track" (get-in track [1 :class]))))))) diff --git a/test/ui/table_test.clj b/test/ui/table_test.clj new file mode 100644 index 0000000..2799558 --- /dev/null +++ b/test/ui/table_test.clj @@ -0,0 +1,31 @@ +(ns ui.table-test + (:require [clojure.test :refer [deftest is testing]] + [ui.table :as table])) + +(deftest table-class-list-test + (testing "default table" + (is (= ["table"] (table/table-class-list {})))) + + (testing "striped variant" + (is (= ["table" "table--striped"] (table/table-class-list {:variant :striped})))) + + (testing "bordered variant" + (is (= ["table" "table--bordered"] (table/table-class-list {:variant :bordered}))))) + +(deftest table-classes-test + (testing "space-joined output" + (is (= "table" (table/table-classes {}))) + (is (= "table table--striped" (table/table-classes {:variant :striped}))))) + +(deftest table-component-test + (testing "renders a table with headers and rows" + (let [result (table/table {:headers ["Name" "Email"] + :rows [["Alice" "alice@example.com"] + ["Bob" "bob@example.com"]]})] + ;; outer div.table-wrapper + (is (= :div (first result))) + (is (= "table-wrapper" (get-in result [1 :class]))) + ;; table element inside + (let [tbl (nth result 2)] + (is (= :table (first tbl))) + (is (= "table" (get-in tbl [1 :class]))))))) diff --git a/test/ui/tooltip_test.clj b/test/ui/tooltip_test.clj new file mode 100644 index 0000000..e705872 --- /dev/null +++ b/test/ui/tooltip_test.clj @@ -0,0 +1,15 @@ +(ns ui.tooltip-test + (:require [clojure.test :refer [deftest is testing]] + [ui.tooltip :as tooltip])) + +(deftest tooltip-component-test + (testing "renders a span with data-tooltip attr" + (let [result (tooltip/tooltip {:text "Hello"} "Hover me")] + (is (= :span (first result))) + (is (= "tooltip" (get-in result [1 :class]))) + (is (= "Hello" (get-in result [1 :data-tooltip]))) + (is (= "Hover me" (nth result 2))))) + + (testing "extra class appended" + (let [result (tooltip/tooltip {:text "Tip" :class "extra"} "X")] + (is (= "tooltip extra" (get-in result [1 :class]))))))