fix(timeline): wire the server date error to the date field's aria-invalid

REQ-011 asks for both required-field errors via per-field aria-invalid, but the
blank-date error was rendered as a standalone <p> after the date card while the
date input's aria-invalid only reflected the client-side malformed-date cue.

DatePrecisionField gains a `dateError` prop: a server error now marks the field
aria-invalid and renders inline under the input (sharing the same error id), and
EventForm drops its detached <p>. While here, migrate the field's two error
texts from hard-coded text-red-600 to the semantic `text-danger` token so they
keep ≥4.5:1 contrast in dark mode (the token remaps; #dc2626 was borderline) —
this also fixes the contrast for WhoWhenSection, the other consumer.

Component test asserts the date input gains aria-invalid on a server date error.

Addresses PR #832 review (Requirements Engineer REQ-011; UI/UX dark-mode contrast).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 00:30:40 +02:00
parent c13baa4785
commit 4dc5e3278f
3 changed files with 25 additions and 10 deletions

View File

@@ -35,6 +35,7 @@ let {
suggestedDateIso = '', suggestedDateIso = '',
dateLabel = m.form_label_date(), dateLabel = m.form_label_date(),
dateRequired = true, dateRequired = true,
dateError = '',
dateTestId = undefined, dateTestId = undefined,
precisionTestId = undefined, precisionTestId = undefined,
endDateInnerTestId = undefined endDateInnerTestId = undefined
@@ -49,6 +50,8 @@ let {
suggestedDateIso?: string; suggestedDateIso?: string;
dateLabel?: string; dateLabel?: string;
dateRequired?: boolean; dateRequired?: boolean;
/** Server-side date error (e.g. blank required field) wired to the field's aria-invalid. */
dateError?: string;
dateTestId?: string; dateTestId?: string;
precisionTestId?: string; precisionTestId?: string;
endDateInnerTestId?: string; endDateInnerTestId?: string;
@@ -83,6 +86,9 @@ onMount(() => {
}); });
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === ''); const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
// Either the client-side malformed-date cue or a server-provided required-field
// error marks the field invalid (REQ-011 per-field aria-invalid).
const dateFieldInvalid = $derived(dateInvalid || dateError.length > 0);
// Inline mirror of the server guard (#678). ISO YYYY-MM-DD strings compare // Inline mirror of the server guard (#678). ISO YYYY-MM-DD strings compare
// lexicographically, so no Date object is needed. Server stays the gate — // lexicographically, so no Date object is needed. Server stays the gate —
@@ -128,17 +134,21 @@ $effect(() => {
maxlength="10" maxlength="10"
aria-required={dateRequired ? 'true' : undefined} aria-required={dateRequired ? 'true' : undefined}
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
{dateInvalid {dateFieldInvalid
? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500'
: 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}" : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
aria-invalid={dateInvalid ? 'true' : undefined} aria-invalid={dateFieldInvalid ? 'true' : undefined}
aria-describedby={dateInvalid ? `${dateInputName}-error` : undefined} aria-describedby={dateFieldInvalid ? `${dateInputName}-error` : undefined}
/> />
<input type="hidden" name={dateInputName} value={dateIso} /> <input type="hidden" name={dateInputName} value={dateIso} />
{#if dateInvalid} {#if dateInvalid}
<p id="{dateInputName}-error" class="mt-1 text-xs text-red-600"> <p id="{dateInputName}-error" class="mt-1 text-xs text-danger">
<span aria-hidden="true"></span>{m.form_date_error()} <span aria-hidden="true"></span>{m.form_date_error()}
</p> </p>
{:else if dateError}
<p id="{dateInputName}-error" class="mt-1 text-xs text-danger">
<span aria-hidden="true"></span>{dateError}
</p>
{/if} {/if}
</div> </div>
@@ -183,7 +193,7 @@ $effect(() => {
/> />
{#if endBeforeStart} {#if endBeforeStart}
<!-- Non-colour cue (WCAG 1.4.1): warning glyph + text, not red alone. --> <!-- Non-colour cue (WCAG 1.4.1): warning glyph + text, not red alone. -->
<p id="{dateInputName}-end-error" class="mt-1 text-xs text-red-600"> <p id="{dateInputName}-end-error" class="mt-1 text-xs text-danger">
<span aria-hidden="true"></span>{m.error_invalid_date_range()} <span aria-hidden="true"></span>{m.error_invalid_date_range()}
</p> </p>
{/if} {/if}

View File

@@ -221,16 +221,12 @@ async function confirmDelete(e: SubmitEvent) {
endDateInputName="eventDateEnd" endDateInputName="eventDateEnd"
precisionInputName="precision" precisionInputName="precision"
dateLabel={m.form_label_date()} dateLabel={m.form_label_date()}
dateError={dateError}
dateTestId="event-date" dateTestId="event-date"
precisionTestId="event-precision" precisionTestId="event-precision"
endDateInnerTestId="event-end-date" endDateInnerTestId="event-end-date"
/> />
</div> </div>
{#if dateError}
<p class="mt-2 text-sm text-danger" role="alert">
<span aria-hidden="true"></span>{dateError}
</p>
{/if}
</div> </div>
<!-- Beschreibung --> <!-- Beschreibung -->

View File

@@ -73,6 +73,15 @@ describe('EventForm — required-field error (REQ-010)', () => {
}); });
}); });
describe('EventForm — server date error wired per-field (REQ-011)', () => {
it('marks the date field aria-invalid and shows the message on a server date error', async () => {
render(EventForm, { form: { dateError: 'Bitte ein Datum eingeben.' } });
await expect.element(page.getByText('Bitte ein Datum eingeben.')).toBeInTheDocument();
const dateInput = document.querySelector('#eventDate') as HTMLInputElement;
expect(dateInput.getAttribute('aria-invalid')).toBe('true');
});
});
describe('EventForm — submitting state (named AC)', () => { describe('EventForm — submitting state (named AC)', () => {
it('renders an enabled submit button initially', async () => { it('renders an enabled submit button initially', async () => {
render(EventForm, { event: makeEvent() }); render(EventForm, { event: makeEvent() });