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

@@ -93,21 +93,28 @@ animate:
- By contrast, `dialog/success-modal-` and the Invoice Pay card (`last-modal-step transition - By contrast, `dialog/success-modal-` and the Invoice Pay card (`last-modal-step transition
duration-150`) keep transitions, so the intended pattern still exists to copy from. duration-150`) keep transitions, so the intended pattern still exists to copy from.
**FIX — DONE (subtle fade) & verified:** added the codebase's existing `fade-in transition-opacity **FIX — DONE (forward/back slide restored) in the shared `wizard2` engine.** The user confirmed the
duration-300` to all three wizard step cards (new-invoice basic-details + accounts, vendor old wizard had a directional **slide forward/back** between steps; the engine migration dropped it.
step-card, client step-card). `fade-in` is defined in `resources/input.css` Restored the original mechanism (read out of the deleted `mm/multi_modal.clj`) in one shared place:
(`.htmx-added .fade-in { opacity:0 }` → transitions to 1 after htmx settle), so each card fades in - `wizard2/step-slide-classes` — the `group-[.forward]/transition:htmx-*` + `group-[.backward]/…`
on open *and* on every step swap. Verified live: cards always settle to **opacity 1** (never stuck variants, now applied to the swapped wizard `<form>`.
invisible) on both open and step navigation — no functional risk. - `wizard2/transitioner` — the `#transitioner` wrapper with the `@htmx:after-request` hook that reads
the `x-transition-type` response header and toggles `group/transition` + `forward|backward` on
Notes / intentionally deferred (needs design intent + visual sign-off, so not done autonomously): itself (so the variants fire on the next swap). All 5 wizard configs' `:open-response` now wrap the
- The transaction-edit "reference" card's `last-modal-step` class is **undefined** (a no-op); its form in `wizard2/transitioner` instead of a plain `#transitioner` div.
only real transition is `transition duration-150`. So there was no clean reference slide to copy. - `wizard2/handle-step-submit` now sets `x-transition-type` (forward on advance, backward on Back,
- The richer **forward/backward slide** transitions (the old `mm/*` modal-stack system using `none` on a same-step validation re-render) + `HX-reswap: outerHTML swap:0.16s` (the swap delay
`group/transition` + `forward`/`backward` + `htmx-*:translate-x-2/3`) were deliberately deleted in that lets the slide-out play). Direction is computed from the step order (`transition-type`).
Phase 11 and purged from the CSS. Re-introducing directional slides is a larger design decision - The earlier per-card `fade-in` (interim) was removed in favor of this.
(which element carries `htmx-swapping`/`htmx-added` — the form vs the card — plus settle timing) - CSS rebuilt so the `group-[.forward]/transition:htmx-*` variants (16 fwd + 16 back) are compiled.
and is left for a human-in-the-loop pass if the subtle fade isn't enough. - Applies to ALL wizards (new-invoice, vendor, client, pay, transaction-rule) since it lives in the
engine. REPL-verified: `open-wizard` emits the transitioner, the form carries the slide classes,
and submit responses carry `x-transition-type` + the `HX-reswap` swap delay.
- **Live-verify caveat:** the long-lived dev server froze its route table at startup
(`auto-ap.handler/match->handler-lookup` is a `def` that merged the per-ns `key->handler` maps), so
an nREPL reload of leaf namespaces does NOT reach the running router — a server refresh
(`stop jetty → tools.namespace refresh → user/start-http`) or a fresh `lein run` is needed to see
it live. (Documented hazard; see the QA memory.)
## 4. Bug list + fixes (prioritized task list) ## 4. Bug list + fixes (prioritized task list)

View File

