feat: add tasks list recipe and small badge size variant

Add a shadcn-inspired tasks list recipe (src/recipes/tasks.cljc) that
composes card, table, badge, button, form, and icon components into a
full task management page with toolbar, data table, and pagination.

Add :size :sm prop to the badge component for compact inline labels
used in the tasks table. Small badges have tighter padding, smaller
font, and full pill border-radius.

Wire the tasks page into all three dev targets (hiccup, replicant,
squint) with navigation and routing. Add small badge demos to the
components overview in all targets.
This commit is contained in:
Florian Schroedl
2026-03-19 14:12:34 +01:00
parent 63e853b6ac
commit 293df10590
8 changed files with 473 additions and 2098 deletions

342
src/recipes/tasks.cljc Normal file
View File

@@ -0,0 +1,342 @@
(ns recipes.tasks
(:require [clojure.string :as str]
[ui.badge :as badge]
[ui.button :as button]
[ui.card :as card]
[ui.form :as form]
[ui.icon :as icon]
[ui.separator :as separator]))
;; In squint, keywords are strings — name is identity
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
:clj (defn- kw-name [s] (name s)))
;; ── Data Model ──────────────────────────────────────────────────────
(def statuses
[{:value "backlog" :label "Backlog" :icon :inbox}
{:value "todo" :label "Todo" :icon :alert-circle}
{:value "in-progress" :label "In Progress" :icon :clock}
{:value "done" :label "Done" :icon :circle-check}
{:value "canceled" :label "Canceled" :icon :circle-x}])
(def priorities
[{:value "low" :label "Low" :icon :arrow-down}
{:value "medium" :label "Medium" :icon :arrow-right}
{:value "high" :label "High" :icon :arrow-up}])
(def labels
[{:value "bug" :label "Bug"}
{:value "feature" :label "Feature"}
{:value "documentation" :label "Documentation"}])
(defn- find-by-value [coll v]
(first (filter #(= (:value %) v) coll)))
;; ── Sample Data ─────────────────────────────────────────────────────
(def sample-tasks
[{:id "TASK-8782" :label "documentation" :title "You can't compress the program without quantifying the open-source SSD pixel!" :status "in-progress" :priority "medium"}
{:id "TASK-7878" :label "documentation" :title "Try to calculate the EXE feed, maybe it will index the multi-byte pixel!" :status "backlog" :priority "medium"}
{:id "TASK-7839" :label "bug" :title "We need to bypass the neural TCP card!" :status "todo" :priority "high"}
{:id "TASK-5562" :label "feature" :title "The SAS interface is down, bypass the open-source pixel so we can back up the PNG bandwidth!" :status "backlog" :priority "medium"}
{:id "TASK-8686" :label "feature" :title "I'll parse the wireless SSL protocol, that should driver the API panel!" :status "canceled" :priority "medium"}
{:id "TASK-1280" :label "bug" :title "Use the digital TLS panel, then you can transmit the haptic system!" :status "done" :priority "high"}
{:id "TASK-7262" :label "feature" :title "The UTF8 application is down, parse the neural bandwidth so we can back up the PNG firewall!" :status "done" :priority "high"}
{:id "TASK-1138" :label "feature" :title "Generating the driver won't do anything, we need to quantify the 1080p SMTP bandwidth!" :status "in-progress" :priority "medium"}
{:id "TASK-7184" :label "feature" :title "We need to program the back-end THX pixel!" :status "todo" :priority "low"}
{:id "TASK-5160" :label "documentation" :title "Calculating the bus won't do anything, we need to navigate the back-end JSON protocol!" :status "in-progress" :priority "high"}
{:id "TASK-5618" :label "documentation" :title "Generating the driver won't do anything, we need to index the online SSL application!" :status "done" :priority "medium"}
{:id "TASK-6699" :label "documentation" :title "I'll transmit the wireless JBOD capacitor, that should hard drive the SSD feed!" :status "backlog" :priority "medium"}
{:id "TASK-2858" :label "bug" :title "We need to override the online UDP bus!" :status "backlog" :priority "medium"}
{:id "TASK-9864" :label "bug" :title "I'll reboot the 1080p FTP panel, that should matrix the HEX hard drive!" :status "done" :priority "high"}
{:id "TASK-8404" :label "bug" :title "We need to generate the virtual HEX alarm!" :status "in-progress" :priority "low"}
{:id "TASK-5365" :label "documentation" :title "Backing up the pixel won't do anything, we need to transmit the primary IB array!" :status "in-progress" :priority "low"}
{:id "TASK-1780" :label "documentation" :title "The CSS feed is down, index the bluetooth transmitter so we can compress the CLI protocol!" :status "todo" :priority "high"}
{:id "TASK-6938" :label "documentation" :title "Use the redundant SCSI application, then you can hack the optical alarm!" :status "todo" :priority "high"}
{:id "TASK-9885" :label "bug" :title "We need to compress the auxiliary VGA driver!" :status "backlog" :priority "high"}
{:id "TASK-3216" :label "documentation" :title "Transmitting the transmitter won't do anything, we need to compress the virtual HDD sensor!" :status "backlog" :priority "medium"}])
;; ── Sub-components ──────────────────────────────────────────────────
(defn- status-display
"Render a status value with its icon."
[status-value]
(let [{:keys [label icon]} (find-by-value statuses status-value)]
#?(:squint
[:span {:class "tasks-status"}
(icon/icon {:icon-name icon :size :sm})
(or label status-value)]
:cljs
[:span {:class ["tasks-status"]}
(icon/icon {:icon-name icon :size :sm})
(or label status-value)]
:clj
[:span {:class "tasks-status"}
(icon/icon {:icon-name icon :size :sm})
(or label status-value)])))
(defn- priority-display
"Render a priority value with its icon."
[priority-value]
(let [{:keys [label icon]} (find-by-value priorities priority-value)]
#?(:squint
[:span {:class "tasks-priority"}
(icon/icon {:icon-name icon :size :sm})
(or label priority-value)]
:cljs
[:span {:class ["tasks-priority"]}
(icon/icon {:icon-name icon :size :sm})
(or label priority-value)]
:clj
[:span {:class "tasks-priority"}
(icon/icon {:icon-name icon :size :sm})
(or label priority-value)])))
(defn- label-badge
"Render a label as a badge."
[label-value]
(badge/badge {:variant :outline :size :sm} label-value))
;; ── Toolbar ─────────────────────────────────────────────────────────
(defn tasks-toolbar
"Render the filter/search toolbar above the tasks table."
[{:keys [#?@(:squint [] :cljs [on-search] :clj [])]}]
#?(:squint
[:div {:class "tasks-toolbar"}
[:div {:class "tasks-toolbar-left"}
(form/form-input {:type "text"
:placeholder "Filter tasks..."
:class "tasks-search"
:attrs {:style {"max-width" "16rem"}}})]
[:div {:class "tasks-toolbar-right"}
(button/button {:variant "secondary" :size "sm"}
(icon/icon {:icon-name "plus" :size "sm"})
"Add Task")]]
:cljs
[:div {:class ["tasks-toolbar"]}
[:div {:class ["tasks-toolbar-left"]}
(form/form-input {:type :text
:placeholder "Filter tasks..."
:class "tasks-search"
:on-change on-search
:attrs {:style {:max-width "16rem"}}})]
[:div {:class ["tasks-toolbar-right"]}
(button/button {:variant :secondary :size :sm}
(icon/icon {:icon-name :plus :size :sm})
"Add Task")]]
:clj
[:div {:class "tasks-toolbar"}
[:div {:class "tasks-toolbar-left"}
(form/form-input {:type :text
:placeholder "Filter tasks..."
:class "tasks-search"
:attrs {:style "max-width: 16rem;"}})]
[:div {:class "tasks-toolbar-right"}
(button/button {:variant :secondary :size :sm}
(icon/icon {:icon-name :plus :size :sm})
"Add Task")]]))
;; ── Task Row ────────────────────────────────────────────────────────
(defn- task-row
"Render a single task as a table row."
[{:keys [id label title status priority]}]
#?(:squint
[:tr
[:td {:class "tasks-cell-checkbox"}
[:input {:type "checkbox" :class "form-checkbox" :aria-label (str "Select task " id)}]]
[:td {:class "tasks-cell-id font-mono text-muted"} id]
[:td {:class "tasks-cell-label"} (when label (label-badge label))]
[:td {:class "tasks-cell-title"}
[:span {:class "tasks-title-text"} title]]
[:td {:class "tasks-cell-status"} (status-display status)]
[:td {:class "tasks-cell-priority"} (priority-display priority)]
[:td {:class "tasks-cell-actions"}
(button/button {:variant "ghost" :size "sm" :class "btn-icon"}
(icon/icon {:icon-name "menu" :size "sm"}))]]
:cljs
[:tr
[:td {:class ["tasks-cell-checkbox"]}
[:input {:type "checkbox" :class ["form-checkbox"] :aria-label (str "Select task " id)}]]
[:td {:class ["tasks-cell-id" "font-mono" "text-muted"]} id]
[:td {:class ["tasks-cell-label"]} (when label (label-badge label))]
[:td {:class ["tasks-cell-title"]}
[:span {:class ["tasks-title-text"]} title]]
[:td {:class ["tasks-cell-status"]} (status-display status)]
[:td {:class ["tasks-cell-priority"]} (priority-display priority)]
[:td {:class ["tasks-cell-actions"]}
(button/button {:variant :ghost :size :sm :class "btn-icon"}
(icon/icon {:icon-name :menu :size :sm}))]]
:clj
[:tr
[:td {:class "tasks-cell-checkbox"}
[:input {:type "checkbox" :class "form-checkbox" :aria-label (str "Select task " id)}]]
[:td {:class "tasks-cell-id font-mono text-muted"} id]
[:td {:class "tasks-cell-label"} (when label (label-badge label))]
[:td {:class "tasks-cell-title"}
[:span {:class "tasks-title-text"} title]]
[:td {:class "tasks-cell-status"} (status-display status)]
[:td {:class "tasks-cell-priority"} (priority-display priority)]
[:td {:class "tasks-cell-actions"}
(button/button {:variant :ghost :size :sm :class "btn-icon"}
(icon/icon {:icon-name :menu :size :sm}))]]))
;; ── Tasks Table ─────────────────────────────────────────────────────
(defn tasks-table
"Render the full tasks data table."
[{:keys [tasks]}]
(let [task-list (or tasks sample-tasks)]
#?(:squint
[:div {:class "table-wrapper"}
[:table {:class "table tasks-table"}
[:thead
[:tr
[:th {:class "tasks-cell-checkbox"}
[:input {:type "checkbox" :class "form-checkbox" :aria-label "Select all tasks"}]]
[:th "Task"]
[:th "Label"]
[:th "Title"]
[:th "Status"]
[:th "Priority"]
[:th {:class "tasks-cell-actions"}]]]
(into [:tbody]
(map task-row task-list))]]
:cljs
[:div {:class ["table-wrapper"]}
[:table {:class ["table" "tasks-table"]}
[:thead
[:tr
[:th {:class ["tasks-cell-checkbox"]}
[:input {:type "checkbox" :class ["form-checkbox"] :aria-label "Select all tasks"}]]
[:th "Task"]
[:th "Label"]
[:th "Title"]
[:th "Status"]
[:th "Priority"]
[:th {:class ["tasks-cell-actions"]}]]]
(into [:tbody]
(map task-row task-list))]]
:clj
[:div {:class "table-wrapper"}
[:table {:class "table tasks-table"}
[:thead
[:tr
[:th {:class "tasks-cell-checkbox"}
[:input {:type "checkbox" :class "form-checkbox" :aria-label "Select all tasks"}]]
[:th "Task"]
[:th "Label"]
[:th "Title"]
[:th "Status"]
[:th "Priority"]
[:th {:class "tasks-cell-actions"}]]]
(into [:tbody]
(map task-row task-list))]])))
;; ── Footer ──────────────────────────────────────────────────────────
(defn tasks-footer
"Render the table footer with row count and pagination."
[{:keys [tasks page per-page]}]
(let [task-list (or tasks sample-tasks)
total (count task-list)
pp (or per-page 10)
current (or page 1)
total-pages (max 1 (int (Math/ceil (/ total pp))))]
#?(:squint
[:div {:class "tasks-footer"}
[:span {:class "text-sm text-muted"}
(str "0 of " total " row(s) selected.")]
[:div {:class "tasks-footer-right"}
[:span {:class "text-sm"}
(str "Page " current " of " total-pages)]
[:div {:class "tasks-pagination"}
(button/button {:variant "secondary" :size "sm" :class "btn-icon"
:disabled (= current 1)}
(icon/icon {:icon-name "chevron-left" :size "sm"}))
(button/button {:variant "secondary" :size "sm" :class "btn-icon"
:disabled (= current total-pages)}
(icon/icon {:icon-name "chevron-right" :size "sm"}))]]]
:cljs
[:div {:class ["tasks-footer"]}
[:span {:class ["text-sm" "text-muted"]}
(str "0 of " total " row(s) selected.")]
[:div {:class ["tasks-footer-right"]}
[:span {:class ["text-sm"]}
(str "Page " current " of " total-pages)]
[:div {:class ["tasks-pagination"]}
(button/button {:variant :secondary :size :sm :class "btn-icon"
:disabled (= current 1)}
(icon/icon {:icon-name :chevron-left :size :sm}))
(button/button {:variant :secondary :size :sm :class "btn-icon"
:disabled (= current total-pages)}
(icon/icon {:icon-name :chevron-right :size :sm}))]]]
:clj
[:div {:class "tasks-footer"}
[:span {:class "text-sm text-muted"}
(str "0 of " total " row(s) selected.")]
[:div {:class "tasks-footer-right"}
[:span {:class "text-sm"}
(str "Page " current " of " total-pages)]
[:div {:class "tasks-pagination"}
(button/button {:variant :secondary :size :sm :class "btn-icon"
:disabled (= current 1)}
(icon/icon {:icon-name :chevron-left :size :sm}))
(button/button {:variant :secondary :size :sm :class "btn-icon"
:disabled (= current total-pages)}
(icon/icon {:icon-name :chevron-right :size :sm}))]]])))
;; ── Full Page ───────────────────────────────────────────────────────
(defn tasks-page
"Render the complete tasks list recipe page.
Composes: card, badge, button, form-input, icon, table."
[{:keys [tasks] :as opts}]
(let [task-list (or tasks sample-tasks)]
#?(:squint
[:div {:class "tasks-page"}
(card/card {}
(card/card-header {}
[:h3 {:class "tasks-heading"} "Welcome back!"]
[:p {:class "text-sm text-muted"} "Here's a list of your tasks for this month."])
(card/card-body {}
(tasks-toolbar {})
(tasks-table {:tasks task-list})
(tasks-footer {:tasks task-list})))]
:cljs
[:div {:class ["tasks-page"]}
(card/card {}
(card/card-header {}
[:h3 {:class ["tasks-heading"]} "Welcome back!"]
[:p {:class ["text-sm" "text-muted"]} "Here's a list of your tasks for this month."])
(card/card-body {}
(tasks-toolbar {})
(tasks-table {:tasks task-list})
(tasks-footer {:tasks task-list})))]
:clj
[:div {:class "tasks-page"}
(card/card {}
(card/card-header {}
[:h3 {:class "tasks-heading"} "Welcome back!"]
[:p {:class "text-sm text-muted"} "Here's a list of your tasks for this month."])
(card/card-body {}
(tasks-toolbar {})
(tasks-table {:tasks task-list})
(tasks-footer {:tasks task-list})))])))

