Capture the proven whole-form hx-select swap method as a reusable skill so every later modal migration is cheaper and consistent. No app code changes. - SKILL.md: the per-migration playbook (classify → baseline → characterize → consolidate render fns → templatize → wire HTMX → collapse routes → verify → commit → feed skill) + Growth contract + non-negotiables. - reference/swap-doctrine.md: the four swap rules, focus invariant, Alpine-survives- swap hardening, target-selector strategy — worked from the real edit.clj swaps (memo no-request, account→location targeted cell, amount→totals sibling-tbody, vendor/mode/row whole-form). 0 OOB. - reference/render-functions.md: explicit-data or top-rooted cursor; the MapCursor fake + transaction-account-row-no-cursor* twin as the smell to remove. - reference/form-vs-wizard.md: classification + the data-driven session-backed (formtools SessionStorage) engine that replaces the snapshot round-trip + protocol. - reference/selmer-conventions.md: STUB, validated in Phase 2. - component-cookbook.md / gotchas.md / test-recipes.md / scorecard.md: seeded from what transaction-edit proves (7 cookbook entries, caret-survival + typeahead test recipes, scorecard baseline LOC 1608 / ~12 routes / 1 no-cursor twin / 2 faked roots / 0 OOB). Scorecard (Transaction Edit baseline, before Phase 2): LOC 1608, routes ~12, no-cursor twins 1, faked-cursor roots 2, snapshot merges ~75, OOB 0, mixed hx- 8.
104 lines
4.6 KiB
Markdown
104 lines
4.6 KiB
Markdown
# Component cookbook
|
|
|
|
GROWS every migration. Each entry: what it is, the swap rule it uses, and the canonical
|
|
snippet. Reuse these before writing anything new; the success signal is *more reuse each
|
|
migration*.
|
|
|
|
Seeded from `transaction/edit.clj` (Hiccup form — Selmer versions land in Phase 2).
|
|
|
|
---
|
|
|
|
## typeahead (account / vendor) — Alpine + tippy, survives swaps
|
|
|
|
Used for account and vendor selection. Click-to-select (not a live text caret), so a
|
|
whole-form swap on change is safe. Null-guard `tippy?`/`$refs.input?`.
|
|
|
|
```clojure
|
|
(defn account-typeahead* [{:keys [name value client-id x-model]}]
|
|
[:div.flex.flex-col
|
|
(com/typeahead {:name name
|
|
:placeholder "Search..."
|
|
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
|
|
(cond-> {:purpose "transaction"} client-id (assoc :client-id client-id)))
|
|
:id name
|
|
:x-model x-model ; binds selected value into the row's Alpine scope
|
|
:value value
|
|
:content-fn (fn [v] (:account/name (d-accounts/clientize ... v client-id)))})])
|
|
```
|
|
Reuse note: `:x-model` lets the *parent* row read the selected id (e.g. `accountId`) to
|
|
gate a targeted location swap. See account-row.
|
|
|
|
## account-row — cursor render fn + per-row targeted location swap + whole-form remove
|
|
|
|
The canonical "row in a repeated grid" pattern. One render fn, top-rooted cursor.
|
|
- account typeahead binds `accountId` into row Alpine scope;
|
|
- **location cell** swaps *only itself* (`#account-location-<index>`) on `changed`
|
|
(swap-doctrine Rule 2);
|
|
- **amount cell** swaps *only* `#account-totals` (Rule 4, sibling tbody);
|
|
- **remove** swaps the whole form (Rule 3).
|
|
|
|
```clojure
|
|
(defn transaction-account-row* [{:keys [value client-id amount-mode index]}]
|
|
(com/data-grid-row
|
|
(-> {:class "account-row" :id (str "account-row-" index)
|
|
:x-data (hx/json {:show ... :accountId (fc/field-value (:transaction-account/account value))})
|
|
:data-key "show" :x-ref "p"}
|
|
hx/alpine-mount-then-appear)
|
|
(fc/with-field :db/id (com/hidden {:name (fc/field-name) :value (fc/field-value)}))
|
|
(fc/with-field :transaction-account/account
|
|
(com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors)}
|
|
(account-typeahead* {:value (fc/field-value) :client-id client-id
|
|
:name (fc/field-name) :x-model "accountId"}))))
|
|
(fc/with-field :transaction-account/location
|
|
(com/data-grid-cell {:id (str "account-location-" index)} ...Rule 2 targeted swap...))
|
|
(fc/with-field :transaction-account/amount
|
|
(com/data-grid-cell {} ...Rule 4 totals swap...))
|
|
(com/data-grid-cell {:class "align-top"} ...Rule 3 whole-form remove...)))
|
|
```
|
|
TODO Phase 2: drop the `transaction-account-row-no-cursor*` twin; this is the only kept form.
|
|
|
|
## totals in a sibling `<tbody>` — Rule 4 instead of OOB
|
|
|
|
Running totals live in their own `<tbody id="account-totals">`, a sibling of the
|
|
input-bearing rows, so an amount edit refreshes them with a plain targeted swap and never
|
|
replaces the amount input (caret survives).
|
|
|
|
```clojure
|
|
(com/data-grid
|
|
{:footer-tbody
|
|
[:tbody {:id "account-totals"}
|
|
(com/data-grid-row {:class "account-total-row"} ... (account-total* request) ...)
|
|
(com/data-grid-row {:class "account-balance-row"} ... (account-balance* request) ...)]}
|
|
...input rows...)
|
|
```
|
|
|
|
## money-input / text-input amount field — Rule 4 targeted totals swap
|
|
|
|
```clojure
|
|
(com/money-input
|
|
{:name (fc/field-name) :id (str "account-amount-" index) :class "w-16 account-amount-field"
|
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
|
:hx-target "#account-totals" :hx-select "#account-totals" :hx-swap "outerHTML"
|
|
:hx-trigger "keyup changed delay:300ms" :hx-include "closest form"})
|
|
```
|
|
`%` mode swaps to `com/text-input {:type "number" :step "0.01"}` with the same swap attrs.
|
|
|
|
## memo field — Rule 1, no request
|
|
|
|
```clojure
|
|
(com/text-input {:value (fc/field-value) :name (fc/field-name) :id "edit-memo"
|
|
:placeholder "Optional note"}) ; no hx-* — rides along to save
|
|
```
|
|
|
|
## mode toggle ($/% radio, simple/advanced link) — Rule 3, whole-form swap
|
|
|
|
```clojure
|
|
(com/radio-card {:options [{:value "$" :content "$"} {:value "%" :content "%"}]
|
|
:value amount-mode :name "step-params[amount-mode]"
|
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
|
|
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"
|
|
:hx-include "closest form"})
|
|
```
|
|
TODO Phase 2: the simple/advanced toggle becomes a `?mode=` re-render (plain form), not a
|
|
dedicated route.
|