feat(forms): wire keyboard hints and aria error wiring across measurement, medication, and mood forms

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 18:33:37 +02:00
1 parent 4455472921
commit 2d8c5e906e
9 files changed
+156 -17

No files matched your search

+1
View File
@@ -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.",
+1
View File
@@ -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.",
@@ -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<string | null>(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({
<Input
id="sys"
type="number"
inputMode="numeric"
enterKeyHint="next"
step="1"
value={sysBp}
onChange={(e) => 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({
<Input
id="dia"
type="number"
inputMode="numeric"
enterKeyHint="next"
step="1"
value={diaBp}
onChange={(e) => 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({
<Input
id="puls"
type="number"
inputMode="numeric"
enterKeyHint="next"
step="1"
value={pulse}
onChange={(e) => setPulse(e.target.value)}
placeholder="72"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
min={30}
max={220}
/>
@@ -321,6 +342,7 @@ export function MeasurementForm({
<Input
id="value"
type="number"
enterKeyHint="next"
step="any"
value={value}
onChange={(e) => setValue(e.target.value)}
@@ -330,6 +352,9 @@ export function MeasurementForm({
: undefined
}
required
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
</div>
)}
@@ -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}
/>
</div>
@@ -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"
/>
</div>
{error && (
<div
id={errorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
@@ -58,7 +58,7 @@ import {
ArrowUpDown,
MoreHorizontal,
} from "lucide-react";
import { useState } from "react";
import { useId, useState } from "react";
import { formatDateTime } from "@/lib/format";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
@@ -150,6 +150,11 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
const [editNotes, setEditNotes] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
// v1.4.27 MB3 — link the edit dialog error banner to the inputs via
// `aria-describedby` so screen readers announce the validation
// failure when the user submits an invalid value or timestamp.
const editErrorId = useId();
const editErrorDescriptor = editError ? editErrorId : undefined;
const setTypeFilter = (value: string) => {
setTypeFilterRaw(value);
@@ -315,7 +320,10 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-48">
<SelectTrigger
className="w-48"
aria-label={t("measurements.filterByType")}
>
<SelectValue placeholder={t("measurements.allTypes")} />
</SelectTrigger>
<SelectContent>
@@ -601,10 +609,14 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
<Input
id="edit-value"
type="number"
enterKeyHint="next"
step="any"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
required
aria-required="true"
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
/>
</div>
@@ -617,6 +629,9 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
value={editMeasuredAt}
onChange={(e) => setEditMeasuredAt(e.target.value)}
required
aria-required="true"
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
/>
</div>
@@ -634,11 +649,14 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
maxLength={MAX_COMMENT_LENGTH}
enterKeyHint="done"
autoCapitalize="sentences"
/>
</div>
{editError && (
<div
id={editErrorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
@@ -443,6 +443,9 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
placeholder={t("medications.sideEffects.notesPlaceholder")}
rows={3}
maxLength={NOTES_MAX}
enterKeyHint="done"
autoCapitalize="sentences"
autoComplete="off"
className="border-input bg-background text-foreground w-full rounded-md border px-2 py-1.5 text-sm"
/>
<p className="text-muted-foreground text-right text-[10px]">
@@ -48,7 +48,7 @@ import {
SkipForward,
AlertTriangle,
} from "lucide-react";
import { useState, useCallback } from "react";
import { useId, useState, useCallback } from "react";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -110,6 +110,10 @@ export function IntakeHistoryList({
const [editSkipped, setEditSkipped] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
// v1.4.27 MB3 — error banners reference the form inputs via
// aria-describedby so screen readers announce validation failures.
const editErrorId = useId();
const editErrorDescriptor = editError ? editErrorId : undefined;
// Create state — controlled via props or internal
const [internalCreating, setInternalCreating] = useState(false);
@@ -118,6 +122,8 @@ export function IntakeHistoryList({
const [createTakenAt, setCreateTakenAt] = useState("");
const [createSkipped, setCreateSkipped] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const createErrorId = useId();
const createErrorDescriptor = createError ? createErrorId : undefined;
const setCreatingRaw = onCreateOpenChange ?? setInternalCreating;
const setCreating = useCallback(
(open: boolean) => {
@@ -542,6 +548,9 @@ export function IntakeHistoryList({
value={editScheduledFor}
onChange={(e) => setEditScheduledFor(e.target.value)}
required
aria-required="true"
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
/>
</div>
@@ -554,6 +563,8 @@ export function IntakeHistoryList({
value={editTakenAt}
onChange={(e) => setEditTakenAt(e.target.value)}
disabled={editSkipped}
aria-invalid={!!editError || undefined}
aria-describedby={editErrorDescriptor}
/>
</div>
@@ -573,6 +584,7 @@ export function IntakeHistoryList({
{editError && (
<div
id={editErrorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
@@ -683,6 +695,9 @@ export function IntakeHistoryList({
value={createScheduledFor}
onChange={(e) => setCreateScheduledFor(e.target.value)}
required
aria-required="true"
aria-invalid={!!createError || undefined}
aria-describedby={createErrorDescriptor}
/>
</div>
@@ -695,6 +710,8 @@ export function IntakeHistoryList({
value={createTakenAt}
onChange={(e) => setCreateTakenAt(e.target.value)}
disabled={createSkipped}
aria-invalid={!!createError || undefined}
aria-describedby={createErrorDescriptor}
/>
</div>
@@ -714,6 +731,7 @@ export function IntakeHistoryList({
{createError && (
<div
id={createErrorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
@@ -73,6 +73,10 @@ export function InventorySection({
// panel for parity with the W8e Provenance accordion.
const panelId = useId();
const pastPanelId = useId();
// v1.4.27 MB3 — link the add-pen error message back to the doses-total
// input via aria-describedby so screen readers announce the failure
// when the form rejects an out-of-range value.
const formErrorId = useId();
const [dosesTotal, setDosesTotal] = useState<string>(
defaultDosesPerUnit ? String(defaultDosesPerUnit) : "",
);
@@ -438,12 +442,16 @@ export function InventorySection({
id="inv-doses-total"
type="number"
inputMode="numeric"
enterKeyHint="next"
min={1}
max={100}
step={1}
value={dosesTotal}
onChange={(e) => setDosesTotal(e.target.value)}
required
aria-required="true"
aria-invalid={!!formError || undefined}
aria-describedby={formError ? formErrorId : undefined}
/>
<p className="text-muted-foreground text-xs">
{t("medications.inventory.fieldDosesTotalHelp")}
@@ -457,6 +465,7 @@ export function InventorySection({
<Input
id="inv-printed-expiry"
type="date"
enterKeyHint="next"
value={printedExpiry}
onChange={(e) => setPrintedExpiry(e.target.value)}
/>
@@ -469,6 +478,7 @@ export function InventorySection({
<Input
id="inv-purchased-at"
type="date"
enterKeyHint="next"
value={purchasedAt}
onChange={(e) => setPurchasedAt(e.target.value)}
/>
@@ -484,12 +494,21 @@ export function InventorySection({
onChange={(e) => setNotes(e.target.value)}
maxLength={200}
rows={2}
enterKeyHint="done"
autoCapitalize="sentences"
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
{formError && (
<p className="text-destructive text-sm">{formError}</p>
<p
id={formErrorId}
role="alert"
aria-live="polite"
className="text-destructive text-sm"
>
{formError}
</p>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
+48 -11
View File
@@ -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}
/>
</div>
@@ -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}
/>
</div>
<div className="space-y-1.5">
@@ -625,6 +642,8 @@ export function MedicationForm({
placeholder="mg"
maxLength={30}
className="w-full"
enterKeyHint="next"
autoComplete="off"
/>
</div>
</div>
@@ -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}
/>
<p className="text-muted-foreground text-xs leading-4">
{t("medications.glp1DosesPerUnitHelp")}
@@ -773,16 +795,23 @@ export function MedicationForm({
{t("medications.scheduleFrom")}
</Label>
<Input
type="text"
inputMode="numeric"
pattern="[0-2][0-9]:[0-5][0-9]"
type="time"
enterKeyHint="next"
placeholder="08:00"
value={s.windowStart}
className="h-11 text-xs md:text-xs"
// v1.4.27 MB3 — `text-base` on mobile keeps the
// schedule time inputs at 16 px, the iOS Safari floor
// below which the focus-zoom auto-fires. Desktop
// stays compact at `text-xs` because the surrounding
// grid carries the small dense rhythm.
className="h-11 text-base md:text-xs"
onChange={(e) =>
updateSchedule(i, "windowStart", e.target.value)
}
required
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
maxLength={5}
/>
</div>
@@ -791,16 +820,18 @@ export function MedicationForm({
{t("medications.scheduleTo")}
</Label>
<Input
type="text"
inputMode="numeric"
pattern="[0-2][0-9]:[0-5][0-9]"
type="time"
enterKeyHint="next"
placeholder="09:00"
value={s.windowEnd}
className="h-11 text-xs md:text-xs"
className="h-11 text-base md:text-xs"
onChange={(e) =>
updateSchedule(i, "windowEnd", e.target.value)
}
required
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
maxLength={5}
/>
</div>
@@ -810,10 +841,13 @@ export function MedicationForm({
</Label>
<Input
value={s.label}
className="h-11 text-xs md:text-xs"
className="h-11 text-base md:text-xs"
onChange={(e) => updateSchedule(i, "label", e.target.value)}
placeholder={t("medications.labelPlaceholder")}
maxLength={50}
enterKeyHint="next"
autoCapitalize="sentences"
autoComplete="off"
/>
</div>
<div className="space-y-2">
@@ -822,10 +856,12 @@ export function MedicationForm({
</Label>
<Input
value={s.dose}
className="h-11 text-xs md:text-xs"
className="h-11 text-base md:text-xs"
onChange={(e) => updateSchedule(i, "dose", e.target.value)}
placeholder={dose || t("medications.defaultDose")}
maxLength={50}
enterKeyHint="done"
autoComplete="off"
/>
</div>
</div>
@@ -910,6 +946,7 @@ export function MedicationForm({
{error && (
<div
id={errorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
+12 -1
View File
@@ -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 {
@@ -65,6 +65,10 @@ export function MoodForm({ onSuccess, onCancel }: MoodFormProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// v1.4.27 MB3 — error banner descriptor for the timestamp input.
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
function resetForm() {
setMood("");
setTagsInput("");
@@ -156,6 +160,9 @@ export function MoodForm({ onSuccess, onCancel }: MoodFormProps) {
value={moodLoggedAt}
onChange={(e) => setMoodLoggedAt(e.target.value)}
required
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
</div>
@@ -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 && (
<div
id={errorId}
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"