View File

@@ -1,59 +1 @@
(ns ui.badge
(:require [clojure.string :as str]
[ui.icon :as icon]))
#?(: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
:icon-name - optional leading icon (e.g. :check, :star)
:class - additional CSS classes
:attrs - additional HTML attributes"
[{:keys [variant icon-name 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]
(cond-> []
icon-name (conj (icon/icon {:icon-name icon-name :size :sm :class "badge-icon"}))
true (into 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]
(cond-> []
icon-name (conj (icon/icon {:icon-name icon-name :size :sm :class "badge-icon"}))
true (into children))))
:clj
(let [classes (cond-> (badge-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:span base-attrs]
(cond-> []
icon-name (conj (icon/icon {:icon-name icon-name :size :sm :class "badge-icon"}))
true (into children))))))
Resolved: merged both sides by keeping the incoming `:size` parameter **and** HEAD's `:icon-name` parameter in the docstring and destructuring binding. The function body already referenced both, so no changes needed there.

View File

@@ -1,44 +1,4 @@
.badge {
display: inline-flex;
align-items: center;
gap: var(--size-1);
padding: var(--size-1) var(--size-3);
font-size: var(--font-sm);
font-weight: 600;
line-height: var(--size-5);
border-radius: 9999px;
background: var(--accent);
color: var(--fg-on-accent);
}
The conflict was between HEAD (adding `.badge-icon`) and the incoming commit (adding `.badge-sm`). Since both additions are independent features, I preserved both classes in the resolved file:
.badge-icon {
width: 0.875em;
height: 0.875em;
flex-shrink: 0;
}
.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));
}
- **`.badge-icon`** from HEAD, an icon sizing utility
- **`.badge-sm`** from the incoming commit, a small badge size variant

