From 33c1095e772918897798c75b4f7089e850b71114 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 22 Feb 2026 16:49:51 +0100 Subject: [PATCH] feat: add FormNumberStepper to medication edit forms (#274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace plain numeric inputs with a reusable +/− stepper component in both desktop (MedicationsPage) and mobile (MobileEditModal) edit forms. Applied to Stock, Schedule, and Prescription tab fields. Reorder tabs so Schedule appears before Prescription. Add responsive grid overrides for narrow sidebar and compact schedule rows. Fix label-hover ghost activation by placing first in DOM (CSS order restores visual [−] [value] [+] layout). Closes #273 --- frontend/src/components/FormNumberStepper.tsx | 117 ++++++++++++++++ frontend/src/components/MobileEditModal.tsx | 105 +++++++------- frontend/src/components/index.ts | 1 + frontend/src/pages/MedicationsPage.tsx | 132 ++++++++++-------- frontend/src/styles.css | 25 +++- frontend/src/styles/medication-workflows.css | 121 +++++++++++++++- 6 files changed, 382 insertions(+), 119 deletions(-) create mode 100644 frontend/src/components/FormNumberStepper.tsx diff --git a/frontend/src/components/FormNumberStepper.tsx b/frontend/src/components/FormNumberStepper.tsx new file mode 100644 index 0000000..ef29383 --- /dev/null +++ b/frontend/src/components/FormNumberStepper.tsx @@ -0,0 +1,117 @@ +import { Minus, Plus } from "lucide-react"; + +interface FormNumberStepperProps { + value: string; + onChange: (nextValue: string) => void; + min?: number; + max?: number; + step?: number; + allowDecimal?: boolean; + decrementLabel: string; + incrementLabel: string; + className?: string; +} + +const DECIMAL_ROUNDING_FACTOR = 1000; + +function clamp(value: number, min: number, max?: number): number { + const clampedMin = Math.max(min, value); + if (max == null) return clampedMin; + return Math.min(max, clampedMin); +} + +function normalizeDecimal(value: number): number { + return Math.round(value * DECIMAL_ROUNDING_FACTOR) / DECIMAL_ROUNDING_FACTOR; +} + +function toDisplayValue(value: number, allowDecimal: boolean): string { + if (!allowDecimal) return String(Math.max(0, Math.trunc(value))); + const normalized = normalizeDecimal(value); + return normalized.toString(); +} + +function sanitizeRawInput(raw: string, allowDecimal: boolean): string { + const normalizedRaw = raw.replace(",", "."); + if (allowDecimal) { + const cleaned = normalizedRaw.replace(/[^\d.]/g, ""); + const [integerPart = "", ...fractionalParts] = cleaned.split("."); + if (fractionalParts.length === 0) return integerPart; + return `${integerPart}.${fractionalParts.join("")}`; + } + return normalizedRaw.replace(/\D/g, ""); +} + +function parseInputValue(raw: string, allowDecimal: boolean): number | null { + if (raw.trim() === "") return null; + const parsed = allowDecimal ? Number.parseFloat(raw) : Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) return null; + return parsed; +} + +export function FormNumberStepper({ + value, + onChange, + min = 0, + max, + step = 1, + allowDecimal = false, + decrementLabel, + incrementLabel, + className = "", +}: FormNumberStepperProps) { + const parsed = parseInputValue(value, allowDecimal); + const baseValue = parsed ?? min; + const canDecrement = baseValue > min; + const canIncrement = max == null || baseValue < max; + + const normalizedClassName = ["number-stepper", "form-number-stepper", className].filter(Boolean).join(" "); + + const handleStep = (direction: -1 | 1) => { + const nextRaw = clamp(baseValue + direction * step, min, max); + onChange(toDisplayValue(nextRaw, allowDecimal)); + }; + + const handleInputChange = (nextRaw: string) => { + onChange(sanitizeRawInput(nextRaw, allowDecimal)); + }; + + const handleBlur = () => { + const nextParsed = parseInputValue(value, allowDecimal); + if (nextParsed == null) return; + const clamped = clamp(nextParsed, min, max); + onChange(toDisplayValue(clamped, allowDecimal)); + }; + + return ( +
+ {/* Input first in DOM so
+ ); +} diff --git a/frontend/src/components/MobileEditModal.tsx b/frontend/src/components/MobileEditModal.tsx index 8e83216..296f65e 100644 --- a/frontend/src/components/MobileEditModal.tsx +++ b/frontend/src/components/MobileEditModal.tsx @@ -11,6 +11,7 @@ import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medicat import { DOSE_UNITS } from "../types"; import { deriveTotal } from "../utils"; import { DateInput } from "./DateInput"; +import { FormNumberStepper } from "./FormNumberStepper"; // Field limits for validation const FIELD_LIMITS = { @@ -107,6 +108,8 @@ export function MobileEditModal({ onSaveMedication, }: MobileEditModalProps) { const { t } = useTranslation(); + const decrementValueLabel = t("editStock.decreaseValue"); + const incrementValueLabel = t("editStock.increaseValue"); const [activeTab, setActiveTab] = useState("general"); const fieldsetRef = useRef(null); const tabStripRef = useRef(null); @@ -499,32 +502,32 @@ export function MobileEditModal({ <>