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.
150 lines
6.5 KiB
Markdown
150 lines
6.5 KiB
Markdown
# Whole-form HTMX swap doctrine
|
|
|
|
Every interactive control picks a swap strategy in this **priority order** (prefer the
|
|
earliest rule that works). Worked examples are the real `transaction/edit.clj` swaps.
|
|
|
|
## Rule 1 — No request when the field affects nothing else
|
|
|
|
Its value rides along in the form and is read on submit. No `hx-*` at all.
|
|
|
|
```clojure
|
|
;; transaction/edit.clj — the memo field. Editing it issues NO request; the value
|
|
;; just rides along until save. The e2e proves zero POSTs fire while typing.
|
|
(com/text-input {:value (fc/field-value)
|
|
:name (fc/field-name)
|
|
:id "edit-memo"
|
|
:placeholder "Optional note"})
|
|
```
|
|
|
|
## Rule 2 — Targeted swap of a single isolated cell when the effect is purely local
|
|
|
|
Give the cell a stable id, keep it **out of the typed input's subtree**, and post the
|
|
whole form but `hx-select` back only that cell.
|
|
|
|
```clojure
|
|
;; transaction/edit.clj — selecting an account only changes that row's valid Location
|
|
;; options, so the change swaps just this cell. Nothing else re-renders.
|
|
[:div {:id (str "account-location-" index)} ; stable, per-row id
|
|
(com/validated-field
|
|
{:x-hx-val:account-id "accountId"
|
|
:x-dispatch:changed "accountId" ; Alpine fires `changed` when account changes
|
|
:hx-trigger "changed"
|
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
|
:hx-target (str "#account-location-" index)
|
|
:hx-select (str "#account-location-" index)
|
|
:hx-swap "outerHTML"
|
|
:hx-include "closest form"} ; whole form posts; only this cell swaps back
|
|
(location-select* {...}))]
|
|
```
|
|
|
|
## Rule 3 — Whole-form swap when the change touches interdependent state
|
|
|
|
Vendor change, add/remove row, mode toggle, $/% radio. The form's hidden state rides
|
|
along, so one swap keeps everything consistent — **no out-of-band swaps**.
|
|
|
|
```clojure
|
|
;; transaction/edit.clj — vendor change rebuilds the whole manual-coding section
|
|
;; (vendor default account, terms, etc. are interdependent).
|
|
[:div {:hx-trigger "change"
|
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
|
:hx-target "#wizard-form"
|
|
:hx-select "#wizard-form"
|
|
:hx-swap "outerHTML"
|
|
:hx-sync "this:replace"
|
|
:hx-include "closest form"}
|
|
...]
|
|
```
|
|
|
|
The active tab/action round-trips through the form (it's a hidden field bound to Alpine
|
|
`activeForm`), so it **survives** the whole-form swap — that's why a whole-form swap is
|
|
safe here even though the user is "on" a tab.
|
|
|
|
## Rule 4 — OOB only for genuinely disjoint DOM regions
|
|
|
|
A global flash/toast, a nav badge, a modal at the document root. **If tempted to OOB
|
|
something inside the same feature, restructure instead**: give the dependent element a
|
|
common ancestor with the trigger and use an ordinary swap.
|
|
|
|
Worked example — running **totals live in their own sibling `<tbody>`** so an amount edit
|
|
swaps the totals without ever replacing the amount input:
|
|
|
|
```clojure
|
|
;; The totals tbody is a sibling of the input-bearing rows.
|
|
(com/data-grid
|
|
{:footer-tbody [:tbody {:id "account-totals"} ...totals rows...]}
|
|
...account rows with inputs...)
|
|
|
|
;; The amount input posts the whole form but hx-selects ONLY #account-totals.
|
|
(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" ; a SIBLING of this input's row...
|
|
:hx-select "#account-totals" ; ...so the input is never in the swapped region
|
|
:hx-swap "outerHTML"
|
|
:hx-trigger "keyup changed delay:300ms"
|
|
:hx-include "closest form"})
|
|
```
|
|
|
|
`grep -c hx-swap-oob` on a migrated modal must be `0` unless a justified disjoint-region
|
|
case is documented here and in `gotchas.md`.
|
|
|
|
---
|
|
|
|
## The focus invariant (must always hold)
|
|
|
|
> The input the user is typing in is never inside the region its own request swaps.
|
|
|
|
This is *the* reason the doctrine works. The amount field swaps a sibling tbody; the memo
|
|
field swaps nothing; the account typeahead's change swaps the whole form but the typeahead
|
|
isn't an active text caret at that moment (it's a click-to-select). The
|
|
`transaction-edit-swap.spec.ts` `sameNode` assertions exist to catch any violation.
|
|
|
|
## Alpine components must survive swaps
|
|
|
|
When a whole-form swap replaces a region containing Alpine/tippy components, they get
|
|
re-initialised from the server-provided values. Two hardening moves:
|
|
|
|
1. **Null-guard every reference** that depends on Alpine/tippy being initialised:
|
|
```clojure
|
|
"@keydown.down.prevent.stop" "tippy?.show()"
|
|
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..."
|
|
```
|
|
(`$refs.input?` / `tippy?` — the `?` matters; a swap can run a handler before re-init.)
|
|
|
|
2. **Let the server value win.** Because the section is rebuilt fresh on each swap, the
|
|
server-driven value (e.g. a vendor's default account) lands without keying tricks —
|
|
no preserved stale Alpine state to fight. The "changing the vendor a *second* time
|
|
still updates it" e2e is the regression guard for this.
|
|
|
|
If you *do* preserve a component across a morph/replace, key it by its server value so
|
|
a server-driven change forces re-init: `(assoc attrs :key (str id "--" current-value))`.
|
|
|
|
Use `hx/alpine-mount-then-appear` for rows that should mount-then-transition-in (it sets
|
|
`x-data {data-key false}`, `x-init $nextTick(() => key=true)`, `x-show key`).
|
|
|
|
---
|
|
|
|
## Selector strategy for targeted swaps (a consideration, not a mandate)
|
|
|
|
Rules 2 and 4 need a stable `hx-target`/`hx-select`. Per-element unique ids
|
|
(`#account-location-0`) work and are what transaction-edit uses today. They get noisy in
|
|
deeply repeated/nested structures. When you hit that (Phase 5 / the wizards), consider:
|
|
|
|
- **Semantic markup + data-attributes** — mark rows/cells with their identity and target
|
|
by attribute, no per-element ids:
|
|
```html
|
|
<tr data-row="account" data-index="0">
|
|
<td data-cell="location"> … </td>
|
|
</tr>
|
|
<!-- hx-target="[data-row='account'][data-index='0'] [data-cell='location']" -->
|
|
```
|
|
- **A `form-path -> selector` function**, derived the same way a cursor path is, so the
|
|
server and the markup agree on the target by construction. A render fn at form-path
|
|
`[:accounts 0 :location]` computes its own stable selector from that path.
|
|
|
|
**Decision status:** still per-element ids. The first modal to hit nested repeated swaps
|
|
(Invoice Bulk Edit, Phase 5) settles the convention and records it here + in
|
|
`component-cookbook.md` for the wizards to reuse.
|