mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(medications): replace the wizard progress bar with a jumpable dot stepper
This commit is contained in:
1 parent
38b12607c2
commit
c1f30466e7
2 files changed
+297
-20
No files matched your search
@@ -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<number, string> = {};
|
||||
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
|
||||
`<Progress>` 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. */}
|
||||
<div className="border-border/70 space-y-1.5 border-b p-4 pr-12 sm:p-6 sm:pr-14">
|
||||
<Progress
|
||||
value={progress}
|
||||
// v1.5.5 D-3 §8 — width-only shadcn `<Progress>`. 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}
|
||||
<WizardStepper
|
||||
steps={stepList}
|
||||
current={step}
|
||||
reachableUntil={reachableUntil}
|
||||
labels={stepperLabels}
|
||||
onJump={goToStep}
|
||||
onFirst={() => 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}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground text-xs">{stepOf}</p>
|
||||
{step === 1 && mode === "create" && (
|
||||
{step === 1 && mode === "create" && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -429,8 +501,8 @@ function WizardDialogShell({
|
||||
/>
|
||||
{t("medications.wizard.nl.button")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step body — v1.5.5 D-3 §8 spacing buckets:
|
||||
@@ -480,6 +552,19 @@ function WizardDialogShell({
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{stepSubline}
|
||||
</p>
|
||||
{/* 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). */}
|
||||
<p
|
||||
className="text-muted-foreground/80 flex items-center gap-0.5 pt-0.5 text-xs"
|
||||
data-slot="wizard-next-hint"
|
||||
>
|
||||
<ChevronRight
|
||||
className="h-3 w-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{nextHint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.8.6 W4b — dot/bullet stepper for the medication wizard.
|
||||
*
|
||||
* Replaces the continuous `<Progress>` 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<number, string>;
|
||||
/** 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 (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
data-slot="wizard-stepper"
|
||||
role="group"
|
||||
aria-label={srLabel}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={onFirst}
|
||||
disabled={!firstEnabled}
|
||||
data-slot="wizard-stepper-first"
|
||||
aria-label={firstLabel}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<ol className="flex min-w-0 flex-1 items-center justify-between">
|
||||
{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 (
|
||||
<li
|
||||
key={stepNumber}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center",
|
||||
index === 0 ? "flex-initial" : "flex-1",
|
||||
)}
|
||||
>
|
||||
{index > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mx-1 h-0 flex-1 border-t",
|
||||
connectorSolid
|
||||
? "border-primary"
|
||||
: "border-muted-foreground/30 border-dashed",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJump(stepNumber)}
|
||||
disabled={!enabled}
|
||||
data-slot="wizard-stepper-dot"
|
||||
data-step-dot={stepNumber}
|
||||
data-active={isActive || undefined}
|
||||
data-completed={isCompleted || undefined}
|
||||
aria-current={isActive ? "step" : undefined}
|
||||
aria-label={`${index + 1}. ${label}`}
|
||||
className={cn(
|
||||
// 44 px hit target via padding even though the visual
|
||||
// dot is small.
|
||||
"group focus-visible:ring-ring/50 flex shrink-0 flex-col items-center gap-1 rounded-md p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
enabled
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-3 w-3 place-items-center rounded-full border transition-colors",
|
||||
isActive
|
||||
? "border-primary bg-primary ring-primary/30 ring-2 ring-offset-1 ring-offset-transparent"
|
||||
: isCompleted
|
||||
? "border-primary bg-primary"
|
||||
: isReachable
|
||||
? "border-primary bg-transparent"
|
||||
: "border-muted-foreground/30 bg-transparent",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-[6rem] truncate text-[0.6875rem] leading-none",
|
||||
isActive
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground",
|
||||
// Crowding guard: on narrow viewports only the
|
||||
// active dot shows its label; from `sm+` every dot
|
||||
// is labelled.
|
||||
isActive ? "inline" : "hidden sm:inline",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={onLast}
|
||||
disabled={!lastEnabled}
|
||||
data-slot="wizard-stepper-last"
|
||||
aria-label={lastLabel}
|
||||
>
|
||||
<SkipForward className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user