feat: add calendar docs with inline markdown rendering
Add src/ui/calendar.md with full documentation for both calendar namespaces (picker props, event grid, ticker, agenda, event data format, date utilities, CSS classes). Add a minimal markdown-to-hiccup renderer (ui.markdown) that handles headings, fenced code blocks, tables, lists, inline code, and bold. Styled with ui/markdown.css using theme tokens. Each dev target renders the docs inline on the Calendar page: - Hiccup: slurps the .md file at render time - Replicant: embeds via compile-time macro (ui.macros/inline-file) - Squint: fetches from /calendar.md served by Vite Also fixes calendar event grid day cells to be square (aspect-ratio: 1 with overflow: hidden instead of min-height).
This commit is contained in:
7
bb.edn
7
bb.edn
@@ -20,7 +20,12 @@
|
||||
(let [lr (slurp "dev/css-live-reload.js")]
|
||||
(spit "dev/replicant/public/css-live-reload.js" lr)
|
||||
(spit "dev/squint/public/css-live-reload.js" lr)
|
||||
(println "Copied css-live-reload.js to dev targets")))}
|
||||
(println "Copied css-live-reload.js to dev targets"))
|
||||
(doseq [md (.listFiles (io/file "src/ui")
|
||||
(reify java.io.FileFilter
|
||||
(accept [_ f] (.endsWith (.getName f) ".md"))))]
|
||||
(spit (str "dev/squint/public/" (.getName md)) (slurp md)))
|
||||
(println "Copied component docs to dev/squint"))}
|
||||
|
||||
watch-theme
|
||||
{:doc "Watch src/ui/*.css and tokens.edn, rebuild theme on changes"
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
[ui.icon :as icon]
|
||||
[ui.separator :as separator]
|
||||
[ui.calendar :as calendar]
|
||||
[ui.calendar-events :as cal-events]))
|
||||
[ui.calendar-events :as cal-events]
|
||||
[ui.markdown :as markdown]))
|
||||
|
||||
;; ── Query Params ────────────────────────────────────────────────────
|
||||
|
||||
@@ -412,6 +413,8 @@
|
||||
(defn calendar-page []
|
||||
[:div
|
||||
(page-header "Calendar" "Date picker, event grid, ticker strip, and agenda list.")
|
||||
(into [:div {:class "md-docs"}]
|
||||
(markdown/markdown->hiccup (slurp "src/ui/calendar.md")))
|
||||
(calendar-demo)])
|
||||
|
||||
(defn icons-page []
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
[ui.icon :as icon]
|
||||
[ui.separator :as separator]
|
||||
[ui.calendar :as calendar]
|
||||
[ui.calendar-events :as cal-events]))
|
||||
[ui.calendar-events :as cal-events]
|
||||
[ui.markdown :as markdown])
|
||||
(:require-macros [ui.macros :refer [inline-file]]))
|
||||
|
||||
;; ── State ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -390,9 +392,13 @@
|
||||
["Dev & Technical"
|
||||
[:code :terminal :database :globe :shield :zap :book-open :map-pin]]])
|
||||
|
||||
(def calendar-docs-md (inline-file "../../src/ui/calendar.md"))
|
||||
|
||||
(defn calendar-page []
|
||||
[:div
|
||||
(page-header "Calendar" "Date picker, event grid, ticker strip, and agenda list.")
|
||||
(into [:div {:class ["md-docs"]}]
|
||||
(markdown/markdown->hiccup calendar-docs-md))
|
||||
(calendar-demo)])
|
||||
|
||||
(defn icons-page []
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
[ui.icon :as icon]
|
||||
[ui.separator :as separator]
|
||||
[ui.calendar :as calendar]
|
||||
[ui.calendar-events :as cal-events]))
|
||||
[ui.calendar-events :as cal-events]
|
||||
[ui.markdown :as markdown]))
|
||||
|
||||
;; ── State ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -433,9 +434,23 @@
|
||||
(into [:div {:style {"display" "grid" "grid-template-columns" "repeat(auto-fill, minmax(5rem, 1fr))" "gap" "var(--size-4)"}}]
|
||||
(map icon-card icons)))))
|
||||
|
||||
(def !calendar-docs (atom nil))
|
||||
|
||||
(defn load-calendar-docs! []
|
||||
(when-not @!calendar-docs
|
||||
(-> (js/fetch "/calendar.md")
|
||||
(.then (fn [r] (.text r)))
|
||||
(.then (fn [text]
|
||||
(reset! !calendar-docs text)
|
||||
(render!))))))
|
||||
|
||||
(defn calendar-page []
|
||||
(load-calendar-docs!)
|
||||
[:div
|
||||
(page-header "Calendar" "Date picker, event grid, ticker strip, and agenda list.")
|
||||
(when-let [md @!calendar-docs]
|
||||
(into [:div {:class "md-docs"}]
|
||||
(markdown/markdown->hiccup md)))
|
||||
(calendar-demo)])
|
||||
|
||||
(defn icons-page []
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
(ns ui.calendar
|
||||
"Month-grid date picker. See src/ui/calendar.md for full documentation."
|
||||
(:require [clojure.string :as str]
|
||||
[ui.button :as button]
|
||||
[ui.icon :as icon]))
|
||||
|
||||
115
src/ui/calendar.md
Normal file
115
src/ui/calendar.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Calendar
|
||||
|
||||
A month-grid date picker and event calendar inspired by [shadcn/radix Calendar](https://ui.shadcn.com/docs/components/radix/calendar) and the org-mode-agenda-cli.
|
||||
|
||||
Two namespaces: `ui.calendar` for the core date picker, `ui.calendar-events` for event grid, ticker strip, and agenda list.
|
||||
|
||||
## Date Picker
|
||||
|
||||
A compact month grid with navigation, today highlighting, and date selection.
|
||||
|
||||
```clojure
|
||||
;; Minimal static calendar
|
||||
(calendar/calendar {:year 2026 :month 3 :today-str "2026-03-29"})
|
||||
|
||||
;; Interactive with selection
|
||||
(calendar/calendar {:year 2026 :month 3
|
||||
:today-str "2026-03-29"
|
||||
:selected-date "2026-03-15"
|
||||
:on-select (fn [date-str] (println date-str))
|
||||
:on-prev-month (fn [_] ...)
|
||||
:on-next-month (fn [_] ...)})
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------------------|--------|-------------------------------------------|
|
||||
| `:year` | int | Displayed year (e.g. 2026) |
|
||||
| `:month` | int | Displayed month (1–12) |
|
||||
| `:today-str` | string | Today's date as `"YYYY-MM-DD"` |
|
||||
| `:selected-date` | string | Selected date as `"YYYY-MM-DD"` or nil |
|
||||
| `:on-select` | fn | `(fn [date-str] ...)` — on day click |
|
||||
| `:on-prev-month` | fn | Called on prev-month button click |
|
||||
| `:on-next-month` | fn | Called on next-month button click |
|
||||
| `:class` | string | Additional CSS classes |
|
||||
| `:attrs` | map | Additional HTML attributes |
|
||||
|
||||
## Event Grid
|
||||
|
||||
A full month grid with colored event pills inside day cells.
|
||||
|
||||
```clojure
|
||||
(cal-events/calendar-event-grid
|
||||
{:year 2026 :month 3
|
||||
:today-str "2026-03-29"
|
||||
:events [{:title "Meeting" :date "2026-03-29" :time-start "10:00" :color :accent}]
|
||||
:on-select (fn [date-str] ...)
|
||||
:on-prev-month (fn [_] ...)
|
||||
:on-next-month (fn [_] ...)
|
||||
:on-event-click (fn [event-map] ...)
|
||||
:max-visible 3})
|
||||
```
|
||||
|
||||
## Day Ticker
|
||||
|
||||
Horizontal scrollable strip showing days with event dot indicators.
|
||||
|
||||
```clojure
|
||||
(cal-events/ticker-strip
|
||||
{:days [{:date "2026-03-29" :day-num 29 :day-label "Su"} ...]
|
||||
:today-str "2026-03-29"
|
||||
:selected "2026-03-29"
|
||||
:events events
|
||||
:on-select (fn [date-str] ...)})
|
||||
```
|
||||
|
||||
## Agenda List
|
||||
|
||||
Vertical list of events grouped by day.
|
||||
|
||||
```clojure
|
||||
(cal-events/agenda-list
|
||||
{:days [{:date "2026-03-29" :label "Today"} ...]
|
||||
:events events
|
||||
:on-event-click (fn [event-map] ...)})
|
||||
```
|
||||
|
||||
## Event Data Format
|
||||
|
||||
Events are plain maps:
|
||||
|
||||
```clojure
|
||||
{:title "Team standup"
|
||||
:date "2026-03-29" ;; YYYY-MM-DD
|
||||
: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}
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
- `(days-in-month year month)` — days in a month (handles leap years)
|
||||
- `(day-of-week year month day)` — 0=Mon..6=Sun
|
||||
- `(first-day-of-week year month)` — weekday of the 1st
|
||||
- `(calendar-days year month)` — full grid including prev/next month padding
|
||||
- `(prev-month year month)` / `(next-month year month)` — returns `[year month]`
|
||||
- `(date-str year month day)` — formats as `"YYYY-MM-DD"`
|
||||
|
||||
## CSS Classes
|
||||
|
||||
The calendar uses `cal-` prefixed classes. Key states on day cells:
|
||||
|
||||
- `.cal-day-today` — today's date
|
||||
- `.cal-day-selected` — currently selected date
|
||||
- `.cal-day-outside` — days from adjacent months
|
||||
- `.cal-day-disabled` — unselectable days
|
||||
|
||||
Event pills use color classes: `.cal-event-accent`, `.cal-event-danger`, `.cal-event-success`, `.cal-event-warning`, `.cal-event-default`.
|
||||
@@ -1,4 +1,5 @@
|
||||
(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]))
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
.cal-event-day {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-height: var(--size-16);
|
||||
aspect-ratio: 1;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
flex-direction: column;
|
||||
@@ -24,6 +24,7 @@
|
||||
border-right: var(--border-0);
|
||||
border-bottom: var(--border-0);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cal-grid-events .cal-day:nth-child(7n) {
|
||||
@@ -442,7 +443,6 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cal-event-day {
|
||||
min-height: var(--size-12);
|
||||
padding: var(--size-1);
|
||||
}
|
||||
|
||||
|
||||
7
src/ui/macros.clj
Normal file
7
src/ui/macros.clj
Normal file
@@ -0,0 +1,7 @@
|
||||
(ns ui.macros)
|
||||
|
||||
(defmacro inline-file
|
||||
"Read a file at compile time and return its contents as a string.
|
||||
For use in ClojureScript (shadow-cljs) to embed file content."
|
||||
[path]
|
||||
(slurp path))
|
||||
183
src/ui/markdown.cljc
Normal file
183
src/ui/markdown.cljc
Normal file
@@ -0,0 +1,183 @@
|
||||
(ns ui.markdown
|
||||
"Minimal markdown-to-hiccup renderer for dev documentation pages.
|
||||
Supports: headings, fenced code blocks, tables, unordered lists,
|
||||
inline code, bold, paragraphs."
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
(defn- parse-inline
|
||||
"Replace inline markdown (backticks, bold) with hiccup fragments.
|
||||
Returns a vector of strings and hiccup nodes."
|
||||
[text]
|
||||
(if (str/blank? text)
|
||||
[text]
|
||||
(loop [remaining text
|
||||
result []]
|
||||
(if (str/blank? remaining)
|
||||
result
|
||||
(let [;; Find the next special marker
|
||||
code-idx (str/index-of remaining "`")
|
||||
bold-idx (str/index-of remaining "**")]
|
||||
(cond
|
||||
;; Inline code is nearest
|
||||
(and code-idx (or (nil? bold-idx) (<= code-idx bold-idx)))
|
||||
(let [before (subs remaining 0 code-idx)
|
||||
after (subs remaining (inc code-idx))
|
||||
end-idx (str/index-of after "`")]
|
||||
(if end-idx
|
||||
(let [code (subs after 0 end-idx)
|
||||
rest (subs after (inc end-idx))]
|
||||
(recur rest (cond-> result
|
||||
(seq before) (conj before)
|
||||
true (conj [:code {:class "md-inline-code"} code]))))
|
||||
;; No closing backtick — treat as literal
|
||||
(conj result remaining)))
|
||||
|
||||
;; Bold is nearest
|
||||
(and bold-idx (or (nil? code-idx) (< bold-idx code-idx)))
|
||||
(let [before (subs remaining 0 bold-idx)
|
||||
after (subs remaining (+ bold-idx 2))
|
||||
end-idx (str/index-of after "**")]
|
||||
(if end-idx
|
||||
(let [bold (subs after 0 end-idx)
|
||||
rest (subs after (+ end-idx 2))]
|
||||
(recur rest (cond-> result
|
||||
(seq before) (conj before)
|
||||
true (conj [:strong bold]))))
|
||||
(conj result remaining)))
|
||||
|
||||
:else
|
||||
(conj result remaining)))))))
|
||||
|
||||
(defn- heading-level [line]
|
||||
(let [trimmed (str/triml line)]
|
||||
(when (str/starts-with? trimmed "#")
|
||||
(let [hashes (re-find #"^#{1,6}" trimmed)]
|
||||
(when hashes
|
||||
(let [n (count hashes)
|
||||
text (str/trim (subs trimmed n))]
|
||||
[n text]))))))
|
||||
|
||||
(defn- table-row? [line]
|
||||
(and (str/starts-with? (str/trim line) "|")
|
||||
(str/ends-with? (str/trim line) "|")))
|
||||
|
||||
(defn- separator-row? [line]
|
||||
(and (table-row? line)
|
||||
(every? #(re-matches #"\s*:?-+:?\s*" %)
|
||||
(-> (str/trim line)
|
||||
(subs 1)
|
||||
(str/replace #"\|$" "")
|
||||
(str/split #"\|")))))
|
||||
|
||||
(defn- parse-table-cells [line]
|
||||
(->> (-> (str/trim line)
|
||||
(subs 1)
|
||||
(str/replace #"\|$" "")
|
||||
(str/split #"\|"))
|
||||
(mapv str/trim)))
|
||||
|
||||
(defn- list-item? [line]
|
||||
(re-matches #"^\s*[-*]\s+.+" line))
|
||||
|
||||
(defn- list-item-text [line]
|
||||
(str/replace line #"^\s*[-*]\s+" ""))
|
||||
|
||||
(defn markdown->hiccup
|
||||
"Convert a markdown string to a hiccup data structure.
|
||||
Returns a vector of hiccup elements."
|
||||
[md-str]
|
||||
(let [lines (str/split-lines md-str)]
|
||||
(loop [i 0
|
||||
result []]
|
||||
(if (>= i (count lines))
|
||||
result
|
||||
(let [line (nth lines i)]
|
||||
(cond
|
||||
;; Blank line — skip
|
||||
(str/blank? line)
|
||||
(recur (inc i) result)
|
||||
|
||||
;; Fenced code block
|
||||
(str/starts-with? (str/trim line) "```")
|
||||
(let [lang (str/replace (str/trim (subs (str/trim line) 3)) #"\"" "")
|
||||
;; Collect lines until closing ```
|
||||
code-lines (loop [j (inc i)
|
||||
acc []]
|
||||
(if (>= j (count lines))
|
||||
[acc j]
|
||||
(let [l (nth lines j)]
|
||||
(if (str/starts-with? (str/trim l) "```")
|
||||
[acc (inc j)]
|
||||
(recur (inc j) (conj acc l))))))]
|
||||
(recur (second code-lines)
|
||||
(conj result
|
||||
[:pre {:class "md-code-block"}
|
||||
[:code {:class (when (seq lang) (str "language-" lang))}
|
||||
(str/join "\n" (first code-lines))]])))
|
||||
|
||||
;; Heading
|
||||
(heading-level line)
|
||||
(let [[n text] (heading-level line)
|
||||
tag (keyword (str "h" n))]
|
||||
(recur (inc i)
|
||||
(conj result (into [tag {:class "md-heading"}] (parse-inline text)))))
|
||||
|
||||
;; Table
|
||||
(table-row? line)
|
||||
(let [header-cells (parse-table-cells line)
|
||||
;; Skip separator row, collect data rows
|
||||
body-start (if (and (< (inc i) (count lines))
|
||||
(separator-row? (nth lines (inc i))))
|
||||
(+ i 2)
|
||||
(inc i))
|
||||
body-rows (loop [j body-start
|
||||
acc []]
|
||||
(if (and (< j (count lines))
|
||||
(table-row? (nth lines j)))
|
||||
(recur (inc j) (conj acc (parse-table-cells (nth lines j))))
|
||||
[acc j]))]
|
||||
(recur (second body-rows)
|
||||
(conj result
|
||||
[:table {:class "md-table"}
|
||||
[:thead
|
||||
(into [:tr]
|
||||
(map (fn [c] (into [:th] (parse-inline c)))
|
||||
header-cells))]
|
||||
(into [:tbody]
|
||||
(map (fn [row]
|
||||
(into [:tr]
|
||||
(map (fn [c] (into [:td] (parse-inline c)))
|
||||
row)))
|
||||
(first body-rows)))])))
|
||||
|
||||
;; Unordered list — collect consecutive items
|
||||
(list-item? line)
|
||||
(let [items (loop [j i
|
||||
acc []]
|
||||
(if (and (< j (count lines))
|
||||
(list-item? (nth lines j)))
|
||||
(recur (inc j) (conj acc (list-item-text (nth lines j))))
|
||||
[acc j]))]
|
||||
(recur (second items)
|
||||
(conj result
|
||||
(into [:ul {:class "md-list"}]
|
||||
(map (fn [txt] (into [:li] (parse-inline txt)))
|
||||
(first items))))))
|
||||
|
||||
;; Paragraph (default)
|
||||
:else
|
||||
(let [;; Collect consecutive non-blank, non-special lines
|
||||
para (loop [j i
|
||||
acc []]
|
||||
(if (and (< j (count lines))
|
||||
(not (str/blank? (nth lines j)))
|
||||
(not (str/starts-with? (str/trim (nth lines j)) "```"))
|
||||
(not (str/starts-with? (str/trim (nth lines j)) "#"))
|
||||
(not (table-row? (nth lines j)))
|
||||
(not (list-item? (nth lines j))))
|
||||
(recur (inc j) (conj acc (nth lines j)))
|
||||
[acc j]))]
|
||||
(recur (second para)
|
||||
(conj result
|
||||
(into [:p {:class "md-paragraph"}]
|
||||
(parse-inline (str/join " " (first para)))))))))))))
|
||||
79
src/ui/markdown.css
Normal file
79
src/ui/markdown.css
Normal file
@@ -0,0 +1,79 @@
|
||||
/* ── Markdown Docs ────────────────────────────────────────────────── */
|
||||
|
||||
.md-docs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-3);
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--fg-0);
|
||||
}
|
||||
|
||||
.md-heading {
|
||||
margin: 0;
|
||||
color: var(--fg-0);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.md-docs h1.md-heading { font-size: var(--font-xl); margin-top: var(--size-2); }
|
||||
.md-docs h2.md-heading { font-size: var(--font-lg); margin-top: var(--size-4); padding-bottom: var(--size-1); border-bottom: var(--border-0); }
|
||||
.md-docs h3.md-heading { font-size: var(--font-md); margin-top: var(--size-2); }
|
||||
|
||||
.md-paragraph {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md-inline-code {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
padding: var(--size-1);
|
||||
background: var(--bg-2);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.md-code-block {
|
||||
margin: 0;
|
||||
padding: var(--size-3);
|
||||
background: var(--bg-2);
|
||||
border-radius: var(--radius-md);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.md-code-block code {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||
font-size: var(--font-xs);
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.md-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.md-table th,
|
||||
.md-table td {
|
||||
padding: var(--size-2) var(--size-3);
|
||||
text-align: left;
|
||||
border-bottom: var(--border-0);
|
||||
}
|
||||
|
||||
.md-table th {
|
||||
font-weight: 600;
|
||||
color: var(--fg-1);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
|
||||
.md-table td {
|
||||
color: var(--fg-0);
|
||||
}
|
||||
|
||||
.md-list {
|
||||
margin: 0;
|
||||
padding-left: var(--size-5);
|
||||
}
|
||||
|
||||
.md-list li {
|
||||
margin-bottom: var(--size-1);
|
||||
}
|
||||
Reference in New Issue
Block a user