refactor(frontend): share DateInputWithPrecision between life-date and relationship fields
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m29s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m22s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 28s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
SDD Gate / RTM Check (pull_request) Successful in 14s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 17s

PersonLifeDateField and RelationshipDateField were the same DateInput + restricted
precision <select>: identical onMount seeding (incl. the YEAR fallback for stored
non-offered precisions), the setCustomValidity partial-date guard, and markup.
Extract that into a domain-agnostic DateInputWithPrecision primitive (caller injects
the precisions, labels, hint, and styling deltas); both fields become thin wrappers
that keep their existing public props, so the person new/edit pages and the Stammbaum
call sites are unchanged. Named to stay distinct from the full DatePrecisionField
(documents/timeline, all seven precisions + RANGE). The relationship select drops its
redundant sr-only label, keeping the equivalent aria-label. PersonLifeDateField,
AddRelationshipForm and RelationshipChip specs (26) stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 20:28:40 +02:00
parent 063d1aac55
commit fe1a3dcc00
3 changed files with 140 additions and 152 deletions

View File

@@ -1,17 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import DateInput from '$lib/shared/primitives/DateInput.svelte';
import DateInputWithPrecision from '$lib/shared/primitives/DateInputWithPrecision.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
// Only DAY / MONTH / YEAR are offered for life dates: RANGE and SEASON make no
// sense for a birth or death, and APPROX stays display-only for legacy imports (#773).
const PERSON_DATE_PRECISIONS: { value: DatePrecision; label: () => string }[] = [
{ value: 'DAY', label: m.person_precision_day },
{ value: 'MONTH', label: m.person_precision_month },
{ value: 'YEAR', label: m.person_precision_year }
];
let {
name,
legend,
@@ -26,73 +19,21 @@ let {
initialPrecision?: string | null;
} = $props();
let iso = $state('');
let errorMessage = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | undefined>();
let precision = $state<DatePrecision>('DAY');
// Seed once at mount (WhoWhenSection pattern): a later load() rerun must not
// stomp the user's in-progress edit.
onMount(() => {
if (initialIso) {
iso = initialIso;
}
const offered = PERSON_DATE_PRECISIONS.some((p) => p.value === initialPrecision);
if (offered) {
precision = initialPrecision as DatePrecision;
} else if (initialIso) {
// Legacy APPROX/SEASON/RANGE precision is not editable here — seed YEAR so an
// untouched save does not silently claim DAY precision for the stored date.
precision = 'YEAR';
}
});
// A partial date leaves the hidden ISO empty — submitting then would silently
// clear a stored date. Block native submission until completed or fully emptied.
$effect(() => {
inputEl?.setCustomValidity(errorMessage ?? '');
});
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';
const precisions: { value: DatePrecision; label: string }[] = $derived([
{ value: 'DAY', label: m.person_precision_day() },
{ value: 'MONTH', label: m.person_precision_month() },
{ value: 'YEAR', label: m.person_precision_year() }
]);
const hint = $derived(`${m.person_precision_hint()} · ${m.person_date_placeholder_hint()}`);
</script>
<fieldset>
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{legend}
</legend>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex-1">
<DateInput
bind:value={iso}
bind:errorMessage={errorMessage}
bind:inputEl={inputEl}
name={name}
id={name}
placeholder="TT.MM.JJJJ"
ariaLabel={legend}
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
class={controlCls}
/>
{#if errorMessage}
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
{/if}
</div>
<div class="flex-1">
<select
id="{name}Precision"
name="{name}Precision"
aria-label="{legend}: {precisionLabel}"
bind:value={precision}
class="{controlCls} bg-surface"
>
{#each PERSON_DATE_PRECISIONS as p (p.value)}
<option value={p.value}>{p.label()}</option>
{/each}
</select>
</div>
</div>
<p class="mt-1 font-sans text-xs text-ink-3">
{m.person_precision_hint()} · {m.person_date_placeholder_hint()}
</p>
</fieldset>
<DateInputWithPrecision
name={name}
legend={legend}
precisionLabel={precisionLabel}
precisions={precisions}
hint={hint}
initialIso={initialIso}
initialPrecision={initialPrecision}
selectClass="bg-surface"
/>

View File

@@ -1,18 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import DateInput from '$lib/shared/primitives/DateInput.svelte';
import DateInputWithPrecision from '$lib/shared/primitives/DateInputWithPrecision.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
// Only DAY / MONTH / YEAR are offered (same as the person life-date field and the
// 60+ author audience). Storage still accepts all seven precisions; SEASON/RANGE/
// APPROX render correctly elsewhere but make no sense to enter for a relationship.
const RELATIONSHIP_DATE_PRECISIONS: { value: DatePrecision; label: () => string }[] = [
{ value: 'DAY', label: m.relation_precision_day },
{ value: 'MONTH', label: m.relation_precision_month },
{ value: 'YEAR', label: m.relation_precision_year }
];
let {
name,
legend,
@@ -25,73 +18,21 @@ let {
initialPrecision?: string | null;
} = $props();
let iso = $state('');
let errorMessage = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | undefined>();
let precision = $state<DatePrecision>('DAY');
// Seed once at mount so a later load() rerun does not stomp an in-progress edit.
onMount(() => {
if (initialIso) {
iso = initialIso;
}
const offered = RELATIONSHIP_DATE_PRECISIONS.some((p) => p.value === initialPrecision);
if (offered) {
precision = initialPrecision as DatePrecision;
} else if (initialIso) {
// A stored non-offered precision (SEASON/RANGE/APPROX) seeds as YEAR so an
// untouched save does not silently claim DAY precision for the stored date.
precision = 'YEAR';
}
});
// A partial date leaves the hidden ISO empty — block native submission until the
// date is completed or fully emptied, so a save can never silently clear a date.
$effect(() => {
inputEl?.setCustomValidity(errorMessage ?? '');
});
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';
const precisions: { value: DatePrecision; label: string }[] = $derived([
{ value: 'DAY', label: m.relation_precision_day() },
{ value: 'MONTH', label: m.relation_precision_month() },
{ value: 'YEAR', label: m.relation_precision_year() }
]);
</script>
<fieldset>
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{legend}
</legend>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex-1">
<DateInput
bind:value={iso}
bind:errorMessage={errorMessage}
bind:inputEl={inputEl}
name={name}
id={name}
placeholder="TT.MM.JJJJ"
ariaLabel={legend}
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
class="{controlCls} bg-surface"
/>
{#if errorMessage}
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
{/if}
</div>
<div class="flex-1">
<label class="sr-only" for="{name}Precision"
>{legend}: {m.relation_label_date_precision()}</label
>
<select
id="{name}Precision"
name="{name}Precision"
aria-label="{legend}: {m.relation_label_date_precision()}"
bind:value={precision}
class="{controlCls} bg-surface text-ink-3"
>
{#each RELATIONSHIP_DATE_PRECISIONS as p (p.value)}
<option value={p.value}>{p.label()}</option>
{/each}
</select>
</div>
</div>
<p class="mt-1 font-sans text-xs text-ink-3">{m.relation_date_placeholder_hint()}</p>
</fieldset>
<DateInputWithPrecision
name={name}
legend={legend}
precisionLabel={m.relation_label_date_precision()}
precisions={precisions}
hint={m.relation_date_placeholder_hint()}
initialIso={initialIso}
initialPrecision={initialPrecision}
inputClass="bg-surface"
selectClass="bg-surface text-ink-3"
/>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from 'svelte';
import DateInput from '$lib/shared/primitives/DateInput.svelte';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
/**
* Compact date + precision field: the {@link DateInput} primitive paired with a
* precision <select> offering a caller-chosen subset of precisions. Shared base of
* PersonLifeDateField (birth/death) and RelationshipDateField (from/to).
*
* Distinct from {@link DatePrecisionField} — that one is the full document/timeline
* field (all seven precisions, German free-text entry, RANGE end-date disclosure).
* This one is the restricted, single-input variant for the person-family forms.
*
* All copy (legend, precision labels, hint, the select's accessible name) and the
* offered precisions are injected by the caller so this stays domain-agnostic.
*/
let {
name,
legend,
precisionLabel,
hint,
precisions,
initialIso = '',
initialPrecision = null,
inputClass = '',
selectClass = ''
}: {
name: string;
legend: string;
precisionLabel: string;
hint: string;
precisions: { value: DatePrecision; label: string }[];
initialIso?: string | null;
initialPrecision?: string | null;
inputClass?: string;
selectClass?: string;
} = $props();
let iso = $state('');
let errorMessage = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | undefined>();
let precision = $state<DatePrecision>('DAY');
// Seed once at mount so a later load() rerun does not stomp an in-progress edit.
onMount(() => {
if (initialIso) {
iso = initialIso;
}
const offered = precisions.some((p) => p.value === initialPrecision);
if (offered) {
precision = initialPrecision as DatePrecision;
} else if (initialIso) {
// A stored non-offered precision (SEASON/RANGE/APPROX) seeds as YEAR so an
// untouched save does not silently claim DAY precision for the stored date.
precision = 'YEAR';
}
});
// A partial date leaves the hidden ISO empty — block native submission until the
// date is completed or fully emptied, so a save can never silently clear a date.
$effect(() => {
inputEl?.setCustomValidity(errorMessage ?? '');
});
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';
</script>
<fieldset>
<legend class="mb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase">
{legend}
</legend>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex-1">
<DateInput
bind:value={iso}
bind:errorMessage={errorMessage}
bind:inputEl={inputEl}
name={name}
id={name}
placeholder="TT.MM.JJJJ"
ariaLabel={legend}
ariaDescribedby={errorMessage ? `${name}-error` : undefined}
class="{controlCls} {inputClass}"
/>
{#if errorMessage}
<p id="{name}-error" class="mt-1 font-sans text-xs text-red-600">{errorMessage}</p>
{/if}
</div>
<div class="flex-1">
<select
id="{name}Precision"
name="{name}Precision"
aria-label="{legend}: {precisionLabel}"
bind:value={precision}
class="{controlCls} {selectClass}"
>
{#each precisions as p (p.value)}
<option value={p.value}>{p.label}</option>
{/each}
</select>
</div>
</div>
<p class="mt-1 font-sans text-xs text-ink-3">{hint}</p>
</fieldset>