feat: add FormNumberStepper to medication edit forms (#274)
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 <input> first in DOM (CSS order restores visual [−] [value] [+] layout). Closes #273
This commit is contained in:
@@ -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 (
|
||||
<div className={normalizedClassName}>
|
||||
{/* Input first in DOM so <label> associates with it, not the decrement button.
|
||||
CSS order restores the visual layout: [−] [input] [+]. */}
|
||||
<input
|
||||
type="text"
|
||||
inputMode={allowDecimal ? "decimal" : "numeric"}
|
||||
pattern={allowDecimal ? "[0-9]*\\.?[0-9]*" : "[0-9]*"}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn decrement"
|
||||
onClick={() => handleStep(-1)}
|
||||
disabled={!canDecrement}
|
||||
aria-label={decrementLabel}
|
||||
>
|
||||
<Minus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-btn increment"
|
||||
onClick={() => handleStep(1)}
|
||||
disabled={!canIncrement}
|
||||
aria-label={incrementLabel}
|
||||
>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<MobileTab>("general");
|
||||
const fieldsetRef = useRef<HTMLFieldSetElement | null>(null);
|
||||
const tabStripRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -499,32 +502,32 @@ export function MobileEditModal({
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.packCount}
|
||||
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("packCount", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.blistersPerPack}
|
||||
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("blistersPerPack", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("pillsPerBlister", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -536,22 +539,22 @@ export function MobileEditModal({
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.totalPills}
|
||||
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("totalPills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("looseTablets", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
@@ -646,22 +649,24 @@ export function MobileEditModal({
|
||||
>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.usage")}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={intake.usage}
|
||||
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
||||
onChange={(nextValue) => onSetIntakeValue(idx, "usage", nextValue)}
|
||||
min={0.5}
|
||||
step={0.5}
|
||||
allowDecimal={true}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.everyDays")}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={intake.every}
|
||||
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
||||
onChange={(nextValue) => onSetIntakeValue(idx, "every", nextValue)}
|
||||
min={1}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact full-row">
|
||||
@@ -740,32 +745,32 @@ export function MobileEditModal({
|
||||
<>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.authorizedRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.prescriptionAuthorizedRefills}
|
||||
onChange={(e) => onHandleValueChange("prescriptionAuthorizedRefills", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("prescriptionAuthorizedRefills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.remainingRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.prescriptionRemainingRefills}
|
||||
onChange={(e) => onHandleValueChange("prescriptionRemainingRefills", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("prescriptionRemainingRefills", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.lowThreshold")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
<FormNumberStepper
|
||||
value={form.prescriptionLowRefillThreshold}
|
||||
onChange={(e) => onHandleValueChange("prescriptionLowRefillThreshold", e.target.value)}
|
||||
onChange={(nextValue) => onHandleValueChange("prescriptionLowRefillThreshold", nextValue)}
|
||||
min={0}
|
||||
decrementLabel={decrementValueLabel}
|
||||
incrementLabel={incrementValueLabel}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
|
||||
@@ -6,6 +6,7 @@ export { ConfirmModal } from "./ConfirmModal";
|
||||
export { DateInput } from "./DateInput";
|
||||
export { DateTimeInput } from "./DateTimeInput";
|
||||
export { default as ExportModal } from "./ExportModal";
|
||||
export { FormNumberStepper } from "./FormNumberStepper";
|
||||
export type { LightboxProps } from "./Lightbox";
|
||||
|
||||
export { Lightbox } from "./Lightbox";
|
||||
|
||||
Reference in New Issue
Block a user