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:
@@ -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}
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|||||||
Reference in New Issue
Block a user