From c1f30466e74acb255ed95defae9688d039a898b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 22:55:32 +0200 Subject: [PATCH] feat(medications): replace the wizard progress bar with a jumpable dot stepper --- .../wizard/MedicationWizardDialog.tsx | 125 ++++++++++-- .../medications/wizard/WizardStepper.tsx | 192 ++++++++++++++++++ 2 files changed, 297 insertions(+), 20 deletions(-) create mode 100644 src/components/medications/wizard/WizardStepper.tsx diff --git a/src/components/medications/wizard/MedicationWizardDialog.tsx b/src/components/medications/wizard/MedicationWizardDialog.tsx index 0d0f7625b..236d18b28 100644 --- a/src/components/medications/wizard/MedicationWizardDialog.tsx +++ b/src/components/medications/wizard/MedicationWizardDialog.tsx @@ -40,6 +40,7 @@ import { toast } from "sonner"; import { Bell, CalendarRange, + ChevronRight, Clock, FlaskConical, Loader2, @@ -50,7 +51,6 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Progress } from "@/components/ui/progress"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; @@ -62,6 +62,7 @@ import { buildCreateBody, commitActiveDraft, emptyWizardPayload, + firstInvalidIndex, hydrateWizardPayload, landingStepForEdit, type MedicationPayload, @@ -70,6 +71,7 @@ import { setActiveSchedule, type WizardPayload, } from "./wizard-payload"; +import { WizardStepper } from "./WizardStepper"; import { Step1Name } from "./steps/Step1Name"; import { Step2Class } from "./steps/Step2Class"; import { Step3Dose } from "./steps/Step3Dose"; @@ -176,7 +178,26 @@ function WizardDialogShell({ const displayStep = fallbackIndex + 1; const isLastStep = displayStep === totalSteps; const isFirstStep = displayStep === 1; - const progress = (displayStep / totalSteps) * 100; + + // v1.8.6 W4b — forward-reachability ceiling for the dot stepper. The + // dot at path-index `j` is reachable iff every gate from the active + // slot up to (but not including) `j` passes. `firstInvalidIndex` + // returns the first failing slot from `fallbackIndex`; every slot up + // to and including that index is reachable (the user can always step + // onto the gate that's currently blocking them). Slots already at or + // behind the active slot are always reachable. On edit the hydrated + // payload validates every gate, so the ceiling is the whole path. + const reachableUntil = useMemo( + () => + Math.max(fallbackIndex, firstInvalidIndex(payload, stepList, fallbackIndex)), + [payload, stepList, fallbackIndex], + ); + // Jump-to-last is enabled only when the WHOLE path validates — i.e. + // every step from the first slot onward passes its gate, so a single + // tap can land the user on the review step without stranding them + // behind an unmet requirement. + const lastReachable = + firstInvalidIndex(payload, stepList, 0) >= totalSteps; const canContinue = useMemo( () => validateStep(payload, step), @@ -212,6 +233,31 @@ function WizardDialogShell({ } }, [payload.mode, payload.cadence.kind, step]); + // v1.8.6 W4b — jump-to-arbitrary-step from the dot stepper. Backward + // jumps are always allowed; a forward jump is gated on EVERY + // intervening path gate validating (a multi-step lookahead over the + // single-step `validateStep`). A jump that lands on the final review + // step replicates the `goNext` Step-8 commit so the schedule-list view + // renders the just-edited cadence + times. On edit the payload is + // pre-hydrated so every gate passes → all dots reachable and + // jump-to-last is one tap. + const goToStep = useCallback( + (target: number) => { + const list = progressIndices(payload.mode, payload.cadence.kind); + const j = list.indexOf(target); + // `fallbackIndex` mirrors the active dot the stepper renders, so + // the jump gate and the visual reachability never drift. + const from = fallbackIndex; + if (j < 0 || j === from) return; + if (j > from && firstInvalidIndex(payload, list, from) < j) return; + if (target === 8) { + setPayload((prev) => commitActiveDraft(prev)); + } + setStep(target as StepNumber); + }, + [payload, fallbackIndex], + ); + // Focus the first interactive control on each step change. The // initial render skips so opening the dialog does not steal focus // from the trigger button — UNLESS the dialog opened on a non-Step-1 @@ -324,6 +370,26 @@ function WizardDialogShell({ total: totalSteps, }); + // v1.8.6 W4b — short per-slot labels for the dot stepper. Keyed by + // raw step number so the path-aware `stepList` maps straight through. + const stepperLabels = useMemo(() => { + const map: Record = {}; + for (const n of stepList) { + map[n] = t(`medications.wizard.steps.step${n}.short`); + } + return map; + }, [stepList, t]); + + // "What's next" hint under the step title. On the final slot the + // hint flips to the save target; otherwise it names the next slot's + // short label. + const nextSlot = stepList[fallbackIndex + 1]; + const nextHint = isLastStep + ? t("medications.wizard.nav.reviewHint") + : t("medications.wizard.nav.nextHint", { + step: t(`medications.wizard.steps.step${nextSlot}.short`), + }); + const primaryCtaLabel = isLastStep ? mode === "edit" ? t("medications.wizard.nav.saveEdit") @@ -396,24 +462,30 @@ function WizardDialogShell({ aria-busy={submitting || undefined} className="flex flex-col" > - {/* v1.5.5 D-3 §8 — progress strip + body share the same - outer padding so they read as one column. The right - padding leaves room for the shell's 36 px close-X - (`pr-12` mobile / `pr-14` desktop). */} + {/* v1.8.6 W4b — dot stepper replaces the v1.5.5 continuous + `` strip + "Step X of Y" caption. The dot row + + body share the same outer padding so they read as one + column; the right padding leaves room for the shell's + 36 px close-X (`pr-12` mobile / `pr-14` desktop). The NL + "Describe" affordance is re-homed beside the stepper on + the Step-1 create path. */}
- `. The - // primitive ships `transition-all`; the strip-bar morph - // proposed in D-2 was stripped in D-3 #4. No clip-path, - // no keyframes; reduced-motion picked up via the shared - // `motion-reduce` invariant. - className="h-1.5 transition-all motion-reduce:transition-none" - aria-label={stepOf} + goToStep(stepList[0])} + onLast={() => goToStep(stepList[totalSteps - 1])} + firstEnabled={!isFirstStep} + lastEnabled={lastReachable && !isLastStep} + firstLabel={t("medications.wizard.nav.jumpFirst")} + lastLabel={t("medications.wizard.nav.jumpLast")} + srLabel={stepOf} /> -
-

{stepOf}

- {step === 1 && mode === "create" && ( + {step === 1 && mode === "create" && ( +
- )} -
+
+ )}
{/* Step body — v1.5.5 D-3 §8 spacing buckets: @@ -480,6 +552,19 @@ function WizardDialogShell({

{stepSubline}

+ {/* v1.8.6 W4b — muted "what's next" hint so the user + knows what the Next tap leads to (or, on the final + slot, that Next saves). */} +

+

diff --git a/src/components/medications/wizard/WizardStepper.tsx b/src/components/medications/wizard/WizardStepper.tsx new file mode 100644 index 000000000..7912202b9 --- /dev/null +++ b/src/components/medications/wizard/WizardStepper.tsx @@ -0,0 +1,192 @@ +"use client"; + +/** + * v1.8.6 W4b — dot/bullet stepper for the medication wizard. + * + * Replaces the continuous `` bar + "Step X of Y" caption with + * one dot per path slot (so the one-shot path shows 5 dots, the + * recurring path 8 — the dialog feeds the mode/cadence-aware `stepList` + * straight in, so the dot count auto-collapses live). Each dot is a + * button: completed + reachable dots jump on click, future dots gate on + * the dialog's multi-step `validateStep` lookahead (`reachableUntil`). + * `SkipBack` / `SkipForward` flank the row for jump-to-first / + * jump-to-last. + * + * Purely presentational — every gating decision is computed in the + * dialog (`goToStep` + `reachableUntil`) so the stepper stays dumb. + * Connectors run dashed for not-yet-reached segments and solid for + * completed ones, giving the "bullets, not one continuous line" feel. + */ + +import { SkipBack, SkipForward } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export interface WizardStepperProps { + /** Ordered raw step numbers for the active mode + cadence path. */ + steps: readonly number[]; + /** The raw step number the body currently renders. */ + current: number; + /** + * Path-index ceiling (exclusive upper bound) of forward-reachable + * slots. A slot at index `j` is reachable iff `j <= reachableUntil`. + * The dialog computes it from the `validateStep` lookahead. + */ + reachableUntil: number; + /** Short per-step label keyed by raw step number. */ + labels: Record; + /** Jump to a raw step number. */ + onJump: (step: number) => void; + onFirst: () => void; + onLast: () => void; + firstEnabled: boolean; + lastEnabled: boolean; + /** Accessible labels for the flanking jump buttons. */ + firstLabel: string; + lastLabel: string; + /** Accessible group label, e.g. "Step 4 of 8". */ + srLabel: string; +} + +export function WizardStepper({ + steps, + current, + reachableUntil, + labels, + onJump, + onFirst, + onLast, + firstEnabled, + lastEnabled, + firstLabel, + lastLabel, + srLabel, +}: WizardStepperProps) { + const currentIndex = steps.indexOf(current); + // When the step pointer is briefly off-path (e.g. Step 5 after the + // mode collapses the path), pin the active slot to the last slot at + // or before the current step so the active dot never disappears. + const activeIndex = + currentIndex >= 0 + ? currentIndex + : steps.reduce((acc, n, i) => (n <= current ? i : acc), 0); + + return ( +
+ + +
    + {steps.map((stepNumber, index) => { + const isActive = index === activeIndex; + const isCompleted = index < activeIndex; + const isReachable = index <= reachableUntil; + const label = labels[stepNumber] ?? String(stepNumber); + // Backward + current are always navigable; forward is gated + // on the reachability ceiling. + const enabled = index <= activeIndex || isReachable; + // A connector segment leads INTO this dot from the previous + // one; it reads "completed" (solid) once we've passed it. + const connectorSolid = index <= activeIndex; + + return ( +
  1. + {index > 0 && ( +
  2. + ); + })} +
+ + +
+ ); +}