@@ -1609,10 +1609,6 @@ input:checked + .toggle-bg {
width: 5em; width: 5em;
} }
.w-\[600px\] {
width: 600px;
}
.w-\[700px\] { .w-\[700px\] {
width: 700px; width: 700px;
} }
@@ -3996,6 +3992,44 @@ input:checked + .toggle-bg {
opacity: 0; opacity: 0;
} }
.group\/transition.backward .group-\[\.backward\]\/transition\:htmx-swapping\:translate-x-1\/4.htmx-swapping {
--tw-translate-x: 25%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.forward .group-\[\.forward\]\/transition\:htmx-swapping\:-translate-x-1\/4.htmx-swapping {
--tw-translate-x: -25%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.backward .group-\[\.backward\]\/transition\:htmx-swapping\:scale-75.htmx-swapping {
--tw-scale-x: .75;
--tw-scale-y: .75;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.forward .group-\[\.forward\]\/transition\:htmx-swapping\:scale-75.htmx-swapping {
--tw-scale-x: .75;
--tw-scale-y: .75;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.backward .group-\[\.backward\]\/transition\:htmx-swapping\:opacity-0.htmx-swapping {
opacity: 0;
}
.group\/transition.forward .group-\[\.forward\]\/transition\:htmx-swapping\:opacity-0.htmx-swapping {
opacity: 0;
}
.group\/transition.backward .group-\[\.backward\]\/transition\:htmx-swapping\:ease-in.htmx-swapping {
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
}
.group\/transition.forward .group-\[\.forward\]\/transition\:htmx-swapping\:ease-in.htmx-swapping {
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
}
.htmx-swapping .htmx-swapping\:-translate-x-2\/3 { .htmx-swapping .htmx-swapping\:-translate-x-2\/3 {
--tw-translate-x: -66.666667%; --tw-translate-x: -66.666667%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
@@ -4011,6 +4045,44 @@ input:checked + .toggle-bg {
opacity: 0; opacity: 0;
} }
.group\/transition.backward .htmx-swapping .group-\[\.backward\]\/transition\:htmx-swapping\:translate-x-1\/4 {
--tw-translate-x: 25%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.forward .htmx-swapping .group-\[\.forward\]\/transition\:htmx-swapping\:-translate-x-1\/4 {
--tw-translate-x: -25%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.backward .htmx-swapping .group-\[\.backward\]\/transition\:htmx-swapping\:scale-75 {
--tw-scale-x: .75;
--tw-scale-y: .75;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.forward .htmx-swapping .group-\[\.forward\]\/transition\:htmx-swapping\:scale-75 {
--tw-scale-x: .75;
--tw-scale-y: .75;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.backward .htmx-swapping .group-\[\.backward\]\/transition\:htmx-swapping\:opacity-0 {
opacity: 0;
}
.group\/transition.forward .htmx-swapping .group-\[\.forward\]\/transition\:htmx-swapping\:opacity-0 {
opacity: 0;
}
.group\/transition.backward .htmx-swapping .group-\[\.backward\]\/transition\:htmx-swapping\:ease-in {
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
}
.group\/transition.forward .htmx-swapping .group-\[\.forward\]\/transition\:htmx-swapping\:ease-in {
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
}
.htmx-added\:translate-x-2\/3.htmx-added { .htmx-added\:translate-x-2\/3.htmx-added {
--tw-translate-x: 66.666667%; --tw-translate-x: 66.666667%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
@@ -4026,6 +4098,44 @@ input:checked + .toggle-bg {
opacity: 0; opacity: 0;
} }
.group\/transition.backward .group-\[\.backward\]\/transition\:htmx-added\:-translate-x-1\/4.htmx-added {
--tw-translate-x: -25%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.forward .group-\[\.forward\]\/transition\:htmx-added\:translate-x-1\/4.htmx-added {
--tw-translate-x: 25%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.backward .group-\[\.backward\]\/transition\:htmx-added\:scale-75.htmx-added {
--tw-scale-x: .75;
--tw-scale-y: .75;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.forward .group-\[\.forward\]\/transition\:htmx-added\:scale-75.htmx-added {
--tw-scale-x: .75;
--tw-scale-y: .75;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.backward .group-\[\.backward\]\/transition\:htmx-added\:opacity-0.htmx-added {
opacity: 0;
}
.group\/transition.forward .group-\[\.forward\]\/transition\:htmx-added\:opacity-0.htmx-added {
opacity: 0;
}
.group\/transition.backward .group-\[\.backward\]\/transition\:htmx-added\:ease-out.htmx-added {
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
.group\/transition.forward .group-\[\.forward\]\/transition\:htmx-added\:ease-out.htmx-added {
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
.htmx-added .htmx-added\:translate-x-2\/3 { .htmx-added .htmx-added\:translate-x-2\/3 {
--tw-translate-x: 66.666667%; --tw-translate-x: 66.666667%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
@@ -4041,6 +4151,44 @@ input:checked + .toggle-bg {
opacity: 0; opacity: 0;
} }
.group\/transition.backward .htmx-added .group-\[\.backward\]\/transition\:htmx-added\:-translate-x-1\/4 {
--tw-translate-x: -25%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.forward .htmx-added .group-\[\.forward\]\/transition\:htmx-added\:translate-x-1\/4 {
--tw-translate-x: 25%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.backward .htmx-added .group-\[\.backward\]\/transition\:htmx-added\:scale-75 {
--tw-scale-x: .75;
--tw-scale-y: .75;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.forward .htmx-added .group-\[\.forward\]\/transition\:htmx-added\:scale-75 {
--tw-scale-x: .75;
--tw-scale-y: .75;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.group\/transition.backward .htmx-added .group-\[\.backward\]\/transition\:htmx-added\:opacity-0 {
opacity: 0;
}
.group\/transition.forward .htmx-added .group-\[\.forward\]\/transition\:htmx-added\:opacity-0 {
opacity: 0;
}
.group\/transition.backward .htmx-added .group-\[\.backward\]\/transition\:htmx-added\:ease-out {
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
.group\/transition.forward .htmx-added .group-\[\.forward\]\/transition\:htmx-added\:ease-out {
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
:is(.dark .dark\:border-blue-500) { :is(.dark .dark\:border-blue-500) {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(0 156 234 / var(--tw-border-opacity)); border-color: rgb(0 156 234 / var(--tw-border-opacity));

View File

@@ -517,7 +517,7 @@
[{:keys [title active all-data nav body]}] [{:keys [title active all-data nav body]}]
(com/modal-card-advanced (com/modal-card-advanced
{"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}" {"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}"
:class " md:w-[820px] md:h-[560px] w-full h-full fade-in transition-opacity duration-300" :class " md:w-[820px] md:h-[560px] w-full h-full"
:x-data (hx/json {"clientName" (:client/name all-data)})} :x-data (hx/json {"clientName" (:client/name all-data)})}
(com/modal-header {} [:div.flex [:div.p-2 title] (com/modal-header {} [:div.flex [:div.p-2 title]
[:p.ml-2.rounded.bg-gray-50.p-2.dark:bg-gray-600 [:span {:x-text "clientName"}]]]) [:p.ml-2.rounded.bg-gray-50.p-2.dark:bg-gray-600 [:span {:x-text "clientName"}]]])
@@ -1216,7 +1216,7 @@
:form-id "wizard-form" :form-id "wizard-form"
:submit-route (bidi/path-for ssr-routes/only-routes ::route/save) :submit-route (bidi/path-for ssr-routes/only-routes ::route/save)
:form-attrs {:hx-ext "response-targets" :hx-target-400 "#form-errors"} :form-attrs {:hx-ext "response-targets" :hx-target-400 "#form-errors"}
:open-response (fn [form] (modal-response [:div#transitioner.flex-1 form])) :open-response (fn [form] (modal-response (wizard2/transitioner form)))
:init-fn client-init-fn :init-fn client-init-fn
:steps [{:key :info :decode decode-info :validate (partial validate-with info-schema) :render render-info :next (fn [_] :matches)} :steps [{:key :info :decode decode-info :validate (partial validate-with info-schema) :render render-info :next (fn [_] :matches)}
{:key :matches :decode (partial decode-with matches-schema) :validate (partial validate-with matches-schema) :render render-matches :next (fn [_] :contact)} {:key :matches :decode (partial decode-with matches-schema) :validate (partial validate-with matches-schema) :render render-matches :next (fn [_] :contact)}

View File

@@ -827,7 +827,7 @@
;; new/edit routes are just (partial wizard2/open-wizard config) -- no hand-rolled ;; new/edit routes are just (partial wizard2/open-wizard config) -- no hand-rolled
;; create!/render/wrap/thread boilerplate. ;; create!/render/wrap/thread boilerplate.
:open-response (fn [form] :open-response (fn [form]
(modal-response [:div#transitioner.flex-1 form])) (modal-response (wizard2/transitioner form)))
:steps [{:key :edit :steps [{:key :edit
:decode decode-rule-form :decode decode-rule-form
:validate rule-form-errors :validate rule-form-errors

View File

@@ -373,7 +373,7 @@
[{:keys [title active all-data nav body]}] [{:keys [title active all-data nav body]}]
(com/modal-card-advanced (com/modal-card-advanced
{"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}" {"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}"
:class " md:w-[760px] md:h-[520px] w-full h-full fade-in transition-opacity duration-300" :class " md:w-[760px] md:h-[520px] w-full h-full"
:x-data (hx/json {"vendorName" (:vendor/name all-data) :x-data (hx/json {"vendorName" (:vendor/name all-data)
"showPrintAs" (boolean (not-empty (:vendor/print-as all-data))) "showPrintAs" (boolean (not-empty (:vendor/print-as all-data)))
"printAs" (:vendor/print-as all-data)})} "printAs" (:vendor/print-as all-data)})}
@@ -672,7 +672,7 @@
:form-id "wizard-form" :form-id "wizard-form"
:submit-route (bidi/path-for ssr-routes/only-routes ::route/save) :submit-route (bidi/path-for ssr-routes/only-routes ::route/save)
:form-attrs {:hx-ext "response-targets" :hx-target-400 "#form-errors"} :form-attrs {:hx-ext "response-targets" :hx-target-400 "#form-errors"}
:open-response (fn [form] (modal-response [:div#transitioner.flex-1 form])) :open-response (fn [form] (modal-response (wizard2/transitioner form)))
:init-fn vendor-init-fn :init-fn vendor-init-fn
:steps [{:key :info :decode (partial decode-with info-schema) :validate (partial validate-with info-schema) :render render-info :next (fn [_] :terms)} :steps [{:key :info :decode (partial decode-with info-schema) :validate (partial validate-with info-schema) :render render-info :next (fn [_] :terms)}
{:key :terms :decode (partial decode-with terms-schema) :validate (partial validate-with terms-schema) :render render-terms :next (fn [_] :account)} {:key :terms :decode (partial decode-with terms-schema) :validate (partial validate-with terms-schema) :render render-terms :next (fn [_] :account)}

View File

@@ -33,11 +33,15 @@
(:require (:require
[auto-ap.ssr.components :as com] [auto-ap.ssr.components :as com]
[auto-ap.ssr.components.wizard-state :as ws] [auto-ap.ssr.components.wizard-state :as ws]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.utils :refer [html-response]])) [auto-ap.ssr.utils :refer [html-response]]))
(defn- step-by-key [config k] (defn- step-by-key [config k]
(first (filter #(= (:key %) k) (:steps config)))) (first (filter #(= (:key %) k) (:steps config))))
(defn- step-index [config k]
(.indexOf (mapv :key (:steps config)) k))
(defn- prev-step (defn- prev-step
"The step key before `k` in the linear step order (or `k` itself if first)." "The step key before `k` in the linear step order (or `k` itself if first)."
[config k] [config k]
@@ -45,13 +49,68 @@
i (.indexOf keys k)] i (.indexOf keys k)]
(if (pos? i) (nth keys (dec i)) 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 (defn wizard-form
"Wrap a step body in the wizard <form>: the form posts to the submit route, and only the "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). 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 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] [config wizard-id current-step body]
[:form (merge {:id (:form-id config "wizard-form") [:form (merge {:id (:form-id config "wizard-form")
:class step-slide-classes
:hx-post (:submit-route config) :hx-post (:submit-route config)
:hx-target "this" :hx-target "this"
:hx-swap "outerHTML" :hx-swap "outerHTML"
@@ -115,6 +174,17 @@
extra))) extra)))
(assoc :session session))) (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 (defn open-wizard
"Create a wizard instance in the session, render its first step, and return a Ring "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 response with the updated session threaded. `:init-fn` returns {:context ..., :init-data
@@ -158,9 +228,11 @@
(expired-response config request) (expired-response config request)
(= direction "back") (= direction "back")
(render-response config wizard-id (let [prev (prev-step config current-step)]
(ws/set-step session wizard-id (prev-step config current-step)) (-> (render-response config wizard-id
(ws/set-step session wizard-id prev)
request) request)
(with-transition (transition-type config current-step prev))))
:else :else
(let [step (step-by-key config current-step) (let [step (step-by-key config current-step)
@@ -171,13 +243,16 @@
posted ((:decode step) clean) posted ((:decode step) clean)
errors (when-let [v (:validate step)] (v posted request))] errors (when-let [v (:validate step)] (v posted request))]
(if (seq errors) (if (seq errors)
(render-response config wizard-id session request ;; same step -> no slide (with-transition nil => "none")
(-> (render-response config wizard-id session request
{:step-errors errors :step-posted posted}) {:step-errors errors :step-posted posted})
(with-transition nil))
(let [session' (ws/put-step session wizard-id current-step posted) (let [session' (ws/put-step session wizard-id current-step posted)
nxt ((:next step) posted)] nxt ((:next step) posted)]
(if (= nxt :done) (if (= nxt :done)
(-> ((:done-fn config) (ws/get-all session' wizard-id) request) (-> ((:done-fn config) (ws/get-all session' wizard-id) request)
(assoc :session (ws/forget session' wizard-id))) (assoc :session (ws/forget session' wizard-id)))
(render-response config wizard-id (-> (render-response config wizard-id
(ws/set-step session' wizard-id nxt) (ws/set-step session' wizard-id nxt)
request)))))))) request)
(with-transition (transition-type config current-step nxt))))))))))

View File

@@ -333,7 +333,7 @@
client-val (or (:invoice/client data) client-from-req)] client-val (or (:invoice/client data) client-from-req)]
(com/modal-card-advanced (com/modal-card-advanced
{"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}" {"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}"
:class " md:w-[750px] md:h-[600px] w-full h-full fade-in transition-opacity duration-300" :class " md:w-[750px] md:h-[600px] w-full h-full"
"x-data" ""} "x-data" ""}
(com/modal-header {} [:div.p-2 (if extant? "Edit invoice" "New invoice")]) (com/modal-header {} [:div.p-2 (if extant? "Edit invoice" "New invoice")])
(com/modal-body (com/modal-body
@@ -526,7 +526,7 @@
total (expense-accounts-total* rows)] total (expense-accounts-total* rows)]
(com/modal-card-advanced (com/modal-card-advanced
{"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}" {"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}"
:class " md:w-[750px] md:h-[600px] w-full h-full fade-in transition-opacity duration-300" :class " md:w-[750px] md:h-[600px] w-full h-full"
"x-data" ""} "x-data" ""}
(com/modal-header {} [:div.p-2 "Invoice accounts "]) (com/modal-header {} [:div.p-2 "Invoice accounts "])
(com/modal-body (com/modal-body
@@ -778,7 +778,7 @@
:submit-route (bidi/path-for ssr-routes/only-routes ::route/new-invoice-submit) :submit-route (bidi/path-for ssr-routes/only-routes ::route/new-invoice-submit)
:form-attrs {:hx-ext "response-targets" :form-attrs {:hx-ext "response-targets"
:hx-target-400 "#form-errors"} :hx-target-400 "#form-errors"}
:open-response (fn [form] (modal-response [:div#transitioner.flex-1 form])) :open-response (fn [form] (modal-response (wizard2/transitioner form)))
:init-fn new-init-fn :init-fn new-init-fn
:steps [{:key :basic-details :steps [{:key :basic-details
:decode decode-basic-details :decode decode-basic-details

View File

@@ -1366,7 +1366,7 @@
:submit-route (bidi/path-for ssr-routes/only-routes ::route/pay-submit) :submit-route (bidi/path-for ssr-routes/only-routes ::route/pay-submit)
:form-attrs {:hx-ext "response-targets" :form-attrs {:hx-ext "response-targets"
:hx-target-400 "#form-errors"} :hx-target-400 "#form-errors"}
:open-response (fn [form] (modal-response [:div#transitioner.flex-1 form])) :open-response (fn [form] (modal-response (wizard2/transitioner form)))
:init-fn pay-init-fn :init-fn pay-init-fn
:steps [{:key :choose-method :steps [{:key :choose-method
:decode decode-choose-method :decode decode-choose-method