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
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:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
106
frontend/src/lib/shared/primitives/DateInputWithPrecision.svelte
Normal file
106
frontend/src/lib/shared/primitives/DateInputWithPrecision.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user