feat(ssr): restore forward/back step slide in the wizard engine

The engine migration replaced the old mm/* modal-stack wizards (which slid
forward/back between steps) with wizard2, but never carried the slide over — step
transitions went flat. Restore the original mechanism in the shared engine so all
wizards (new-invoice, vendor, client, pay, transaction-rule) get it:

- wizard2/step-slide-classes: the group-[.forward]/transition:htmx-* and
  group-[.backward]/* slide variants, applied to the swapped <form>.
- wizard2/transitioner: the #transitioner wrapper whose @htmx:after-request hook
  reads the x-transition-type response header and toggles group/transition +
  forward|backward on itself. All 5 configs' :open-response now use it.
- wizard2/handle-step-submit sets x-transition-type (forward on advance, backward
  on Back, none on a same-step validation re-render) + HX-reswap "outerHTML
  swap:0.16s" so the slide-out plays before the swap. Direction computed from
  step order (transition-type).
- Removed the interim per-card fade-in in favor of this.
- Rebuilt output.css so the 16 fwd + 16 back slide variants are compiled.

REPL-verified: open-wizard emits the transitioner, the form carries the slide
classes, and submit responses carry the transition headers. Live verification
needs a server refresh (the dev server froze its route table at startup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 21:17:51 -07:00
parent 6c791efb06
commit b44213bffd
8 changed files with 267 additions and 37 deletions

View File

@@ -33,11 +33,15 @@
(:require
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.wizard-state :as ws]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.utils :refer [html-response]]))
(defn- step-by-key [config k]
(first (filter #(= (:key %) k) (:steps config))))
(defn- step-index [config k]
(.indexOf (mapv :key (:steps config)) k))
(defn- prev-step
"The step key before `k` in the linear step order (or `k` itself if first)."
[config k]
@@ -45,13 +49,68 @@
i (.indexOf keys k)]
(if (pos? i) (nth keys (dec i)) k)))
(defn- transition-type
"forward when advancing to a later step, backward when returning to an earlier one, nil
when staying on the same step (a validation re-render) — used to drive the slide."
[config from-key to-key]
(let [fi (step-index config from-key)
ti (step-index config to-key)]
(cond
(or (neg? fi) (neg? ti) (= fi ti)) nil
(> fi ti) "backward"
:else "forward")))
(def step-slide-classes
"Forward/back slide variants applied to the swapped wizard <form>. The #transitioner
ancestor (see `transitioner`) carries `group/transition` + `forward`|`backward`; during a
step swap the outgoing form gets `htmx-swapping` and the incoming one `htmx-added`, so
these variants animate the card sliding/fading in the matching direction."
(str "group-[.forward]/transition:htmx-swapping:opacity-0 "
"group-[.forward]/transition:htmx-swapping:-translate-x-1/4 "
"group-[.forward]/transition:htmx-swapping:scale-75 "
"group-[.forward]/transition:htmx-swapping:ease-in "
"group-[.forward]/transition:htmx-added:opacity-0 "
"group-[.forward]/transition:htmx-added:scale-75 "
"group-[.forward]/transition:htmx-added:translate-x-1/4 "
"group-[.forward]/transition:htmx-added:ease-out "
"group-[.backward]/transition:htmx-swapping:opacity-0 "
"group-[.backward]/transition:htmx-swapping:translate-x-1/4 "
"group-[.backward]/transition:htmx-swapping:scale-75 "
"group-[.backward]/transition:htmx-swapping:ease-in "
"group-[.backward]/transition:htmx-added:opacity-0 "
"group-[.backward]/transition:htmx-added:scale-75 "
"group-[.backward]/transition:htmx-added:-translate-x-1/4 "
"group-[.backward]/transition:htmx-added:ease-out "
"opacity-100 translate-x-0 scale-100 transition duration-150"))
(defn transitioner
"Wrap the opened wizard form in the #transitioner the slide animation hooks onto. After
each step swap it reads the `x-transition-type` response header and toggles
`group/transition` + `forward`|`backward` on itself, which drives `step-slide-classes` on
the swapped form. Rendered once on open; the form swaps itself inside it, so this node
(and its Alpine state) persists across steps."
[form]
[:div#transitioner.flex-1
{:x-data (hx/json {"transitionType" "none"})
:x-ref "transitioner"
"@htmx:after-request" (str "if (event.detail.xhr.getResponseHeader('x-transition-type') && "
"event.detail.xhr.getResponseHeader('x-transition-type') !== 'none') {"
" $refs.transitioner.classList.remove('forward');"
" $refs.transitioner.classList.remove('backward');"
" $refs.transitioner.classList.add('group/transition');"
" $refs.transitioner.classList.add(event.detail.xhr.getResponseHeader('x-transition-type'));"
"} else { $refs.transitioner.classList.remove('group/transition'); }")}
form])
(defn wizard-form
"Wrap a step body in the wizard <form>: the form posts to the submit route, and only the
wizard-id + current-step ride along (no accumulated data — that lives in the session).
Enter is guarded so it triggers the step's primary nav button (the one marked
`data-primary`) rather than whichever submit button the browser picks first."
`data-primary`) rather than whichever submit button the browser picks first. The form
carries `step-slide-classes` so the whole-form swap animates as a directional slide."
[config wizard-id current-step body]
[:form (merge {:id (:form-id config "wizard-form")
:class step-slide-classes
:hx-post (:submit-route config)
:hx-target "this"
:hx-swap "outerHTML"
@@ -115,6 +174,17 @@
extra)))
(assoc :session session)))
(defn- with-transition
"Attach the slide headers the #transitioner reads: `x-transition-type` (forward/backward)
plus an `HX-reswap` swap delay so the outgoing form's slide-out is visible before the
swap. nil tt -> `none` (a same-step validation re-render does not animate)."
[resp tt]
(if tt
(-> resp
(assoc-in [:headers "x-transition-type"] tt)
(assoc-in [:headers "HX-reswap"] "outerHTML swap:0.16s"))
(assoc-in resp [:headers "x-transition-type"] "none")))
(defn open-wizard
"Create a wizard instance in the session, render its first step, and return a Ring
response with the updated session threaded. `:init-fn` returns {:context ..., :init-data
@@ -158,9 +228,11 @@
(expired-response config request)
(= direction "back")
(render-response config wizard-id
(ws/set-step session wizard-id (prev-step config current-step))
request)
(let [prev (prev-step config current-step)]
(-> (render-response config wizard-id
(ws/set-step session wizard-id prev)
request)
(with-transition (transition-type config current-step prev))))
:else
(let [step (step-by-key config current-step)
@@ -171,13 +243,16 @@
posted ((:decode step) clean)
errors (when-let [v (:validate step)] (v posted request))]
(if (seq errors)
(render-response config wizard-id session request
{:step-errors errors :step-posted posted})
;; same step -> no slide (with-transition nil => "none")
(-> (render-response config wizard-id session request
{:step-errors errors :step-posted posted})
(with-transition nil))
(let [session' (ws/put-step session wizard-id current-step posted)
nxt ((:next step) posted)]
(if (= nxt :done)
(-> ((:done-fn config) (ws/get-all session' wizard-id) request)
(assoc :session (ws/forget session' wizard-id)))
(render-response config wizard-id
(ws/set-step session' wizard-id nxt)
request))))))))
(-> (render-response config wizard-id
(ws/set-step session' wizard-id nxt)
request)
(with-transition (transition-type config current-step nxt))))))))))