Compare commits

..

5 Commits

Author SHA1 Message Date
Daniel Volz 259f00e7a0 fix: unify number stepper layout and detail modal padding (#279)
Reorder stepper DOM elements (input first) and apply refill-number-stepper
class to both steppers for consistent CSS order-based layout.
Fix missing bottom padding on .med-detail-body.
2026-02-22 17:57:36 +01:00
github-actions[bot] e9f2760815 chore: update test count badges [skip ci] 2026-02-22 16:55:21 +00:00
Daniel Volz d0e2ee0783 fix: trim whitespace from username on login and registration (#277)
Add .trim() to both loginSchema and registerSchema Zod validators so
leading/trailing spaces are stripped before validation and DB lookup.
Includes 5 new test cases covering trim behavior for both endpoints.
2026-02-22 17:51:41 +01:00
Daniel Volz c620146c4b chore: release v1.15.0 (#275) 2026-02-22 16:54:49 +01:00
Daniel Volz 33c1095e77 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
2026-02-22 16:49:51 +01:00
12 changed files with 489 additions and 145 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-564%2F564-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Backend_Tests-569%2F569-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-777%2F777-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.14.4",
"version": "1.15.0",
"private": true,
"type": "module",
"scripts": {
+2 -1
View File
@@ -53,6 +53,7 @@ const sensitiveRateLimitConfig = {
const registerSchema = z.object({
username: z
.string()
.trim()
.min(3, "Username must be at least 3 characters")
.max(50, "Username must be at most 50 characters")
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
@@ -63,7 +64,7 @@ const registerSchema = z.object({
});
const loginSchema = z.object({
username: z.string().min(1, "Username is required"),
username: z.string().trim().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
rememberMe: z.boolean().optional().default(false),
});
+80
View File
@@ -245,6 +245,57 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
expect(response.json().code).toBe("VALIDATION_ERROR");
});
it("should register with trimmed username when input has whitespace", async () => {
const response = await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: " trimuser ",
password: "TestPassword123",
},
});
expect(response.statusCode).toBe(201);
expect(response.json().user.username).toBe("trimuser");
});
it("should reject whitespace-only username on registration", async () => {
const response = await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: " ",
password: "TestPassword123",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
});
it("should reject duplicate username even with surrounding whitespace", async () => {
await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: "spacedupe",
password: "TestPassword123",
},
});
const response = await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: " spacedupe ",
password: "AnotherPassword123",
},
});
expect(response.statusCode).toBe(409);
expect(response.json().code).toBe("USERNAME_EXISTS");
});
it("should reject invalid username characters", async () => {
const response = await app.inject({
method: "POST",
@@ -341,6 +392,35 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
expect(response.json().code).toBe("INVALID_CREDENTIALS");
});
it("should login successfully when username has leading/trailing whitespace", async () => {
const response = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
username: " loginuser ",
password: "TestPassword123",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().ok).toBe(true);
expect(response.json().user.username).toBe("loginuser");
});
it("should reject whitespace-only username on login", async () => {
const response = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
username: " ",
password: "TestPassword123",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
});
it("should support rememberMe option", async () => {
const response = await app.inject({
method: "POST",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.14.4",
"version": "1.15.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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>
);
}
+18 -18
View File
@@ -275,6 +275,14 @@ export function MedDetailModal({
return (
<div className="number-stepper refill-number-stepper">
<input
type="number"
min={min}
max={max}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
/>
<button
type="button"
className="stepper-btn decrement"
@@ -284,14 +292,6 @@ export function MedDetailModal({
>
<Minus size={16} aria-hidden="true" />
</button>
<input
type="number"
min={min}
max={max}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
/>
<button
type="button"
className="stepper-btn increment"
@@ -321,16 +321,7 @@ export function MedDetailModal({
const canIncrement = clamped < max;
return (
<div className="number-stepper">
<button
type="button"
className="stepper-btn decrement"
onClick={() => onChange(Math.max(min, clamped - 1))}
disabled={!canDecrement}
aria-label={decrementLabel}
>
<Minus size={16} aria-hidden="true" />
</button>
<div className="number-stepper refill-number-stepper">
<input
type="number"
min={min}
@@ -341,6 +332,15 @@ export function MedDetailModal({
onChange(Number.isNaN(parsed) ? min : Math.min(max, Math.max(min, parsed)));
}}
/>
<button
type="button"
className="stepper-btn decrement"
onClick={() => onChange(Math.max(min, clamped - 1))}
disabled={!canDecrement}
aria-label={decrementLabel}
>
<Minus size={16} aria-hidden="true" />
</button>
<button
type="button"
className="stepper-btn increment"
+55 -50
View File
@@ -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">
+1
View File
@@ -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";
+72 -60
View File
@@ -5,7 +5,15 @@ import { Bell, Eye, Minus, Pencil, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { ConfirmModal, DateInput, Lightbox, MedicationAvatar, MobileEditModal, ReportModal } from "../components";
import {
ConfirmModal,
DateInput,
FormNumberStepper,
Lightbox,
MedicationAvatar,
MobileEditModal,
ReportModal,
} from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext, useUnsavedChanges } from "../context";
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
@@ -175,6 +183,8 @@ export function MedicationsPage() {
const pillsPerBlister = Number(form.pillsPerBlister) || 1;
return packCount * blistersPerPack * pillsPerBlister;
}, [form.packageType, form.packCount, form.blistersPerPack, form.pillsPerBlister, form.looseTablets]);
const decrementValueLabel = t("editStock.decreaseValue");
const incrementValueLabel = t("editStock.increaseValue");
const dateConsistencyError = useMemo(() => {
const medicationStartDate = form.medicationStartDate;
@@ -966,15 +976,6 @@ export function MedicationsPage() {
>
{t("form.sections.stock")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "prescription"}
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
onClick={() => setActiveTab("prescription")}
>
{t("form.sections.prescription")}
</button>
<button
type="button"
role="tab"
@@ -984,6 +985,15 @@ export function MedicationsPage() {
>
{t("form.sections.schedule")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "prescription"}
className={`form-tab${activeTab === "prescription" ? " active" : ""}`}
onClick={() => setActiveTab("prescription")}
>
{t("form.sections.prescription")}
</button>
</div>
<fieldset className="readonly-fieldset" disabled={readOnlyView}>
<div className={`form-tab-panel${activeTab === "general" ? " active" : ""}`}>
@@ -1157,32 +1167,32 @@ export function MedicationsPage() {
<>
<label>
{t("form.packs")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
<FormNumberStepper
value={form.packCount}
onChange={(e) => handleValueChange("packCount", e.target.value)}
onChange={(nextValue) => handleValueChange("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) => handleValueChange("blistersPerPack", e.target.value)}
onChange={(nextValue) => handleValueChange("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) => handleValueChange("pillsPerBlister", e.target.value)}
onChange={(nextValue) => handleValueChange("pillsPerBlister", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label>
@@ -1194,22 +1204,22 @@ export function MedicationsPage() {
<>
<label>
{t("form.totalCapacity")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
<FormNumberStepper
value={form.totalPills}
onChange={(e) => handleValueChange("totalPills", e.target.value)}
onChange={(nextValue) => handleValueChange("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) => handleValueChange("looseTablets", e.target.value)}
onChange={(nextValue) => handleValueChange("looseTablets", nextValue)}
min={0}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
</>
@@ -1300,32 +1310,32 @@ export function MedicationsPage() {
<>
<label className="prescription-field">
{t("prescription.authorizedRefills")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
<FormNumberStepper
value={form.prescriptionAuthorizedRefills}
onChange={(e) => handleValueChange("prescriptionAuthorizedRefills", e.target.value)}
onChange={(nextValue) => handleValueChange("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) => handleValueChange("prescriptionRemainingRefills", e.target.value)}
onChange={(nextValue) => handleValueChange("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) => handleValueChange("prescriptionLowRefillThreshold", e.target.value)}
onChange={(nextValue) => handleValueChange("prescriptionLowRefillThreshold", nextValue)}
min={0}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label className="prescription-field">
@@ -1362,22 +1372,24 @@ export function MedicationsPage() {
<div className="blister-inputs">
<label>
{t("form.blisters.usage")}
<input
type="text"
inputMode="decimal"
pattern="[0-9]*\.?[0-9]*"
<FormNumberStepper
value={intake.usage}
onChange={(e) => setIntakeValue(idx, "usage", e.target.value)}
onChange={(nextValue) => setIntakeValue(idx, "usage", nextValue)}
min={0.5}
step={0.5}
allowDecimal={true}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label>
{t("form.blisters.everyDays")}
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
<FormNumberStepper
value={intake.every}
onChange={(e) => setIntakeValue(idx, "every", e.target.value)}
onChange={(nextValue) => setIntakeValue(idx, "every", nextValue)}
min={1}
decrementLabel={decrementValueLabel}
incrementLabel={incrementValueLabel}
/>
</label>
<label>
+21 -6
View File
@@ -1127,11 +1127,15 @@ body.modal-open {
}
.blister-row .blister-inputs {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1.05fr) minmax(10.75rem, 1fr) minmax(7.25rem, 0.8fr);
gap: 0.75rem;
align-items: end;
}
.blister-row .blister-inputs > label {
min-width: 0;
}
.blister-row .blister-inputs label.taken-by-field {
grid-column: span 2;
}
@@ -1154,6 +1158,17 @@ body.modal-open {
}
}
/* Desktop edit sidebar can be narrow; avoid clipping right-side controls. */
@media (min-width: 769px) {
.edit-sidebar .blister-row .blister-inputs {
grid-template-columns: 1fr 1fr;
}
.edit-sidebar .blister-row .blister-inputs label.taken-by-field {
grid-column: 1 / -1;
}
}
.gap {
gap: 0.6rem;
}
@@ -3376,7 +3391,7 @@ button.has-validation-error {
transition:
opacity 0.15s,
visibility 0.15s;
z-index: 100;
z-index: 1100;
pointer-events: none;
}
@@ -3394,7 +3409,7 @@ button.has-validation-error {
transition:
opacity 0.15s,
visibility 0.15s;
z-index: 101;
z-index: 1101;
}
/* Tooltip aligned to left edge of icon (prevents clipping inside modals) */
@@ -4335,7 +4350,7 @@ button.has-validation-error {
overscroll-behavior: contain;
display: flex;
flex-direction: column;
overflow: hidden;
overflow: visible;
}
.med-detail-modal .med-detail-body {
@@ -4534,7 +4549,7 @@ button.has-validation-error {
}
.med-detail-body {
padding: 1.5rem 2rem 0;
padding: 1.5rem 2rem 2rem;
background: var(--bg-primary);
}
@@ -4701,7 +4716,7 @@ button.has-validation-error {
background: var(--bg-primary);
border-radius: 0 0 12px 12px;
flex-shrink: 0;
overflow: hidden;
overflow: visible;
position: relative;
z-index: 1;
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
+120 -7
View File
@@ -446,7 +446,7 @@
}
.refill-number-stepper input {
order: initial;
order: 0;
text-align: center;
padding: 0.75rem 0.5rem;
}
@@ -460,21 +460,29 @@
}
.refill-number-stepper .stepper-btn.decrement {
order: initial;
order: -1;
background: color-mix(in srgb, var(--danger) 22%, var(--bg-tertiary));
color: var(--danger);
}
.refill-number-stepper .stepper-btn.increment {
order: initial;
order: 1;
border-right: none;
border-left: 1px solid var(--border-primary);
background: color-mix(in srgb, var(--bg-tertiary) 85%, transparent);
background: color-mix(in srgb, var(--success) 22%, var(--bg-tertiary));
color: var(--success);
}
.refill-number-stepper .stepper-btn:hover:not(:disabled) {
filter: none;
background: color-mix(in srgb, var(--accent) 14%, var(--bg-tertiary));
}
.refill-number-stepper .stepper-btn.decrement:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 36%, var(--bg-tertiary));
}
.refill-number-stepper .stepper-btn.increment:hover:not(:disabled) {
background: color-mix(in srgb, var(--success) 36%, var(--bg-tertiary));
}
@media (min-width: 641px) {
@@ -488,12 +496,12 @@
}
[data-theme="light"] .refill-number-stepper .stepper-btn.decrement {
background: color-mix(in srgb, var(--bg-tertiary) 90%, transparent);
background: color-mix(in srgb, #dc2626 18%, white);
color: #b91c1c;
}
[data-theme="light"] .refill-number-stepper .stepper-btn.increment {
background: color-mix(in srgb, var(--bg-tertiary) 90%, transparent);
background: color-mix(in srgb, #0f766e 18%, white);
color: #0f766e;
}
}
@@ -504,6 +512,111 @@
}
}
/* Form stepper keeps symmetric - value + layout in all contexts (desktop/mobile). */
.form-number-stepper {
display: grid;
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
}
.form-number-stepper input {
order: 0;
text-align: center;
padding: 0.75rem 0.5rem;
min-width: 0;
width: 100%;
color: var(--text-primary);
}
.form-number-stepper .stepper-btn {
flex: 0 0 auto;
border-right: 1px solid var(--border-primary);
border-left: none;
background: color-mix(in srgb, var(--bg-tertiary) 85%, transparent);
color: var(--text-secondary);
}
.form-number-stepper .stepper-btn.decrement {
order: -1;
background: color-mix(in srgb, var(--danger) 22%, var(--bg-tertiary));
color: var(--danger);
}
.form-number-stepper .stepper-btn.increment {
order: 1;
border-right: none;
border-left: 1px solid var(--border-primary);
background: color-mix(in srgb, var(--success) 22%, var(--bg-tertiary));
color: var(--success);
}
.form-number-stepper .stepper-btn:hover:not(:disabled) {
filter: none;
}
.form-number-stepper .stepper-btn.decrement:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 36%, var(--bg-tertiary));
}
.form-number-stepper .stepper-btn.increment:hover:not(:disabled) {
background: color-mix(in srgb, var(--success) 36%, var(--bg-tertiary));
}
/* Highlight both controls when the center value field is focused (keyboard/click). */
.form-number-stepper:has(input:focus) .stepper-btn.decrement:not(:disabled),
.form-number-stepper:has(input:focus-visible) .stepper-btn.decrement:not(:disabled) {
background: color-mix(in srgb, var(--danger) 36%, var(--bg-tertiary));
}
.form-number-stepper:has(input:focus) .stepper-btn.increment:not(:disabled),
.form-number-stepper:has(input:focus-visible) .stepper-btn.increment:not(:disabled) {
background: color-mix(in srgb, var(--success) 36%, var(--bg-tertiary));
}
/* Dense schedule grids need a compact variant so the middle value stays visible. */
.blister-inputs .form-number-stepper,
.mobile-edit-form .blister-row .form-number-stepper {
grid-template-columns: 2.35rem minmax(2rem, 1fr) 2.35rem;
}
.blister-inputs .form-number-stepper input,
.mobile-edit-form .blister-row .form-number-stepper input {
min-height: 2.35rem;
padding: 0.5rem 0.35rem;
}
.blister-inputs .form-number-stepper .stepper-btn,
.mobile-edit-form .blister-row .form-number-stepper .stepper-btn {
min-width: 2.35rem;
min-height: 2.35rem;
}
@media (min-width: 641px) {
.form-number-stepper {
display: grid;
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
}
.form-number-stepper input {
padding-left: 0.5rem;
}
[data-theme="light"] .form-number-stepper .stepper-btn.decrement {
background: color-mix(in srgb, #dc2626 18%, white);
color: #b91c1c;
}
[data-theme="light"] .form-number-stepper .stepper-btn.increment {
background: color-mix(in srgb, #0f766e 18%, white);
color: #0f766e;
}
}
@media (max-width: 640px) {
.form-number-stepper {
grid-template-columns: 2.75rem minmax(0, 1fr) 2.75rem;
}
}
.edit-stock-summary {
display: flex;
gap: 0.5rem;