From 2d8c5e906e1f46bd21431a3e08286a9c2cc339d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 18:33:37 +0200 Subject: [PATCH] feat(forms): wire keyboard hints and aria error wiring across measurement, medication, and mood forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pair every required field with `enterKeyHint`, `aria-required`, `aria-invalid`, and `aria-describedby` so the form-level error banner gets announced the moment validation fails, and so each input surfaces the right virtual-keyboard hint (next, done) when the user moves through the form. - Measurement form (single + BP triple) and the inline edit dialog pin `aria-describedby` to the alert region, add `enterKeyHint` on every numeric and notes input, and pass the filter Select an explicit `aria-label`. - Medication form gets the chained `enterKeyHint` flow on name → dose → unit → doses-per-pen, switches the schedule window inputs to `type="time"`, and forces `text-base` on the dense schedule cells so iOS Safari does not auto-zoom the field on focus. - Intake create + edit dialogs reference the dialog-local error banner via `aria-describedby`. - Inventory add-pen dialog wires the same error descriptor and adds `enterKeyHint` to the date and doses-total fields plus `autoCapitalize="sentences"` on the notes textarea. - Side-effect log notes textarea gets `enterKeyHint="done"` and `autoCapitalize="sentences"`. - Mood form pins the timestamp input to the error banner and lifts the tags input to `autoCapitalize="none"` so colour-coded chips do not get title-cased. Adds the `measurements.filterByType` translation in de + en. --- messages/de.json | 1 + messages/en.json | 1 + .../measurements/measurement-form.tsx | 33 ++++++++++- .../measurements/measurement-list.tsx | 22 ++++++- .../medications/SideEffectsSection.tsx | 3 + .../medications/intake-history-list.tsx | 20 ++++++- .../medications/inventory-section.tsx | 21 ++++++- .../medications/medication-form.tsx | 59 +++++++++++++++---- src/components/mood/mood-form.tsx | 13 +++- 9 files changed, 156 insertions(+), 17 deletions(-) diff --git a/messages/de.json b/messages/de.json index 66a787e6a..25fffb059 100644 --- a/messages/de.json +++ b/messages/de.json @@ -336,6 +336,7 @@ "invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.", "loginRequired": "Bitte anmelden, um Messwerte zu sehen.", "allTypes": "Alle Typen", + "filterByType": "Nach Typ filtern", "measurementCount": "{count} Messwerte", "emptyTitle": "Noch keine Messwerte", "emptyDescription": "Erfasse Gewicht, Blutdruck, Puls oder andere Werte, um Trends zu erkennen.", diff --git a/messages/en.json b/messages/en.json index aa147e399..e0808411f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -336,6 +336,7 @@ "invalidTimestamp": "Please select a valid time.", "loginRequired": "Please sign in to view measurements.", "allTypes": "All types", + "filterByType": "Filter by type", "measurementCount": "{count} measurements", "emptyTitle": "No measurements yet", "emptyDescription": "Log your weight, blood pressure, pulse, or other vitals to start tracking trends.", diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index d4012b943..18fe636d6 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useId, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { @@ -139,6 +139,13 @@ export function MeasurementForm({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // v1.4.27 MB3 — wire `aria-describedby` on every required field so + // screen readers announce the form-level error banner the moment it + // surfaces. The banner already carries `role="alert"`, so the + // descriptor relationship is purely additive. + const errorId = useId(); + const errorDescriptor = error ? errorId : undefined; + const typeInfo = MEASUREMENT_TYPES.find((t) => t.value === type); const isBpMode = type === "BLOOD_PRESSURE"; const isGlucoseMode = type === "BLOOD_GLUCOSE"; @@ -270,11 +277,16 @@ export function MeasurementForm({ setSysBp(e.target.value)} placeholder="120" required + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} min={60} max={280} /> @@ -284,11 +296,16 @@ export function MeasurementForm({ setDiaBp(e.target.value)} placeholder="80" required + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} min={30} max={200} /> @@ -298,10 +315,14 @@ export function MeasurementForm({ setPulse(e.target.value)} placeholder="72" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} min={30} max={220} /> @@ -321,6 +342,7 @@ export function MeasurementForm({ setValue(e.target.value)} @@ -330,6 +352,9 @@ export function MeasurementForm({ : undefined } required + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} /> )} @@ -364,6 +389,9 @@ export function MeasurementForm({ value={measuredAt} onChange={(e) => setMeasuredAt(e.target.value)} required + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} /> @@ -385,11 +413,14 @@ export function MeasurementForm({ onChange={(e) => setNotes(e.target.value)} placeholder={t("measurements.notesPlaceholder")} maxLength={MAX_COMMENT_LENGTH} + enterKeyHint="done" + autoCapitalize="sentences" /> {error && ( {editError && ( @@ -554,6 +563,8 @@ export function IntakeHistoryList({ value={editTakenAt} onChange={(e) => setEditTakenAt(e.target.value)} disabled={editSkipped} + aria-invalid={!!editError || undefined} + aria-describedby={editErrorDescriptor} /> @@ -573,6 +584,7 @@ export function IntakeHistoryList({ {editError && ( @@ -695,6 +710,8 @@ export function IntakeHistoryList({ value={createTakenAt} onChange={(e) => setCreateTakenAt(e.target.value)} disabled={createSkipped} + aria-invalid={!!createError || undefined} + aria-describedby={createErrorDescriptor} /> @@ -714,6 +731,7 @@ export function IntakeHistoryList({ {createError && ( {formError && ( -

{formError}

+ )}
diff --git a/src/components/medications/medication-form.tsx b/src/components/medications/medication-form.tsx index 6d299a4fa..dda262861 100644 --- a/src/components/medications/medication-form.tsx +++ b/src/components/medications/medication-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useId, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -304,6 +304,12 @@ export function MedicationForm({ const [purgeDialogOpen, setPurgeDialogOpen] = useState(false); const [phaseConfigOpen, setPhaseConfigOpen] = useState(false); + // v1.4.27 MB3 — link the form-level error banner to every required + // input via `aria-describedby` so screen readers announce the + // validation failure on save. + const errorId = useId(); + const errorDescriptor = error ? errorId : undefined; + const isEdit = !!initial; const dose = doseAmount ? `${doseAmount}${doseUnit ? ` ${doseUnit}` : ""}` @@ -527,6 +533,12 @@ export function MedicationForm({ placeholder={t("medications.namePlaceholder")} required maxLength={100} + enterKeyHint="next" + autoCapitalize="words" + autoComplete="off" + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} />
@@ -613,6 +625,11 @@ export function MedicationForm({ placeholder={t("medications.dosePlaceholder")} required maxLength={20} + enterKeyHint="next" + autoComplete="off" + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} />
@@ -625,6 +642,8 @@ export function MedicationForm({ placeholder="mg" maxLength={30} className="w-full" + enterKeyHint="next" + autoComplete="off" />
@@ -690,11 +709,14 @@ export function MedicationForm({ id="med-doses-per-unit" type="number" inputMode="numeric" + enterKeyHint="next" min={1} max={100} value={dosesPerUnit} onChange={(e) => setDosesPerUnit(e.target.value)} placeholder={t("medications.glp1DosesPerUnitPlaceholder")} + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} />

{t("medications.glp1DosesPerUnitHelp")} @@ -773,16 +795,23 @@ export function MedicationForm({ {t("medications.scheduleFrom")} updateSchedule(i, "windowStart", e.target.value) } required + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} maxLength={5} /> @@ -791,16 +820,18 @@ export function MedicationForm({ {t("medications.scheduleTo")} updateSchedule(i, "windowEnd", e.target.value) } required + aria-required="true" + aria-invalid={!!error || undefined} + aria-describedby={errorDescriptor} maxLength={5} /> @@ -810,10 +841,13 @@ export function MedicationForm({ updateSchedule(i, "label", e.target.value)} placeholder={t("medications.labelPlaceholder")} maxLength={50} + enterKeyHint="next" + autoCapitalize="sentences" + autoComplete="off" />

@@ -822,10 +856,12 @@ export function MedicationForm({ updateSchedule(i, "dose", e.target.value)} placeholder={dose || t("medications.defaultDose")} maxLength={50} + enterKeyHint="done" + autoComplete="off" />
@@ -910,6 +946,7 @@ export function MedicationForm({ {error && ( @@ -176,6 +183,9 @@ export function MoodForm({ onSuccess, onCancel }: MoodFormProps) { value={tagsInput} onChange={(e) => setTagsInput(e.target.value)} placeholder={t("mood.tagsPlaceholder")} + enterKeyHint="done" + autoCapitalize="none" + autoComplete="off" /> {/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip appends the localised label to the free-text tag list. @@ -228,6 +238,7 @@ export function MoodForm({ onSuccess, onCancel }: MoodFormProps) { {error && (