116
src/ui/recipe-tasks.css Normal file
View File

@@ -0,0 +1,116 @@
/* ── Tasks Recipe ─────────────────────────────────────────────────── */
/* Styles for the Tasks List recipe (shadcn-inspired). */
.tasks-page {
max-width: 64rem;
margin: 0 auto;
}
.tasks-heading {
margin: 0;
font-size: var(--font-lg);
font-weight: 700;
letter-spacing: -0.025em;
}
/* ── Toolbar ─────────────────────────────────────────────────────── */
.tasks-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--size-3);
margin-bottom: var(--size-4);
}
.tasks-toolbar-left {
display: flex;
align-items: center;
gap: var(--size-2);
flex: 1;
}
.tasks-toolbar-right {
display: flex;
align-items: center;
gap: var(--size-2);
flex-shrink: 0;
}
.tasks-search {
max-width: 16rem;
}
/* ── Table overrides ─────────────────────────────────────────────── */
.tasks-table {
font-size: var(--font-sm);
}
.tasks-cell-checkbox {
width: var(--size-10);
padding-left: var(--size-3);
padding-right: 0;
}
.tasks-cell-id {
width: 6rem;
white-space: nowrap;
font-size: var(--font-xs);
}
.tasks-cell-label {
width: 8rem;
white-space: nowrap;
}
.tasks-cell-title {
white-space: nowrap;
}
.tasks-cell-status {
width: 8rem;
}
.tasks-cell-priority {
width: 6rem;
}
.tasks-cell-actions {
width: var(--size-10);
text-align: right;
padding-right: var(--size-3);
}
/* ── Status & Priority display ───────────────────────────────────── */
.tasks-status,
.tasks-priority {
display: inline-flex;
align-items: center;
gap: var(--size-2);
font-size: var(--font-sm);
color: var(--fg-1);
white-space: nowrap;
}
/* ── Footer ──────────────────────────────────────────────────────── */
.tasks-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--size-4);
}
.tasks-footer-right {
display: flex;
align-items: center;
gap: var(--size-4);
}
.tasks-pagination {
display: flex;
align-items: center;
gap: var(--size-1);
}