First interactive Transaction Edit component rendered from a Selmer template instead of
Hiccup/com/select, proving the render-file path + the Hiccup<->Selmer interop bridge on
real, e2e-covered markup.
- resources/templates/components/location-select.html: plain-HTML <select> with a
{% for %} over option maps + {% if opt.selected %}.
- location-select*: build options/selected/classes in Clojure (reusing
inputs/default-input-classes so styling can't drift), render via
sel/render->hiccup, and embed the fragment back into the still-Hiccup account row.
- skill: finalize selmer-conventions.md from this validated example (was a stub); add the
cookbook entry; scorecard marks the first Selmer component.
Verified on a fresh server: full suite 38 pass / 1 unrelated fail, swap 6/6,
transaction-edit 8/8 -- the Shared Location test selects through the Selmer <select>,
saves, and spreads Shared -> DT. Verified by string-match + e2e (not byte-parity:
hh/add-class is set-based so class order differs, CSS is order-independent).
Scope note: the modal's remaining attribute-heavy components delegate to the shared
com/typeahead / com/select / com/button-group-button; converting those is the
cross-cutting Phase 11 Selmer sweep, not a single-modal change (Open decision 2).
4.7 KiB
Selmer template conventions
Validated in the Transaction Edit migration:
location-select*now renders fromresources/templates/components/location-select.htmlvia the interop bridge, embedded back into the Hiccup account row. Verified: swap spec 6/6, transaction-edit 8/8 (the Shared Location test selects through the Selmer<select>, saves, and spreads to DT).
Why Selmer for interactive components
In Hiccup the same Alpine/HTMX attribute is sometimes a keyword and sometimes a string in
the same file — there's no rule a reader (or an LLM) can rely on. The real
com/typeahead- mixes them in one map:
:x-modelable "value.value" ; keyword key
"x-ref" "hidden" ; string key
"@keydown.down.prevent.stop" "tippy?.show();" ; handlers MUST be strings
:x-init "..." ; structural attrs are keywords
In a Selmer template the same markup is unambiguous plain HTML:
{# templates/components/typeahead.html #}
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
<a class="{{ classes }}" x-ref="input" tabindex="0"
@keydown.down.prevent.stop="tippy?.show()"
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
<span x-text="value.label"></span>
</a>
</div>
Note the tippy?. null-guard carried over from the swap doctrine — Selmer doesn't change
the Alpine-survives-swap requirement.
The render helper + interop bridge (auto-ap.ssr.selmer)
(sel/render template ctx) ; selmer/render-file -> HTML string (classpath-relative path)
(sel/render-str template ctx) ; render from a string (tests/REPL)
(sel/hiccup->html h) ; Hiccup -> string, for {{ frag|safe }} inside a template
(sel/raw html-string) ; wrap a rendered string so hiccup2 emits it verbatim
(sel/render->hiccup template ctx); render + raw, ready to drop into a Hiccup tree
The bridge works both ways (proven in selmer_test): a Hiccup component renders inside
a Selmer template (hiccup->html + |safe), and a Selmer fragment renders inside a Hiccup
tree (render->hiccup, which raw-wraps so hiccup2 doesn't double-escape).
The worked example — location-select*
Template (resources/templates/components/location-select.html): plain HTML, an
{% for %} over option maps, {% if opt.selected %}.
;; Clojure side: build the data, compute classes (reuse inputs/default-input-classes so
;; styling can't drift), render, and return a Hiccup-embeddable fragment.
(defn location-select* [{:keys [name client-locations value ...]}]
(let [options (cond ...) ; [[value label] ...]
selected (or value (ffirst options))
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
(sel/render->hiccup "templates/components/location-select.html"
{:name name :classes classes
:options (for [[v l] options] {:value v :label l :selected (= v selected)})})))
Lessons:
- Pass computed values in, don't hard-code. Reuse the Clojure source of truth
(
inputs/default-input-classes) as a context value rather than copying class strings into the template — otherwise styling drifts from the shared components. - Verify by string-match + e2e, not byte-parity.
hh/add-classis set-based, so class order differs from the oldcom/selectoutput; CSS is order-independent and the e2e proves behavior. (testing-conventions: don't assert on exact markup.) - Embed via the bridge.
render->hiccuplets the Selmer fragment sit inside the still-Hiccup row (com/validated-fieldwraps it) — strangler, one component at a time.
Composition
Selmer composes via {% include %} and {% block %}. Prefer small per-component templates
referenced by classpath path. Keep |safe to values the server fully controls (rendered
Hiccup, JSON for x-data), never raw user input.
Scope (Open decision 2)
Hybrid, and the boundary is real: the modal's attribute-heavy components delegate to the
shared com/typeahead / com/select / com/button-group-button. Converting those is a
cross-cutting change (every modal uses them), so it belongs to the Phase 11 Selmer sweep,
not a single modal. location-select* is the first, self-contained proof; the shared
components follow when the sweep promotes them to Selmer partials.
Attribute-consistency scorecard (heuristic 8)
grep -cE '"x-[a-z]|"hx-[a-z]|"@' <migrated-template> # → 0 mixed encodings in Selmer
A migrated Selmer template has no mixed :x-/"x-" encodings because everything is plain
HTML. (The Hiccup "@click"/":class" offenders that remain in edit.clj live in the
shared-component call sites — they clear when those components move to Selmer.)