From d8e280df2a0912bc78fcede872f8d801070f20ca Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Sun, 29 Mar 2026 11:17:24 +0200 Subject: [PATCH] feat(calendar-events): add view toggle, source filters, detail dialog, error banner, loading indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New stateless components ported from org-mode-agenda-cli reference app: - view-toggle: Grid/Agenda segmented control - source-toggles: colored pill buttons for event source filtering - event-detail-dialog: overlay with title, date, time, tags, source - error-banner: dismissible error bar - loading-indicator: pulsing dot for async state Enhanced existing components: - calendar-event-grid: added :loading?, :header-actions slots - agenda-day-group/agenda-list: added :day-actions slot per day header - Event data format extended with :tags and :source fields CSS: view toggle, loading pulse animation, error banner, source toggles, detail dialog overlay — all using theme tokens with dark mode support. Tests: 126 tests, 794 assertions, 0 failures. Dev demos updated in all 3 targets with full interactive calendar app. --- dev/hiccup/src/dev/hiccup.clj | 49 ++-- dev/replicant/src/dev/replicant.cljs | 131 +++++----- dev/squint/src/dev/squint.cljs | 158 ++++++------ src/ui/calendar.md | 62 ++++- src/ui/calendar_events.cljc | 353 +++++++++++++++++++++++++-- src/ui/calendar_events.css | 286 ++++++++++++++++++++++ test/ui/calendar_events_test.clj | 58 +++++ 7 files changed, 915 insertions(+), 182 deletions(-) diff --git a/dev/hiccup/src/dev/hiccup.clj b/dev/hiccup/src/dev/hiccup.clj index 0bcbf54..7a7c3e5 100644 --- a/dev/hiccup/src/dev/hiccup.clj +++ b/dev/hiccup/src/dev/hiccup.clj @@ -318,14 +318,14 @@ (form/form-input {:type :email :error true :value "invalid-email"}))])) (def sample-calendar-events - [{:title "Team standup" :date "2026-03-29" :time-start "09:00" :time-end "09:30" :color :accent} - {:title "Lunch with Alex" :date "2026-03-29" :time-start "12:00" :time-end "13:00" :color :success} - {:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" :color :danger} - {:title "Design review" :date "2026-03-30" :time-start "10:00" :color :warning} - {:title "All-day planning" :date "2026-03-31" :color nil :done? true} - {:title "Sprint retro" :date "2026-04-01" :time-start "14:00" :time-end "15:00" :color :accent} - {:title "1:1 with manager" :date "2026-04-02" :time-start "11:00" :color :success} - {:title "Release party" :date "2026-04-03" :time-start "17:00" :color :danger}]) + [{:title "Team standup" :date "2026-03-29" :time-start "09:00" :time-end "09:30" :color :accent :tags ["work"] :source "Work"} + {:title "Lunch with Alex" :date "2026-03-29" :time-start "12:00" :time-end "13:00" :color :success :tags ["social"] :source "Personal"} + {:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" :color :danger :tags ["ops" "urgent"] :source "Work"} + {:title "Design review" :date "2026-03-30" :time-start "10:00" :color :warning :tags ["design"] :source "Work"} + {:title "All-day planning" :date "2026-03-31" :color nil :done? true :source "Work"} + {:title "Sprint retro" :date "2026-04-01" :time-start "14:00" :time-end "15:00" :color :accent :source "Work"} + {:title "1:1 with manager" :date "2026-04-02" :time-start "11:00" :color :success :source "Work"} + {:title "Release party" :date "2026-04-03" :time-start "17:00" :color :danger :tags ["social"] :source "Personal"}]) (defn calendar-demo [] (section "Calendar" @@ -335,12 +335,25 @@ :selected-date "2026-03-29"}) (calendar/calendar {:year 2026 :month 4 :today-str "2026-03-29"})] - [:h5 "Event Grid"] + [:h5 "Full Calendar App"] + + ;; Error banner + (cal-events/error-banner {:message "Could not fetch events (demo)"}) + + ;; Source toggles + (cal-events/source-toggles + {:sources [{:name "Work" :color :accent :active? true} + {:name "Personal" :color :success :active? true} + {:name "Inbox" :color :warning :active? false}]}) + + ;; Event grid with view toggle in header (cal-events/calendar-event-grid {:year 2026 :month 3 :today-str "2026-03-29" :selected-date "2026-03-29" - :events sample-calendar-events}) + :events sample-calendar-events + :header-actions (cal-events/view-toggle {:view :month})}) - [:h5 "Day Ticker"] + ;; Ticker + Agenda + [:h5 "Agenda View"] (cal-events/ticker-strip {:days [{:date "2026-03-27" :day-num 27 :day-label "Fr"} {:date "2026-03-28" :day-num 28 :day-label "Sa"} {:date "2026-03-29" :day-num 29 :day-label "Su"} @@ -352,15 +365,19 @@ :today-str "2026-03-29" :selected "2026-03-29" :events sample-calendar-events}) - - [:h5 "Agenda List"] (cal-events/agenda-list {:days [{:date "2026-03-29" :label "Today"} {:date "2026-03-30" :label "Tomorrow"} {:date "2026-03-31" :label "Tue"} {:date "2026-04-01" :label "Wed"} {:date "2026-04-02" :label "Thu"} {:date "2026-04-03" :label "Fri"}] - :events sample-calendar-events}))) + :events sample-calendar-events}) + + ;; Event detail dialog (static demo) + [:h5 "Event Detail Dialog"] + (cal-events/event-detail-dialog + {:event {:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" + :color :danger :tags ["ops" "urgent"] :source "Work"}}))) ;; ── Pages ─────────────────────────────────────────────────────────── @@ -413,9 +430,9 @@ (defn calendar-page [] [:div (page-header "Calendar" "Date picker, event grid, ticker strip, and agenda list.") + (calendar-demo) (into [:div {:class "md-docs"}] - (markdown/markdown->hiccup (slurp "src/ui/calendar.md"))) - (calendar-demo)]) + (markdown/markdown->hiccup (slurp "src/ui/calendar.md")))]) (defn icons-page [] [:div diff --git a/dev/replicant/src/dev/replicant.cljs b/dev/replicant/src/dev/replicant.cljs index c5ee9b6..37bf45a 100644 --- a/dev/replicant/src/dev/replicant.cljs +++ b/dev/replicant/src/dev/replicant.cljs @@ -280,75 +280,80 @@ (form/form-field {:label "Email" :error "Please enter a valid email address."} (form/form-input {:type :email :error true :value "invalid-email"}))])) -(defonce !cal-state (atom {:year 2026 :month 3 :selected-date nil})) +(defonce !cal-state (atom {:year 2026 :month 3 :selected-date nil + :view :month :selected-event nil :error nil})) (def sample-calendar-events - [{:title "Team standup" :date "2026-03-29" :time-start "09:00" :time-end "09:30" :color :accent} - {:title "Lunch with Alex" :date "2026-03-29" :time-start "12:00" :time-end "13:00" :color :success} - {:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" :color :danger} - {:title "Design review" :date "2026-03-30" :time-start "10:00" :color :warning} - {:title "All-day planning" :date "2026-03-31" :color nil :done? true} - {:title "Sprint retro" :date "2026-04-01" :time-start "14:00" :time-end "15:00" :color :accent} - {:title "1:1 with manager" :date "2026-04-02" :time-start "11:00" :color :success} - {:title "Release party" :date "2026-04-03" :time-start "17:00" :color :danger}]) + [{:title "Team standup" :date "2026-03-29" :time-start "09:00" :time-end "09:30" :color :accent :tags ["work"] :source "Work"} + {:title "Lunch with Alex" :date "2026-03-29" :time-start "12:00" :time-end "13:00" :color :success :tags ["social"] :source "Personal"} + {:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" :color :danger :tags ["ops" "urgent"] :source "Work"} + {:title "Design review" :date "2026-03-30" :time-start "10:00" :color :warning :tags ["design"] :source "Work"} + {:title "All-day planning" :date "2026-03-31" :color nil :done? true :source "Work"} + {:title "Sprint retro" :date "2026-04-01" :time-start "14:00" :time-end "15:00" :color :accent :source "Work"} + {:title "1:1 with manager" :date "2026-04-02" :time-start "11:00" :color :success :source "Work"} + {:title "Release party" :date "2026-04-03" :time-start "17:00" :color :danger :tags ["social"] :source "Personal"}]) (defn calendar-demo [] - (let [{:keys [year month selected-date]} @!cal-state - today-str "2026-03-29"] + (let [{:keys [year month selected-date view selected-event error]} @!cal-state + today-str "2026-03-29" + prev-month! (fn [_] (let [[ny nm] (calendar/prev-month year month)] + (swap! !cal-state assoc :year ny :month nm))) + next-month! (fn [_] (let [[ny nm] (calendar/next-month year month)] + (swap! !cal-state assoc :year ny :month nm)))] (section "Calendar" - [:h5 "Date Picker (interactive)"] - [:div {:style {:display "flex" :gap "1.5rem" :flex-wrap "wrap"}} - (calendar/calendar {:year year :month month - :today-str today-str - :selected-date selected-date - :on-select (fn [d] (swap! !cal-state assoc :selected-date d)) - :on-prev-month (fn [_] - (let [[ny nm] (calendar/prev-month year month)] - (swap! !cal-state assoc :year ny :month nm))) - :on-next-month (fn [_] - (let [[ny nm] (calendar/next-month year month)] - (swap! !cal-state assoc :year ny :month nm)))})] - (when selected-date - [:p {:style {:color "var(--fg-1)" :font-size "var(--font-sm)"}} - (str "Selected: " selected-date)]) + [:h5 "Date Picker"] + (calendar/calendar {:year year :month month + :today-str today-str + :selected-date selected-date + :on-select (fn [d] (swap! !cal-state assoc :selected-date d)) + :on-prev-month prev-month! + :on-next-month next-month!}) - [:h5 "Event Grid"] - (cal-events/calendar-event-grid {:year year :month month - :today-str today-str - :selected-date selected-date - :events sample-calendar-events - :on-select (fn [d] (swap! !cal-state assoc :selected-date d)) - :on-prev-month (fn [_] - (let [[ny nm] (calendar/prev-month year month)] - (swap! !cal-state assoc :year ny :month nm))) - :on-next-month (fn [_] - (let [[ny nm] (calendar/next-month year month)] - (swap! !cal-state assoc :year ny :month nm))) - :on-event-click (fn [evt] (js/console.log "Event clicked:" (:title evt)))}) + [:h5 "Full Calendar App"] + (cal-events/error-banner {:message error + :on-dismiss (fn [_] (swap! !cal-state assoc :error nil))}) - [:h5 "Day Ticker"] - (cal-events/ticker-strip {:days [{:date "2026-03-27" :day-num 27 :day-label "Fr"} - {:date "2026-03-28" :day-num 28 :day-label "Sa"} - {:date "2026-03-29" :day-num 29 :day-label "Su"} - {:date "2026-03-30" :day-num 30 :day-label "Mo"} - {:date "2026-03-31" :day-num 31 :day-label "Tu"} - {:date "2026-04-01" :day-num 1 :day-label "We"} - {:date "2026-04-02" :day-num 2 :day-label "Th"} - {:date "2026-04-03" :day-num 3 :day-label "Fr"}] - :today-str today-str - :selected (or selected-date today-str) - :events sample-calendar-events - :on-select (fn [d] (swap! !cal-state assoc :selected-date d))}) + (cal-events/source-toggles + {:sources [{:name "Work" :color :accent :active? true} + {:name "Personal" :color :success :active? true}] + :on-toggle (fn [s] (js/console.log "Toggle source:" s))}) - [:h5 "Agenda List"] - (cal-events/agenda-list {:days [{:date "2026-03-29" :label "Today"} - {:date "2026-03-30" :label "Tomorrow"} - {:date "2026-03-31" :label "Tue"} - {:date "2026-04-01" :label "Wed"} - {:date "2026-04-02" :label "Thu"} - {:date "2026-04-03" :label "Fri"}] - :events sample-calendar-events - :on-event-click (fn [evt] (js/console.log "Agenda event:" (:title evt)))})))) + (if (= view :agenda) + [:div + (cal-events/ticker-strip {:days [{:date "2026-03-27" :day-num 27 :day-label "Fr"} + {:date "2026-03-28" :day-num 28 :day-label "Sa"} + {:date "2026-03-29" :day-num 29 :day-label "Su"} + {:date "2026-03-30" :day-num 30 :day-label "Mo"} + {:date "2026-03-31" :day-num 31 :day-label "Tu"} + {:date "2026-04-01" :day-num 1 :day-label "We"} + {:date "2026-04-02" :day-num 2 :day-label "Th"} + {:date "2026-04-03" :day-num 3 :day-label "Fr"}] + :today-str today-str + :selected (or selected-date today-str) + :events sample-calendar-events + :on-select (fn [d] (swap! !cal-state assoc :selected-date d))}) + (cal-events/agenda-list {:days [{:date "2026-03-29" :label "Today"} + {:date "2026-03-30" :label "Tomorrow"} + {:date "2026-03-31" :label "Tue"} + {:date "2026-04-01" :label "Wed"} + {:date "2026-04-02" :label "Thu"} + {:date "2026-04-03" :label "Fri"}] + :events sample-calendar-events + :on-event-click (fn [evt] (swap! !cal-state assoc :selected-event evt))})] + (cal-events/calendar-event-grid {:year year :month month + :today-str today-str + :selected-date selected-date + :events sample-calendar-events + :on-select (fn [d] (swap! !cal-state assoc :selected-date d)) + :on-prev-month prev-month! + :on-next-month next-month! + :on-event-click (fn [evt] (swap! !cal-state assoc :selected-event evt)) + :header-actions (cal-events/view-toggle + {:view view + :on-change (fn [v] (swap! !cal-state assoc :view v))})})) + + (cal-events/event-detail-dialog {:event selected-event + :on-close (fn [_] (swap! !cal-state assoc :selected-event nil))})))) ;; ── Pages ─────────────────────────────────────────────────────────── @@ -397,9 +402,9 @@ (defn calendar-page [] [:div (page-header "Calendar" "Date picker, event grid, ticker strip, and agenda list.") + (calendar-demo) (into [:div {:class ["md-docs"]}] - (markdown/markdown->hiccup calendar-docs-md)) - (calendar-demo)]) + (markdown/markdown->hiccup calendar-docs-md))]) (defn icons-page [] [:div diff --git a/dev/squint/src/dev/squint.cljs b/dev/squint/src/dev/squint.cljs index d762f98..e806d7f 100644 --- a/dev/squint/src/dev/squint.cljs +++ b/dev/squint/src/dev/squint.cljs @@ -297,85 +297,99 @@ (form/form-field {:label "Email" :error "Please enter a valid email address."} (form/form-input {:type "email" :error true :value "invalid-email"}))])) -(def !cal-state (atom {:year 2026 :month 3 :selected-date nil})) +(def !cal-state (atom {:year 2026 :month 3 :selected-date nil + :view "month" :selected-event nil :error nil})) (def sample-calendar-events - [{:title "Team standup" :date "2026-03-29" :time-start "09:00" :time-end "09:30" :color "accent"} - {:title "Lunch with Alex" :date "2026-03-29" :time-start "12:00" :time-end "13:00" :color "success"} - {:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" :color "danger"} - {:title "Design review" :date "2026-03-30" :time-start "10:00" :color "warning"} - {:title "All-day planning" :date "2026-03-31" :color nil :done? true} - {:title "Sprint retro" :date "2026-04-01" :time-start "14:00" :time-end "15:00" :color "accent"} - {:title "1:1 with manager" :date "2026-04-02" :time-start "11:00" :color "success"} - {:title "Release party" :date "2026-04-03" :time-start "17:00" :color "danger"}]) + [{:title "Team standup" :date "2026-03-29" :time-start "09:00" :time-end "09:30" :color "accent" :tags ["work"] :source "Work"} + {:title "Lunch with Alex" :date "2026-03-29" :time-start "12:00" :time-end "13:00" :color "success" :tags ["social"] :source "Personal"} + {:title "Deploy v2.0" :date "2026-03-29" :time-start "15:00" :color "danger" :tags ["ops" "urgent"] :source "Work"} + {:title "Design review" :date "2026-03-30" :time-start "10:00" :color "warning" :tags ["design"] :source "Work"} + {:title "All-day planning" :date "2026-03-31" :color nil :done? true :source "Work"} + {:title "Sprint retro" :date "2026-04-01" :time-start "14:00" :time-end "15:00" :color "accent" :source "Work"} + {:title "1:1 with manager" :date "2026-04-02" :time-start "11:00" :color "success" :source "Work"} + {:title "Release party" :date "2026-04-03" :time-start "17:00" :color "danger" :tags ["social"] :source "Personal"}]) + +(defn- nav! [key val] + (fn [_] (swap! !cal-state assoc key val) (render!))) + +(defn- prev-month! [_] + (let [{:keys [year month]} @!cal-state + [ny nm] (calendar/prev-month year month)] + (swap! !cal-state assoc :year ny :month nm) + (render!))) + +(defn- next-month! [_] + (let [{:keys [year month]} @!cal-state + [ny nm] (calendar/next-month year month)] + (swap! !cal-state assoc :year ny :month nm) + (render!))) (defn calendar-demo [] - (let [{:keys [year month selected-date]} @!cal-state + (let [{:keys [year month selected-date view selected-event error]} @!cal-state today-str "2026-03-29"] (section "Calendar" - [:h5 "Date Picker (interactive)"] - [:div {:style {"display" "flex" "gap" "1.5rem" "flex-wrap" "wrap"}} - (calendar/calendar {:year year :month month - :today-str today-str - :selected-date selected-date - :on-select (fn [d] - (swap! !cal-state assoc :selected-date d) - (render!)) - :on-prev-month (fn [_] - (let [[ny nm] (calendar/prev-month year month)] - (swap! !cal-state assoc :year ny :month nm) - (render!))) - :on-next-month (fn [_] - (let [[ny nm] (calendar/next-month year month)] - (swap! !cal-state assoc :year ny :month nm) - (render!)))})] - (when selected-date - [:p {:style {"color" "var(--fg-1)" "font-size" "var(--font-sm)"}} - (str "Selected: " selected-date)]) + [:h5 "Date Picker"] + (calendar/calendar {:year year :month month + :today-str today-str + :selected-date selected-date + :on-select (nav! :selected-date) + :on-prev-month prev-month! + :on-next-month next-month!}) - [:h5 "Event Grid"] - (cal-events/calendar-event-grid {:year year :month month - :today-str today-str - :selected-date selected-date - :events sample-calendar-events - :on-select (fn [d] - (swap! !cal-state assoc :selected-date d) - (render!)) - :on-prev-month (fn [_] - (let [[ny nm] (calendar/prev-month year month)] - (swap! !cal-state assoc :year ny :month nm) - (render!))) - :on-next-month (fn [_] - (let [[ny nm] (calendar/next-month year month)] - (swap! !cal-state assoc :year ny :month nm) - (render!))) - :on-event-click (fn [evt] (js/console.log "Event clicked:" (:title evt)))}) + [:h5 "Full Calendar App"] + ;; Error banner + (cal-events/error-banner {:message error + :on-dismiss (nav! :error nil)}) - [:h5 "Day Ticker"] - (cal-events/ticker-strip {:days [{:date "2026-03-27" :day-num 27 :day-label "Fr"} - {:date "2026-03-28" :day-num 28 :day-label "Sa"} - {:date "2026-03-29" :day-num 29 :day-label "Su"} - {:date "2026-03-30" :day-num 30 :day-label "Mo"} - {:date "2026-03-31" :day-num 31 :day-label "Tu"} - {:date "2026-04-01" :day-num 1 :day-label "We"} - {:date "2026-04-02" :day-num 2 :day-label "Th"} - {:date "2026-04-03" :day-num 3 :day-label "Fr"}] - :today-str today-str - :selected (or selected-date today-str) - :events sample-calendar-events - :on-select (fn [d] - (swap! !cal-state assoc :selected-date d) - (render!))}) + ;; Source toggles + (cal-events/source-toggles + {:sources [{:name "Work" :color "accent" :active? true} + {:name "Personal" :color "success" :active? true}] + :on-toggle (fn [s] (js/console.log "Toggle source:" s))}) - [:h5 "Agenda List"] - (cal-events/agenda-list {:days [{:date "2026-03-29" :label "Today"} - {:date "2026-03-30" :label "Tomorrow"} - {:date "2026-03-31" :label "Tue"} - {:date "2026-04-01" :label "Wed"} - {:date "2026-04-02" :label "Thu"} - {:date "2026-04-03" :label "Fri"}] - :events sample-calendar-events - :on-event-click (fn [evt] (js/console.log "Agenda event:" (:title evt)))})))) + ;; View toggle + event grid / agenda + (if (= view "agenda") + [:div + (cal-events/ticker-strip {:days [{:date "2026-03-27" :day-num 27 :day-label "Fr"} + {:date "2026-03-28" :day-num 28 :day-label "Sa"} + {:date "2026-03-29" :day-num 29 :day-label "Su"} + {:date "2026-03-30" :day-num 30 :day-label "Mo"} + {:date "2026-03-31" :day-num 31 :day-label "Tu"} + {:date "2026-04-01" :day-num 1 :day-label "We"} + {:date "2026-04-02" :day-num 2 :day-label "Th"} + {:date "2026-04-03" :day-num 3 :day-label "Fr"}] + :today-str today-str + :selected (or selected-date today-str) + :events sample-calendar-events + :on-select (nav! :selected-date)}) + (cal-events/agenda-list {:days [{:date "2026-03-29" :label "Today"} + {:date "2026-03-30" :label "Tomorrow"} + {:date "2026-03-31" :label "Tue"} + {:date "2026-04-01" :label "Wed"} + {:date "2026-04-02" :label "Thu"} + {:date "2026-04-03" :label "Fri"}] + :events sample-calendar-events + :on-event-click (fn [evt] + (swap! !cal-state assoc :selected-event evt) + (render!))})] + (cal-events/calendar-event-grid {:year year :month month + :today-str today-str + :selected-date selected-date + :events sample-calendar-events + :on-select (nav! :selected-date) + :on-prev-month prev-month! + :on-next-month next-month! + :on-event-click (fn [evt] + (swap! !cal-state assoc :selected-event evt) + (render!)) + :header-actions (cal-events/view-toggle + {:view view + :on-change (nav! :view)})})) + + ;; Event detail dialog + (cal-events/event-detail-dialog {:event selected-event + :on-close (fn [] (swap! !cal-state assoc :selected-event nil) (render!))})))) ;; ── Pages ─────────────────────────────────────────────────────────── @@ -448,10 +462,10 @@ (load-calendar-docs!) [:div (page-header "Calendar" "Date picker, event grid, ticker strip, and agenda list.") + (calendar-demo) (when-let [md @!calendar-docs] (into [:div {:class "md-docs"}] - (markdown/markdown->hiccup md))) - (calendar-demo)]) + (markdown/markdown->hiccup md)))]) (defn icons-page [] [:div diff --git a/src/ui/calendar.md b/src/ui/calendar.md index be12908..40860ef 100644 --- a/src/ui/calendar.md +++ b/src/ui/calendar.md @@ -75,6 +75,60 @@ Vertical list of events grouped by day. :on-event-click (fn [event-map] ...)}) ``` +## View Toggle + +A segmented control to switch between Grid and Agenda views: + +```clojure +(cal-events/view-toggle + {:view :month ;; :month or :agenda + :on-change (fn [new-view] ...)}) +``` + +## Source Filter Toggles + +Colored pill buttons to show/hide event sources: + +```clojure +(cal-events/source-toggles + {:sources [{:name "Work" :color :accent :active? true} + {:name "Personal" :color :success :active? false}] + :on-toggle (fn [source-name] ...)}) +``` + +## Event Detail Dialog + +An overlay dialog showing event details (date, time, tags, source): + +```clojure +(cal-events/event-detail-dialog + {:event {:title "Meeting" :date "2026-03-29" :time-start "10:00" + :color :accent :tags ["work"] :source "Work Calendar"} + :on-close (fn [] ...)}) +``` + +## Error Banner + +A dismissible error bar at the top of the calendar: + +```clojure +(cal-events/error-banner + {:message "Failed to fetch events" + :on-dismiss (fn [_] ...)}) +``` + +## Loading Indicator + +A pulsing dot for loading state, placed inline in the header: + +```clojure +(cal-events/loading-indicator) +``` + +## Event Colors + +Colors map to the theme's semantic tokens and support dark mode automatically: `:accent`, `:danger`, `:success`, `:warning`. Pass `nil` for the default gray. + ## Event Data Format Events are plain maps: @@ -85,13 +139,11 @@ Events are plain maps: :time-start "09:00" ;; HH:MM or nil :time-end "09:30" ;; HH:MM or nil :color :accent ;; :accent :danger :success :warning or nil - :done? false} + :done? false + :tags ["work" "daily"] ;; optional, shown in detail dialog + :source "Work Calendar"} ;; optional, shown in detail dialog ``` -## Event Colors - -Colors map to the theme's semantic tokens and support dark mode automatically: `:accent`, `:danger`, `:success`, `:warning`. Pass `nil` for the default gray. - ## Date Utilities All date math is pure (no JS Date dependency) and works on all targets: diff --git a/src/ui/calendar_events.cljc b/src/ui/calendar_events.cljc index 0591e53..6383492 100644 --- a/src/ui/calendar_events.cljc +++ b/src/ui/calendar_events.cljc @@ -1,7 +1,9 @@ (ns ui.calendar-events "Event-aware calendar components. See src/ui/calendar.md for full documentation." (:require [clojure.string :as str] - [ui.calendar :as cal])) + [ui.calendar :as cal] + [ui.button :as button] + [ui.icon :as icon])) ;; In squint, keywords are strings — name is identity #?(:squint (defn- kw-name [s] s) @@ -16,6 +18,8 @@ ;; :time-end - "HH:MM" string or nil ;; :color - :accent, :danger, :success, :warning, or nil (default) ;; :done? - boolean +;; :tags - vector of strings (optional) +;; :source - string (optional, e.g. file/calendar name) (def event-colors #{"accent" "danger" "success" "warning"}) @@ -94,8 +98,260 @@ [opts] (str/join " " (agenda-event-class-list opts))) +(defn view-toggle-class-list + "Returns a vector of CSS class strings for a view toggle button. + Options: + :active? - boolean" + [{:keys [active?]}] + (cond-> ["cal-view-btn"] + active? (conj "cal-view-btn-active"))) + +(defn view-toggle-classes [opts] + (str/join " " (view-toggle-class-list opts))) + +(defn source-toggle-class-list + "Returns a vector of CSS class strings for a source filter toggle. + Options: + :color - event color keyword + :active? - boolean (visible)" + [{:keys [color active?]}] + (cond-> ["cal-source-toggle" (event-color-class color)] + (not active?) (conj "cal-source-inactive"))) + +(defn source-toggle-classes [opts] + (str/join " " (source-toggle-class-list opts))) + ;; ── Components ────────────────────────────────────────────────────── +;; ── View Toggle ───────────────────────────────────────────────────── + +(defn view-toggle + "Render a Grid/Agenda view toggle. + + Props: + :view - current view (:month or :agenda) + :on-change - callback fn, receives the new view keyword + :class - additional CSS classes + :attrs - additional HTML attributes" + [{:keys [view on-change class attrs]}] + (let [v (kw-name (or view #?(:squint "month" :cljs :month :clj :month)))] + #?(:squint + (let [classes (cond-> "cal-view-toggle" class (str " " class)) + base-attrs (merge {:class classes} attrs)] + [:div base-attrs + [:button {:class (view-toggle-classes {:active? (= v "month")}) + :on-click (when on-change (fn [_] (on-change "month")))} + "Grid"] + [:button {:class (view-toggle-classes {:active? (= v "agenda")}) + :on-click (when on-change (fn [_] (on-change "agenda")))} + "Agenda"]]) + + :cljs + (let [classes (cond-> ["cal-view-toggle"] class (conj class)) + base-attrs (merge {:class classes} attrs)] + [:div base-attrs + [:button {:class (view-toggle-class-list {:active? (= v "month")}) + :on (when on-change {:click (fn [_] (on-change :month))})} + "Grid"] + [:button {:class (view-toggle-class-list {:active? (= v "agenda")}) + :on (when on-change {:click (fn [_] (on-change :agenda))})} + "Agenda"]]) + + :clj + (let [classes (cond-> "cal-view-toggle" class (str " " class)) + base-attrs (merge {:class classes} attrs)] + [:div base-attrs + [:button {:class (view-toggle-classes {:active? (= v "month")})} "Grid"] + [:button {:class (view-toggle-classes {:active? (= v "agenda")})} "Agenda"]])))) + +;; ── Loading Indicator ─────────────────────────────────────────────── + +(defn loading-indicator + "Render a small pulsing dot to indicate loading state." + [] + #?(:squint [:div {:class "cal-loading"}] + :cljs [:div {:class ["cal-loading"]}] + :clj [:div {:class "cal-loading"}])) + +;; ── Error Banner ──────────────────────────────────────────────────── + +(defn error-banner + "Render an error banner at the top of the calendar. + + Props: + :message - error message string + :on-dismiss - callback to dismiss the error" + [{:keys [message on-dismiss]}] + (when message + #?(:squint + [:div {:class "cal-error-banner"} + [:span (str "Error: " message)] + (when on-dismiss + [:button {:class "cal-error-dismiss" :on-click on-dismiss} "\u00d7"])] + + :cljs + [:div {:class ["cal-error-banner"]} + [:span (str "Error: " message)] + (when on-dismiss + [:button {:class ["cal-error-dismiss"] + :on {:click on-dismiss}} "\u00d7"])] + + :clj + [:div {:class "cal-error-banner"} + [:span (str "Error: " message)] + [:button {:class "cal-error-dismiss"} "\u00d7"]]))) + +;; ── Source Filter Toggles ─────────────────────────────────────────── + +(defn source-toggles + "Render a row of source filter toggle buttons. + + Props: + :sources - vector of {:name :color :active?} maps + :on-toggle - callback fn, receives source name string + :class - additional CSS classes + :attrs - additional HTML attributes" + [{:keys [sources on-toggle class attrs]}] + (when (seq sources) + #?(:squint + (let [classes (cond-> "cal-source-toggles" class (str " " class)) + base-attrs (merge {:class classes} attrs)] + (into [:div base-attrs] + (map (fn [src] + [:button {:class (source-toggle-classes {:color (:color src) :active? (:active? src)}) + :on-click (when on-toggle (fn [_] (on-toggle (:name src))))} + (:name src)]) + sources))) + + :cljs + (let [classes (cond-> ["cal-source-toggles"] class (conj class)) + base-attrs (merge {:class classes} attrs)] + (into [:div base-attrs] + (map (fn [src] + [:button {:class (source-toggle-class-list {:color (:color src) :active? (:active? src)}) + :on (when on-toggle {:click (fn [_] (on-toggle (:name src)))})} + (:name src)]) + sources))) + + :clj + (let [classes (cond-> "cal-source-toggles" class (str " " class)) + base-attrs (merge {:class classes} attrs)] + (into [:div base-attrs] + (map (fn [src] + [:button {:class (source-toggle-classes {:color (:color src) :active? (:active? src)})} + (:name src)]) + sources)))))) + +;; ── Event Detail Dialog ───────────────────────────────────────────── + +(defn event-detail-dialog + "Render an event detail overlay/dialog. + + Props: + :event - event map (nil to hide) + :on-close - callback to close the dialog + :class - additional CSS classes + :attrs - additional HTML attributes" + [{:keys [event on-close class attrs]}] + (when event + (let [title (:title event) + done? (:done? event) + tags (or (:tags event) []) + source (:source event) + color (:color event) + time-str (event-time-display event) + date (:date event)] + #?(:squint + [:div {:class "cal-detail-overlay" + :on-click on-close} + [:div {:class (str "cal-detail " (event-color-class color) + (when class (str " " class))) + :on-click (fn [e] (.stopPropagation e))} + [:div {:class "cal-detail-header"} + [:div {:class "cal-detail-title"} + (when done? [:span {:class "cal-detail-badge"} "DONE"]) + title] + [:button {:class "cal-detail-close" :on-click on-close} "\u00d7"]] + [:div {:class "cal-detail-body"} + (when date + [:div {:class "cal-detail-row"} + [:span {:class "cal-detail-label"} "Date"] + [:span {:class "cal-detail-value"} (str/replace date "-" "/")]]) + (when time-str + [:div {:class "cal-detail-row"} + [:span {:class "cal-detail-label"} "Time"] + [:span {:class "cal-detail-value"} time-str]]) + (when (seq tags) + [:div {:class "cal-detail-row"} + [:span {:class "cal-detail-label"} "Tags"] + (into [:div {:class "cal-detail-tags"}] + (map (fn [t] [:span {:class "cal-detail-tag"} t]) tags))]) + (when source + [:div {:class "cal-detail-row"} + [:span {:class "cal-detail-label"} "Source"] + [:span {:class "cal-detail-value"} source]])]]] + + :cljs + [:div {:class ["cal-detail-overlay"] + :on {:click on-close}} + [:div {:class [(str "cal-detail " (event-color-class color)) + (when class class)] + :on {:click (fn [e] (.stopPropagation e))}} + [:div {:class ["cal-detail-header"]} + [:div {:class ["cal-detail-title"]} + (when done? [:span {:class ["cal-detail-badge"]} "DONE"]) + title] + [:button {:class ["cal-detail-close"] + :on {:click on-close}} "\u00d7"]] + [:div {:class ["cal-detail-body"]} + (when date + [:div {:class ["cal-detail-row"]} + [:span {:class ["cal-detail-label"]} "Date"] + [:span {:class ["cal-detail-value"]} (str/replace date "-" "/")]]) + (when time-str + [:div {:class ["cal-detail-row"]} + [:span {:class ["cal-detail-label"]} "Time"] + [:span {:class ["cal-detail-value"]} time-str]]) + (when (seq tags) + [:div {:class ["cal-detail-row"]} + [:span {:class ["cal-detail-label"]} "Tags"] + (into [:div {:class ["cal-detail-tags"]}] + (map (fn [t] [:span {:class ["cal-detail-tag"]} t]) tags))]) + (when source + [:div {:class ["cal-detail-row"]} + [:span {:class ["cal-detail-label"]} "Source"] + [:span {:class ["cal-detail-value"]} source]])]]] + + :clj + [:div {:class "cal-detail-overlay"} + [:div {:class (str "cal-detail " (event-color-class color) + (when class (str " " class)))} + [:div {:class "cal-detail-header"} + [:div {:class "cal-detail-title"} + (when done? [:span {:class "cal-detail-badge"} "DONE"]) + title] + [:button {:class "cal-detail-close"} "\u00d7"]] + [:div {:class "cal-detail-body"} + (when date + [:div {:class "cal-detail-row"} + [:span {:class "cal-detail-label"} "Date"] + [:span {:class "cal-detail-value"} (str/replace date "-" "/")]]) + (when time-str + [:div {:class "cal-detail-row"} + [:span {:class "cal-detail-label"} "Time"] + [:span {:class "cal-detail-value"} time-str]]) + (when (seq tags) + [:div {:class "cal-detail-row"} + [:span {:class "cal-detail-label"} "Tags"] + (into [:div {:class "cal-detail-tags"}] + (map (fn [t] [:span {:class "cal-detail-tag"} t]) tags))]) + (when source + [:div {:class "cal-detail-row"} + [:span {:class "cal-detail-label"} "Source"] + [:span {:class "cal-detail-value"} source]])]]])))) + +;; ── Event Pill ────────────────────────────────────────────────────── + (defn event-pill "Render a small event chip for use inside calendar day cells. @@ -133,6 +389,8 @@ [:span {:class "cal-event-time"} time-str]) [:span {:class "cal-event-title"} title]]))) +;; ── Event Day Cell ────────────────────────────────────────────────── + (defn event-day-cell "Render a day cell with event pills for the calendar event grid. @@ -193,6 +451,8 @@ (when (pos? overflow) [[:div {:class "cal-event-more"} (str "+" overflow " more")]])))]))) +;; ── Calendar Event Grid ───────────────────────────────────────────── + (defn calendar-event-grid "Render a month grid calendar with events displayed in day cells. @@ -207,19 +467,32 @@ :on-next-month - callback for next month nav :on-event-click - callback for event click :max-visible - max events per cell (default 3) + :loading? - show loading indicator + :header-actions - extra hiccup to render in header right side :class - additional CSS classes :attrs - additional HTML attributes" [{:keys [year month today-str selected-date events on-select on-prev-month on-next-month on-event-click max-visible - class attrs]}] + loading? header-actions class attrs]}] (let [days (cal/calendar-days year month)] #?(:squint (let [classes (cond-> "cal cal-has-events" class (str " " class)) base-attrs (merge {:class classes} attrs)] [:div base-attrs - (cal/calendar-header {:year year :month month - :on-prev-month on-prev-month - :on-next-month on-next-month}) + [:div {:class "cal-nav"} + (button/button {:variant "ghost" :icon "chevron-left" :size "sm" + :on-click on-prev-month + :class "cal-nav-btn" + :attrs {:aria-label "Previous month"}}) + [:div {:class "cal-nav-title"} + (str (cal/month-name month) " " year) + (when loading? (loading-indicator))] + [:div {:class "cal-nav-actions"} + header-actions + (button/button {:variant "ghost" :icon "chevron-right" :size "sm" + :on-click on-next-month + :class "cal-nav-btn" + :attrs {:aria-label "Next month"}})]] (cal/calendar-weekdays {}) (into [:div {:class "cal-grid cal-grid-events"}] (map (fn [day-info] @@ -237,9 +510,20 @@ classes (cond-> cls class (conj class)) base-attrs (merge {:class classes} attrs)] [:div base-attrs - (cal/calendar-header {:year year :month month - :on-prev-month on-prev-month - :on-next-month on-next-month}) + [:div {:class ["cal-nav"]} + (button/button {:variant :ghost :icon :chevron-left :size :sm + :on-click on-prev-month + :class "cal-nav-btn" + :attrs {:aria-label "Previous month"}}) + [:div {:class ["cal-nav-title"]} + (str (cal/month-name month) " " year) + (when loading? (loading-indicator))] + [:div {:class ["cal-nav-actions"]} + header-actions + (button/button {:variant :ghost :icon :chevron-right :size :sm + :on-click on-next-month + :class "cal-nav-btn" + :attrs {:aria-label "Next month"}})]] (cal/calendar-weekdays {}) (into [:div {:class ["cal-grid" "cal-grid-events"]}] (map (fn [day-info] @@ -256,7 +540,18 @@ (let [classes (cond-> "cal cal-has-events" class (str " " class)) base-attrs (merge {:class classes} attrs)] [:div base-attrs - (cal/calendar-header {:year year :month month}) + [:div {:class "cal-nav"} + (button/button {:variant :ghost :icon :chevron-left :size :sm + :class "cal-nav-btn" + :attrs {:aria-label "Previous month"}}) + [:div {:class "cal-nav-title"} + (str (cal/month-name month) " " year) + (when loading? (loading-indicator))] + [:div {:class "cal-nav-actions"} + header-actions + (button/button {:variant :ghost :icon :chevron-right :size :sm + :class "cal-nav-btn" + :attrs {:aria-label "Next month"}})]] (cal/calendar-weekdays {}) (into [:div {:class "cal-grid cal-grid-events"}] (map (fn [day-info] @@ -321,12 +616,8 @@ (map (fn [evt] (ticker-dot {:event evt})) (take 4 day-evts)))]))) -(def ^:private weekday-short-names - ["Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun"]) - (defn ticker-strip "Render a horizontal scrollable day ticker strip. - Shows days from the given list with event dot indicators. Props: :days - vector of {:date :day-num :day-label} maps @@ -424,18 +715,21 @@ "Render a day group in the agenda list with header and event rows. Props: - :date - YYYY-MM-DD string - :label - display label (e.g. 'Today', 'Tomorrow', 'Mon') - :events - all events (filtered internally) - :on-event-click - callback for event click" - [{:keys [date label events on-event-click]}] + :date - YYYY-MM-DD string + :label - display label (e.g. 'Today', 'Tomorrow', 'Mon') + :events - all events (filtered internally) + :on-event-click - callback for event click + :day-actions - extra hiccup to render in the day header (e.g. add button)" + [{:keys [date label events on-event-click day-actions]}] (let [day-evts (events-for-date events date)] (when (seq day-evts) #?(:squint [:div {:class "cal-agenda-day-group"} [:div {:class "cal-agenda-day-header"} - [:span {:class "cal-agenda-day-label"} label] - [:span {:class "cal-agenda-day-date"} (str/replace date "-" "/")]] + [:div {:class "cal-agenda-day-header-left"} + [:span {:class "cal-agenda-day-label"} label] + [:span {:class "cal-agenda-day-date"} (str/replace date "-" "/")]] + (when day-actions day-actions)] (into [:div {:class "cal-agenda-day-events"}] (map (fn [evt] (agenda-event-row {:event evt :on-click on-event-click})) @@ -444,8 +738,10 @@ :cljs [:div {:class ["cal-agenda-day-group"]} [:div {:class ["cal-agenda-day-header"]} - [:span {:class ["cal-agenda-day-label"]} label] - [:span {:class ["cal-agenda-day-date"]} (str/replace date "-" "/")]] + [:div {:class ["cal-agenda-day-header-left"]} + [:span {:class ["cal-agenda-day-label"]} label] + [:span {:class ["cal-agenda-day-date"]} (str/replace date "-" "/")]] + (when day-actions day-actions)] (into [:div {:class ["cal-agenda-day-events"]}] (map (fn [evt] (agenda-event-row {:event evt :on-click on-event-click})) @@ -454,8 +750,10 @@ :clj [:div {:class "cal-agenda-day-group"} [:div {:class "cal-agenda-day-header"} - [:span {:class "cal-agenda-day-label"} label] - [:span {:class "cal-agenda-day-date"} (str/replace date "-" "/")]] + [:div {:class "cal-agenda-day-header-left"} + [:span {:class "cal-agenda-day-label"} label] + [:span {:class "cal-agenda-day-date"} (str/replace date "-" "/")]] + (when day-actions day-actions)] (into [:div {:class "cal-agenda-day-events"}] (map (fn [evt] (agenda-event-row {:event evt})) @@ -468,14 +766,17 @@ :days - vector of {:date :label} maps for days to show :events - all events :on-event-click - callback for event click + :day-actions-fn - (fn [date-str] hiccup) — optional, renders action slot per day header :class - additional CSS classes :attrs - additional HTML attributes" - [{:keys [days events on-event-click class attrs]}] + [{:keys [days events on-event-click day-actions-fn class attrs]}] (let [groups (keep (fn [d] (agenda-day-group {:date (:date d) :label (:label d) :events events - :on-event-click on-event-click})) + :on-event-click on-event-click + :day-actions (when day-actions-fn + (day-actions-fn (:date d)))})) days) empty? (not (seq groups))] #?(:squint diff --git a/src/ui/calendar_events.css b/src/ui/calendar_events.css index 6dd0509..ddd0c18 100644 --- a/src/ui/calendar_events.css +++ b/src/ui/calendar_events.css @@ -439,6 +439,292 @@ font-size: var(--font-sm); } +/* ── Nav Actions (header right side slot) ────────────────────────── */ + +.cal-nav-actions { + display: flex; + align-items: center; + gap: var(--size-1); +} + +/* ── View Toggle ─────────────────────────────────────────────────── */ + +.cal-view-toggle { + display: flex; + background: var(--bg-2); + border-radius: var(--radius-sm); + padding: var(--size-1); + gap: var(--size-1); +} + +.cal-view-btn { + appearance: none; + border: none; + background: transparent; + color: var(--fg-2); + font-family: inherit; + font-size: var(--font-xs); + font-weight: 600; + padding: var(--size-1) var(--size-2); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.cal-view-btn:hover { + color: var(--fg-1); +} + +.cal-view-btn-active { + background: var(--bg-1); + color: var(--fg-0); + box-shadow: var(--shadow-0); +} + +/* ── Loading Indicator ───────────────────────────────────────────── */ + +.cal-loading { + width: var(--size-2); + height: var(--size-2); + border-radius: 9999px; + background: var(--accent); + animation: cal-pulse 0.9s ease-in-out infinite; + flex-shrink: 0; + display: inline-block; + margin-left: var(--size-2); + vertical-align: middle; +} + +@keyframes cal-pulse { + 0%, 100% { opacity: 0.3; transform: scale(0.8); } + 50% { opacity: 1; transform: scale(1.2); } +} + +/* ── Error Banner ────────────────────────────────────────────────── */ + +.cal-error-banner { + background: var(--danger-100); + border-bottom: 1px solid var(--danger-200); + color: var(--danger-800); + padding: var(--size-2) var(--size-4); + display: flex; + align-items: center; + justify-content: space-between; + font-size: var(--font-xs); + font-weight: 500; +} + +.cal-error-dismiss { + appearance: none; + border: none; + background: none; + color: var(--danger-800); + cursor: pointer; + font-size: var(--font-md); + padding: 0 var(--size-1); + opacity: 0.6; + line-height: 1; +} + +.cal-error-dismiss:hover { + opacity: 1; +} + +[data-theme="dark"] .cal-error-banner { + background: var(--danger-950); + border-bottom-color: var(--danger-900); + color: var(--danger-200); +} + +[data-theme="dark"] .cal-error-dismiss { + color: var(--danger-200); +} + +/* ── Source Filter Toggles ───────────────────────────────────────── */ + +.cal-source-toggles { + display: flex; + gap: var(--size-1); + padding: var(--size-2) var(--size-4); + border-bottom: var(--border-0); + background: var(--bg-1); + flex-wrap: wrap; +} + +.cal-source-toggle { + appearance: none; + border: none; + padding: var(--size-1) var(--size-2); + border-radius: 9999px; + font-family: inherit; + font-size: var(--font-xs); + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.cal-source-toggle:hover { + filter: brightness(0.95); +} + +.cal-source-inactive { + background: var(--bg-2) !important; + color: var(--fg-2) !important; + border-left-color: transparent !important; +} + +/* ── Agenda Day Header (with action slot) ────────────────────────── */ + +.cal-agenda-day-header-left { + display: flex; + align-items: baseline; + gap: var(--size-2); + flex: 1; +} + +.cal-agenda-day-header { + justify-content: space-between; +} + +/* ── Event Detail Dialog ─────────────────────────────────────────── */ + +.cal-detail-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: var(--size-4); + animation: cal-fade-in 0.15s ease; +} + +@keyframes cal-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.cal-detail { + background: var(--bg-1); + border-radius: var(--radius-md); + width: 100%; + max-width: 28rem; + box-shadow: var(--shadow-3); + border-left: 4px solid transparent; + animation: cal-dialog-in 0.15s ease; + overflow: hidden; +} + +@keyframes cal-dialog-in { + from { opacity: 0; transform: scale(0.96) translateY(var(--size-2)); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.cal-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--size-4); + padding: var(--size-4) var(--size-4) var(--size-2); +} + +.cal-detail-title { + font-size: var(--font-base); + font-weight: 600; + line-height: 1.35; + display: flex; + align-items: baseline; + gap: var(--size-2); + flex: 1; + min-width: 0; + word-break: break-word; +} + +.cal-detail-badge { + font-size: var(--font-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: var(--size-1) var(--size-1); + border-radius: var(--radius-sm); + background: var(--bg-2); + color: var(--fg-1); + flex-shrink: 0; + white-space: nowrap; +} + +.cal-detail-close { + appearance: none; + border: none; + background: none; + color: var(--fg-2); + cursor: pointer; + font-size: var(--font-lg); + line-height: 1; + padding: 0; + flex-shrink: 0; + transition: color 0.15s; +} + +.cal-detail-close:hover { + color: var(--fg-0); +} + +.cal-detail-body { + padding: 0 var(--size-4) var(--size-4); + display: flex; + flex-direction: column; + gap: var(--size-2); +} + +.cal-detail-row { + display: flex; + align-items: baseline; + gap: var(--size-3); +} + +.cal-detail-label { + font-size: var(--font-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-2); + width: var(--size-16); + flex-shrink: 0; +} + +.cal-detail-value { + font-size: var(--font-sm); + color: var(--fg-0); + font-weight: 400; +} + +.cal-detail-tags { + display: flex; + flex-wrap: wrap; + gap: var(--size-1); +} + +.cal-detail-tag { + font-size: var(--font-xs); + font-weight: 600; + padding: var(--size-1) var(--size-2); + border-radius: 9999px; + background: var(--bg-2); + color: var(--fg-1); +} + +[data-theme="dark"] .cal-detail { + box-shadow: var(--shadow-3); +} + +[data-theme="dark"] .cal-detail-overlay { + background: rgba(0, 0, 0, 0.5); +} + /* ── Responsive ──────────────────────────────────────────────────── */ @media (max-width: 768px) { diff --git a/test/ui/calendar_events_test.clj b/test/ui/calendar_events_test.clj index 795e1cf..39bcd99 100644 --- a/test/ui/calendar_events_test.clj +++ b/test/ui/calendar_events_test.clj @@ -115,3 +115,61 @@ :label "Wed" :events sample-events})] (is (nil? result))))) + +;; ── New component tests ───────────────────────────────────────────── + +(deftest view-toggle-class-list-test + (testing "active" + (is (= ["cal-view-btn" "cal-view-btn-active"] + (cal-events/view-toggle-class-list {:active? true})))) + (testing "inactive" + (is (= ["cal-view-btn"] + (cal-events/view-toggle-class-list {:active? false}))))) + +(deftest source-toggle-class-list-test + (testing "active source" + (is (= ["cal-source-toggle" "cal-event-accent"] + (cal-events/source-toggle-class-list {:color :accent :active? true})))) + (testing "inactive source" + (is (= ["cal-source-toggle" "cal-event-danger" "cal-source-inactive"] + (cal-events/source-toggle-class-list {:color :danger :active? false}))))) + +(deftest view-toggle-component-test + (testing "renders view toggle (clj target)" + (let [result (cal-events/view-toggle {:view :month})] + (is (= :div (first result))) + (is (= "cal-view-toggle" (get-in result [1 :class])))))) + +(deftest loading-indicator-test + (testing "renders loading indicator" + (let [result (cal-events/loading-indicator)] + (is (= :div (first result))) + (is (= "cal-loading" (get-in result [1 :class])))))) + +(deftest error-banner-test + (testing "renders error with message" + (let [result (cal-events/error-banner {:message "Something broke"})] + (is (some? result)) + (is (= :div (first result))))) + (testing "returns nil with no message" + (is (nil? (cal-events/error-banner {:message nil}))))) + +(deftest source-toggles-test + (testing "renders source toggles" + (let [result (cal-events/source-toggles + {:sources [{:name "Work" :color :accent :active? true} + {:name "Personal" :color :success :active? false}]})] + (is (some? result)) + (is (= :div (first result))))) + (testing "returns nil with no sources" + (is (nil? (cal-events/source-toggles {:sources []}))))) + +(deftest event-detail-dialog-test + (testing "renders detail dialog with event" + (let [evt {:title "Meeting" :date "2026-03-29" :color :accent + :time-start "10:00" :tags ["work"] :source "cal:Work"} + result (cal-events/event-detail-dialog {:event evt})] + (is (some? result)) + (is (= :div (first result))))) + (testing "returns nil with no event" + (is (nil? (cal-events/event-detail-dialog {:event nil})))))