fix(person): block submit while a life-date input is partial

A partial date (e.g. "14.03.") left the hidden ISO input empty, so
saving the edit form silently cleared a stored date. PersonLifeDateField
now delegates to the shared DateInput primitive (inline format error,
calendar validation) and sets a custom validity while the error is
present, so the browser blocks native submission for both person forms.
A full clear stays submittable - that is the intentional clear path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-12 19:31:39 +02:00
committed by marcel
parent 4419c434a1
commit e712477d2b
4 changed files with 107 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { isoToGerman, handleGermanDateInput } from '$lib/shared/utils/date'; import DateInput from '$lib/shared/primitives/DateInput.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate'; import type { DatePrecision } from '$lib/shared/utils/documentDate';
// Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no // Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no
@@ -26,8 +26,9 @@ let {
initialPrecision?: string | null; initialPrecision?: string | null;
} = $props(); } = $props();
let display = $state('');
let iso = $state(''); let iso = $state('');
let errorMessage = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | undefined>();
let precision = $state<DatePrecision>('DAY'); let precision = $state<DatePrecision>('DAY');
// Seed once at mount (WhoWhenSection pattern): a later load() rerun must not // Seed once at mount (WhoWhenSection pattern): a later load() rerun must not
@@ -35,7 +36,6 @@ let precision = $state<DatePrecision>('DAY');
onMount(() => { onMount(() => {
if (initialIso) { if (initialIso) {
iso = initialIso; iso = initialIso;
display = isoToGerman(initialIso);
} }
const offered = PERSON_DATE_PRECISIONS.some((p) => p.value === initialPrecision); const offered = PERSON_DATE_PRECISIONS.some((p) => p.value === initialPrecision);
if (offered) { if (offered) {
@@ -47,11 +47,11 @@ onMount(() => {
} }
}); });
function handleInput(e: Event) { // A partial date leaves the hidden ISO empty — submitting then would silently
const result = handleGermanDateInput(e); // clear a stored date. Block native submission until completed or fully emptied.
display = result.display; $effect(() => {
iso = result.iso; inputEl?.setCustomValidity(errorMessage ?? '');
} });
const controlCls = const controlCls =
'block min-h-[44px] w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'; 'block min-h-[44px] w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring';
@@ -63,18 +63,20 @@ const controlCls =
</legend> </legend>
<div class="flex flex-col gap-2 sm:flex-row"> <div class="flex flex-col gap-2 sm:flex-row">
<div class="flex-1"> <div class="flex-1">
<input <DateInput
bind:value={iso}
bind:errorMessage={errorMessage}
bind:inputEl={inputEl}
name={name}
id={name} id={name}
type="text"
inputmode="numeric"
placeholder="TT.MM.JJJJ" placeholder="TT.MM.JJJJ"
maxlength="10" ariaLabel={legend}
aria-label={legend} ariaDescribedby={errorMessage ? `${name}-error` : undefined}
value={display}
oninput={handleInput}
class={controlCls} class={controlCls}
/> />
<input type="hidden" name={name} value={iso} /> {#if errorMessage}
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
{/if}
</div> </div>
<div class="flex-1"> <div class="flex-1">
<select <select

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import PersonLifeDateField from './PersonLifeDateField.svelte';
import { m } from '$lib/paraglide/messages.js';
afterEach(cleanup);
const baseProps = {
name: 'birthDate',
legend: 'Geburtsdatum',
precisionLabel: 'Genauigkeit'
};
const visibleInput = () => page.getByLabelText('Geburtsdatum', { exact: true });
const hiddenIso = () => document.querySelector<HTMLInputElement>('input[name="birthDate"]');
describe('PersonLifeDateField', () => {
it('shows the format error and flags the input invalid for a partial date', async () => {
render(PersonLifeDateField, { props: baseProps });
await userEvent.fill(visibleInput(), '14.03.');
await expect.element(page.getByText(m.form_date_error())).toBeVisible();
const input = (await visibleInput().element()) as HTMLInputElement;
expect(input.checkValidity()).toBe(false);
expect(hiddenIso()?.value).toBe('');
});
it('clears error and custom validity when the input is fully emptied', async () => {
render(PersonLifeDateField, { props: { ...baseProps, initialIso: '1899-03-14' } });
await userEvent.fill(visibleInput(), '');
await expect.element(page.getByText(m.form_date_error())).not.toBeInTheDocument();
const input = (await visibleInput().element()) as HTMLInputElement;
expect(input.checkValidity()).toBe(true);
// Empty hidden ISO is the intentional clear path — the server action omits the pair.
expect(hiddenIso()?.value).toBe('');
});
it('keeps a stored date submittable when untouched', async () => {
render(PersonLifeDateField, {
props: { ...baseProps, initialIso: '1899-03-14', initialPrecision: 'DAY' }
});
await expect.element(visibleInput()).toHaveValue('14.03.1899');
const input = (await visibleInput().element()) as HTMLInputElement;
expect(input.checkValidity()).toBe(true);
expect(hiddenIso()?.value).toBe('1899-03-14');
});
it('becomes valid again once the partial date is completed', async () => {
render(PersonLifeDateField, { props: baseProps });
await userEvent.fill(visibleInput(), '14.03.');
await userEvent.fill(visibleInput(), '14.03.1899');
await expect.element(page.getByText(m.form_date_error())).not.toBeInTheDocument();
const input = (await visibleInput().element()) as HTMLInputElement;
expect(input.checkValidity()).toBe(true);
expect(hiddenIso()?.value).toBe('1899-03-14');
});
});

View File

@@ -11,6 +11,11 @@ interface Props {
placeholder?: string; placeholder?: string;
class?: string; class?: string;
onchange?: () => void; onchange?: () => void;
ariaLabel?: string;
ariaDescribedby?: string;
// Escape hatch for callers that need the raw element, e.g. to set a custom
// validity and block native form submission while the date is partial.
inputEl?: HTMLInputElement;
} }
let { let {
@@ -20,7 +25,10 @@ let {
id, id,
placeholder, placeholder,
class: className = '', class: className = '',
onchange onchange,
ariaLabel,
ariaDescribedby,
inputEl = $bindable()
}: Props = $props(); }: Props = $props();
let display = $state(isoToGerman(value ?? '')); let display = $state(isoToGerman(value ?? ''));
@@ -76,10 +84,13 @@ function handleInput(e: Event) {
</script> </script>
<input <input
bind:this={inputEl}
type="text" type="text"
inputmode="numeric" inputmode="numeric"
maxlength="10" maxlength="10"
id={id} id={id}
aria-label={ariaLabel}
aria-describedby={ariaDescribedby}
value={display} value={display}
placeholder={placeholder ?? m.form_placeholder_date()} placeholder={placeholder ?? m.form_placeholder_date()}
oninput={handleInput} oninput={handleInput}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, afterEach } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import PersonEditForm from './PersonEditForm.svelte'; import PersonEditForm from './PersonEditForm.svelte';
afterEach(cleanup); afterEach(cleanup);
@@ -219,4 +219,16 @@ describe('PersonEditForm', () => {
const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement; const select = (await page.getByLabelText(/^generation$/i).element()) as HTMLSelectElement;
expect(select.value).toBe(''); expect(select.value).toBe('');
}); });
// ─── partial-date guard (#812 review) ────────────────────────────────────────
it('blocks submission while a stored birth date is partially edited (no silent clear)', async () => {
render(PersonEditForm, { props: { person: personPersonal } });
await userEvent.fill(page.getByLabelText(/^geburtsdatum$/i), '14.03.');
const birthInput = (await page.getByLabelText(/^geburtsdatum$/i).element()) as HTMLInputElement;
expect(birthInput.checkValidity()).toBe(false);
await expect.element(page.getByText(/Bitte im Format TT\.MM\.JJJJ/)).toBeVisible();
});
}); });