33c1095e77
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
118 lines
3.3 KiB
TypeScript
118 lines
3.3 KiB
TypeScript
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>
|
||
);
|
||
}
|