mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
4455472921
commit
2d8c5e906e
9 files changed
+156
-17
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user