feat(document): surface end-before-start inline on the date form (#678)
Add an endBeforeStart $derived to WhoWhenSection (lexicographic ISO compare, no Date object) that renders an inline error on the end-date field — border-red-400, aria-invalid, aria-describedby, and a #end-date-error <p> inside the existing aria-live region — with a ⚠ glyph so the cue is not colour-alone (WCAG 1.4.1). Save is not disabled; the server stays the gate. Wire ErrorCode INVALID_DATE_RANGE through errors.ts getErrorMessage and add the single key error_invalid_date_range to de/en/es, so the same translated string is used inline (client) and via getErrorMessage (server fallback). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -655,6 +655,7 @@
|
|||||||
"person_alias_btn_delete": "Entfernen",
|
"person_alias_btn_delete": "Entfernen",
|
||||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||||
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
||||||
|
"error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.",
|
||||||
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||||
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||||
|
|||||||
@@ -655,6 +655,7 @@
|
|||||||
"person_alias_btn_delete": "Remove",
|
"person_alias_btn_delete": "Remove",
|
||||||
"error_alias_not_found": "The name alias was not found.",
|
"error_alias_not_found": "The name alias was not found.",
|
||||||
"error_invalid_person_type": "The specified person type is not valid.",
|
"error_invalid_person_type": "The specified person type is not valid.",
|
||||||
|
"error_invalid_date_range": "The end date must not be before the start date.",
|
||||||
"validation_last_name_required": "Last name is required.",
|
"validation_last_name_required": "Last name is required.",
|
||||||
"validation_first_name_required": "First name is required.",
|
"validation_first_name_required": "First name is required.",
|
||||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||||
|
|||||||
@@ -655,6 +655,7 @@
|
|||||||
"person_alias_btn_delete": "Eliminar",
|
"person_alias_btn_delete": "Eliminar",
|
||||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||||
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
||||||
|
"error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.",
|
||||||
"validation_last_name_required": "El apellido es obligatorio.",
|
"validation_last_name_required": "El apellido es obligatorio.",
|
||||||
"validation_first_name_required": "El nombre es obligatorio.",
|
"validation_first_name_required": "El nombre es obligatorio.",
|
||||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ onMount(() => {
|
|||||||
|
|
||||||
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === '');
|
||||||
|
|
||||||
|
// Inline mirror of the server guard (#678). ISO YYYY-MM-DD strings compare
|
||||||
|
// lexicographically, so no Date object is needed. Server stays the gate —
|
||||||
|
// this only surfaces the error early; it never disables Save.
|
||||||
|
const endBeforeStart = $derived(
|
||||||
|
showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso
|
||||||
|
);
|
||||||
|
|
||||||
function handleDateInput(e: Event) {
|
function handleDateInput(e: Event) {
|
||||||
const result = handleGermanDateInput(e);
|
const result = handleGermanDateInput(e);
|
||||||
dateDisplay = result.display;
|
dateDisplay = result.display;
|
||||||
@@ -155,8 +162,19 @@ $effect(() => {
|
|||||||
oninput={handleEndDateInput}
|
oninput={handleEndDateInput}
|
||||||
placeholder={m.form_placeholder_date()}
|
placeholder={m.form_placeholder_date()}
|
||||||
maxlength="10"
|
maxlength="10"
|
||||||
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
aria-invalid={endBeforeStart ? 'true' : undefined}
|
||||||
|
aria-describedby={endBeforeStart ? 'end-date-error' : undefined}
|
||||||
|
class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm
|
||||||
|
{endBeforeStart
|
||||||
|
? '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'}"
|
||||||
/>
|
/>
|
||||||
|
{#if endBeforeStart}
|
||||||
|
<!-- Non-colour cue (WCAG 1.4.1): warning glyph + text, not red alone. -->
|
||||||
|
<p id="end-date-error" class="mt-1 text-xs text-red-600">
|
||||||
|
<span aria-hidden="true">⚠ </span>{m.error_invalid_date_range()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -102,3 +102,49 @@ describe('WhoWhenSection — precision controls', () => {
|
|||||||
expect(raw?.querySelector('b')).toBeNull();
|
expect(raw?.querySelector('b')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('WhoWhenSection — end-before-start inline validation (#678)', () => {
|
||||||
|
it('shows an inline error on the end-date field when end is before start (AC1)', async () => {
|
||||||
|
render(WhoWhenSection, {
|
||||||
|
precision: 'RANGE',
|
||||||
|
dateIso: '1917-01-11',
|
||||||
|
endDateIso: '1917-01-10'
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = document.querySelector('input#metaDateEnd') as HTMLInputElement;
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('#end-date-error')).not.toBeNull();
|
||||||
|
expect(end.getAttribute('aria-invalid')).toBe('true');
|
||||||
|
expect(end.className).toContain('border-red-400');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the inline error once the end date is corrected, without reload (AC5)', async () => {
|
||||||
|
render(WhoWhenSection, {
|
||||||
|
precision: 'RANGE',
|
||||||
|
dateIso: '1917-01-11',
|
||||||
|
endDateIso: '1917-01-10'
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(document.querySelector('#end-date-error')).not.toBeNull());
|
||||||
|
|
||||||
|
const end = document.querySelector('input#metaDateEnd') as HTMLInputElement;
|
||||||
|
end.value = '12.01.1917'; // now after the start
|
||||||
|
end.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.querySelector('#end-date-error')).toBeNull();
|
||||||
|
expect(end.getAttribute('aria-invalid')).not.toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show the inline error when precision is not RANGE', async () => {
|
||||||
|
render(WhoWhenSection, {
|
||||||
|
precision: 'DAY',
|
||||||
|
dateIso: '1917-01-11',
|
||||||
|
endDateIso: '1917-01-10'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.querySelector('#end-date-error')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ErrorCode =
|
|||||||
| 'PERSON_NOT_FOUND'
|
| 'PERSON_NOT_FOUND'
|
||||||
| 'ALIAS_NOT_FOUND'
|
| 'ALIAS_NOT_FOUND'
|
||||||
| 'INVALID_PERSON_TYPE'
|
| 'INVALID_PERSON_TYPE'
|
||||||
|
| 'INVALID_DATE_RANGE'
|
||||||
| 'DOCUMENT_NOT_FOUND'
|
| 'DOCUMENT_NOT_FOUND'
|
||||||
| 'DOCUMENT_NO_FILE'
|
| 'DOCUMENT_NO_FILE'
|
||||||
| 'FILE_NOT_FOUND'
|
| 'FILE_NOT_FOUND'
|
||||||
@@ -87,6 +88,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_alias_not_found();
|
return m.error_alias_not_found();
|
||||||
case 'INVALID_PERSON_TYPE':
|
case 'INVALID_PERSON_TYPE':
|
||||||
return m.error_invalid_person_type();
|
return m.error_invalid_person_type();
|
||||||
|
case 'INVALID_DATE_RANGE':
|
||||||
|
return m.error_invalid_date_range();
|
||||||
case 'DOCUMENT_NOT_FOUND':
|
case 'DOCUMENT_NOT_FOUND':
|
||||||
return m.error_document_not_found();
|
return m.error_document_not_found();
|
||||||
case 'DOCUMENT_NO_FILE':
|
case 'DOCUMENT_NO_FILE':
|
||||||
|
|||||||
Reference in New Issue
Block a user