mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
refactor(records): move the life circumstances field primitives into their own file
The draft state for the typed fields pushed the form past its size baseline. The primitives own only their input state and read nothing from the form, so they move out and the baselines tighten.
This commit is contained in:
1 parent
41f90bac3f
commit
d3401b5b89
4 files changed
+334
-318
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 94309,
|
||||
"total": 94069,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -17,7 +17,7 @@
|
||||
"components/medications/medication-table.tsx": 853,
|
||||
"components/medications/wizard/wizard-payload.ts": 1414,
|
||||
"components/mood/mood-list.tsx": 1408,
|
||||
"components/records/life-circumstances-manager.tsx": 1080,
|
||||
"components/records/life-circumstances-manager.tsx": 828,
|
||||
"components/timeline/timeline-chart.tsx": 1093,
|
||||
"components/timeline/timeline-geometry.ts": 1288,
|
||||
"lib/ai/anthropic-client.ts": 804,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 345,
|
||||
"total": 111894,
|
||||
"total": 111909,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
"app/api/admin/notifications/reminder-check/route.ts :: apiHandler(arg)": 206,
|
||||
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
|
||||
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
|
||||
"app/api/auth/login/route.ts :: apiHandler(arg)": 260,
|
||||
"app/api/auth/me/route.ts :: apiHandler(arg)": 378,
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
|
||||
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
|
||||
"app/api/auth/register/route.ts :: apiHandler(arg)": 266,
|
||||
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
|
||||
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
|
||||
"app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213,
|
||||
@@ -187,7 +187,7 @@
|
||||
"components/records/allergy-form.tsx :: AllergyForm": 225,
|
||||
"components/records/audit-c-panel.tsx :: AuditCPanel": 285,
|
||||
"components/records/free-text-take-over.tsx :: FreeTextTakeOver": 201,
|
||||
"components/records/life-circumstances-manager.tsx :: SectionBody": 482,
|
||||
"components/records/life-circumstances-manager.tsx :: SectionBody": 472,
|
||||
"components/settings/about-section.tsx :: AboutSection": 236,
|
||||
"components/settings/access/grant-invite-card.tsx :: GrantInviteCard": 311,
|
||||
"components/settings/access/managed-profile-create-form.tsx :: ManagedProfileCreateForm": 235,
|
||||
@@ -207,7 +207,7 @@
|
||||
"components/settings/import-panel/health-connect-import-card.tsx :: HealthConnectImportCard": 293,
|
||||
"components/settings/import-panel/record-csv-import-card.tsx :: RecordCsvImportSheet": 458,
|
||||
"components/settings/insights-pill-order-section.tsx :: InsightsPillOrderSection": 255,
|
||||
"components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 230,
|
||||
"components/settings/integrations/connections-panel.tsx :: ConnectionsPanel": 227,
|
||||
"components/settings/integrations/document-sources-card.tsx :: SourceForm": 306,
|
||||
"components/settings/integrations/google-health-card.tsx :: GoogleHealthCard": 300,
|
||||
"components/settings/integrations/integration-card.tsx :: IntegrationCard": 209,
|
||||
@@ -300,7 +300,7 @@
|
||||
"lib/jobs/coach-nudge.ts :: runCoachNudgeTick": 292,
|
||||
"lib/jobs/coach-reminder-sweep.ts :: runCoachReminderSweep": 222,
|
||||
"lib/jobs/cycle-reminder.ts :: runCycleReminderTick": 243,
|
||||
"lib/jobs/document-summary.ts :: runDocumentSummaryJob": 239,
|
||||
"lib/jobs/document-summary.ts :: runDocumentSummaryJob": 238,
|
||||
"lib/jobs/google-health-backfill.ts :: runGoogleHealthBackfillForUser": 276,
|
||||
"lib/jobs/insight-pregenerate.ts :: forceWarmUser": 282,
|
||||
"lib/jobs/insight-pregenerate.ts :: forceWarmUser > withFeatureCacheScope(arg)": 252,
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* The small field primitives the life circumstances form is built from.
|
||||
* Each one owns only its own input state; the form owns the answers.
|
||||
*/
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
export type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
export function Field({
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{/* Block, so the colon follows a label that wraps instead of standing
|
||||
alone at the far edge as a flex item. */}
|
||||
<Label htmlFor={id} className="block leading-snug">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChoiceField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
t,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
options: ReadonlyArray<{ value: string; label: string }>;
|
||||
onChange: (value: string) => void;
|
||||
t: Translate;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Field id={id} label={label}>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">{t("records.life.noAnswer")}</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoolField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: boolean | null;
|
||||
onChange: (value: boolean | null) => void;
|
||||
t: Translate;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ChoiceField
|
||||
id={id}
|
||||
label={label}
|
||||
value={value === null ? "" : value ? "yes" : "no"}
|
||||
options={[
|
||||
{ value: "yes", label: t("records.life.yes") },
|
||||
{ value: "no", label: t("records.life.no") },
|
||||
]}
|
||||
onChange={(next) => onChange(next === "" ? null : next === "yes")}
|
||||
t={t}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NumberField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number | null;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number | null) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
// What the person is typing. A year passes through "1", "19" and "198" on
|
||||
// its way to "1985", so clamping each keystroke turned the first digit into
|
||||
// the minimum and the field could not be typed into. A value is committed
|
||||
// once it is in range, and leaving the field puts the committed one back.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
return (
|
||||
<Field id={id} label={label}>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode={step < 1 ? "decimal" : "numeric"}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={draft ?? (value === null ? "" : String(value))}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
setDraft(raw);
|
||||
if (raw === "") return onChange(null);
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed >= min && parsed <= max) {
|
||||
onChange(parsed);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setDraft(null)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A month typed as YYYY-MM. Only a complete month is committed, so the field
|
||||
* keeps what is being typed on its own: holding it to the committed value
|
||||
* threw away every keystroke until the text was a whole month, and only a
|
||||
* paste got through. Leaving the field pads "2019-3" to "2019-03" and puts
|
||||
* the committed month back over anything that is not one.
|
||||
*/
|
||||
export function QuitMonthInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
placeholder="YYYY-MM"
|
||||
inputMode="numeric"
|
||||
maxLength={7}
|
||||
value={draft ?? value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
setDraft(raw);
|
||||
const month = completeMonth(raw);
|
||||
if (month !== undefined) onChange(month);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (draft !== null) {
|
||||
const short = /^(\d{4})-([1-9])$/.exec(draft.trim());
|
||||
if (short) onChange(`${short[1]}-0${short[2]}`);
|
||||
}
|
||||
setDraft(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A whole month, null for an emptied field, undefined while still typing. */
|
||||
function completeMonth(raw: string): string | null | undefined {
|
||||
const text = raw.trim();
|
||||
if (text === "") return null;
|
||||
return /^\d{4}-(0[1-9]|1[0-2])$/.test(text) ? text : undefined;
|
||||
}
|
||||
|
||||
export function MultiField<T extends string>({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
max,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
options: ReadonlyArray<{ value: T; label: string }>;
|
||||
value: readonly T[];
|
||||
onChange: (value: T[]) => void;
|
||||
max?: number;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="space-y-2" data-slot={id}>
|
||||
<legend className="text-sm font-medium">{label}</legend>
|
||||
{hint ? <p className="text-muted-foreground text-xs">{hint}</p> : null}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
||||
{options.map((option) => {
|
||||
const checked = value.includes(option.value);
|
||||
const full = max !== undefined && value.length >= max && !checked;
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex min-h-11 min-w-0 items-center gap-2 text-sm sm:min-h-9"
|
||||
>
|
||||
<Checkbox
|
||||
className="shrink-0"
|
||||
checked={checked}
|
||||
disabled={disabled || full}
|
||||
onCheckedChange={(next) =>
|
||||
onChange(
|
||||
next === true
|
||||
? [...value, option.value]
|
||||
: value.filter((item) => item !== option.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** Month and year of "valid since", as `YYYY-MM`. */
|
||||
export function MonthField({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
t: Translate;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const fmt = useFormatters();
|
||||
const [year, month] = value.split("-");
|
||||
const thisYear = new Date().getFullYear();
|
||||
const years = Array.from({ length: 80 }, (_, i) => String(thisYear - i));
|
||||
return (
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-sm font-medium">
|
||||
{t("records.life.validSince")}
|
||||
</legend>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<NativeSelect
|
||||
id={`${id}-month`}
|
||||
aria-label={t("records.life.month")}
|
||||
value={month}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(`${year}-${event.target.value}`)}
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => {
|
||||
const m = String(i + 1).padStart(2, "0");
|
||||
return (
|
||||
<option key={m} value={m}>
|
||||
{fmt.monthShort(new Date(Date.UTC(2026, i, 15)))}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</NativeSelect>
|
||||
<NativeSelect
|
||||
id={`${id}-year`}
|
||||
aria-label={t("records.life.year")}
|
||||
value={year}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(`${event.target.value}-${month}`)}
|
||||
>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -19,13 +19,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, Loader2 } from "lucide-react";
|
||||
|
||||
import { AuditCPanel } from "@/components/records/audit-c-panel";
|
||||
import {
|
||||
BoolField,
|
||||
ChoiceField,
|
||||
Field,
|
||||
MonthField,
|
||||
MultiField,
|
||||
NumberField,
|
||||
QuitMonthInput,
|
||||
type Translate,
|
||||
} from "@/components/records/life-circumstances-fields";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
@@ -57,10 +63,6 @@ import {
|
||||
|
||||
type Section = LifeCircumstancesDto["sections"][number];
|
||||
type Fact = Section["facts"][number];
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
const EMPTY = {
|
||||
TOBACCO_DETAIL: {
|
||||
@@ -172,308 +174,6 @@ async function writeKind(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Small field primitives ───────────────────────────────────────────────────
|
||||
|
||||
function Field({
|
||||
id,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{/* Block, so the colon follows a label that wraps instead of standing
|
||||
alone at the far edge as a flex item. */}
|
||||
<Label htmlFor={id} className="block leading-snug">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
t,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
options: ReadonlyArray<{ value: string; label: string }>;
|
||||
onChange: (value: string) => void;
|
||||
t: Translate;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Field id={id} label={label}>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">{t("records.life.noAnswer")}</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function BoolField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: boolean | null;
|
||||
onChange: (value: boolean | null) => void;
|
||||
t: Translate;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ChoiceField
|
||||
id={id}
|
||||
label={label}
|
||||
value={value === null ? "" : value ? "yes" : "no"}
|
||||
options={[
|
||||
{ value: "yes", label: t("records.life.yes") },
|
||||
{ value: "no", label: t("records.life.no") },
|
||||
]}
|
||||
onChange={(next) => onChange(next === "" ? null : next === "yes")}
|
||||
t={t}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number | null;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number | null) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
// What the person is typing. A year passes through "1", "19" and "198" on
|
||||
// its way to "1985", so clamping each keystroke turned the first digit into
|
||||
// the minimum and the field could not be typed into. A value is committed
|
||||
// once it is in range, and leaving the field puts the committed one back.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
return (
|
||||
<Field id={id} label={label}>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode={step < 1 ? "decimal" : "numeric"}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={draft ?? (value === null ? "" : String(value))}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
setDraft(raw);
|
||||
if (raw === "") return onChange(null);
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed >= min && parsed <= max) {
|
||||
onChange(parsed);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setDraft(null)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A month typed as YYYY-MM. Only a complete month is committed, so the field
|
||||
* keeps what is being typed on its own: holding it to the committed value
|
||||
* threw away every keystroke until the text was a whole month, and only a
|
||||
* paste got through. Leaving the field pads "2019-3" to "2019-03" and puts
|
||||
* the committed month back over anything that is not one.
|
||||
*/
|
||||
function QuitMonthInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
value: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
placeholder="YYYY-MM"
|
||||
inputMode="numeric"
|
||||
maxLength={7}
|
||||
value={draft ?? value ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
setDraft(raw);
|
||||
const month = completeMonth(raw);
|
||||
if (month !== undefined) onChange(month);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (draft !== null) {
|
||||
const short = /^(\d{4})-([1-9])$/.exec(draft.trim());
|
||||
if (short) onChange(`${short[1]}-0${short[2]}`);
|
||||
}
|
||||
setDraft(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A whole month, null for an emptied field, undefined while still typing. */
|
||||
function completeMonth(raw: string): string | null | undefined {
|
||||
const text = raw.trim();
|
||||
if (text === "") return null;
|
||||
return /^\d{4}-(0[1-9]|1[0-2])$/.test(text) ? text : undefined;
|
||||
}
|
||||
|
||||
function MultiField<T extends string>({
|
||||
id,
|
||||
label,
|
||||
hint,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
max,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
options: ReadonlyArray<{ value: T; label: string }>;
|
||||
value: readonly T[];
|
||||
onChange: (value: T[]) => void;
|
||||
max?: number;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="space-y-2" data-slot={id}>
|
||||
<legend className="text-sm font-medium">{label}</legend>
|
||||
{hint ? <p className="text-muted-foreground text-xs">{hint}</p> : null}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2">
|
||||
{options.map((option) => {
|
||||
const checked = value.includes(option.value);
|
||||
const full = max !== undefined && value.length >= max && !checked;
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex min-h-11 min-w-0 items-center gap-2 text-sm sm:min-h-9"
|
||||
>
|
||||
<Checkbox
|
||||
className="shrink-0"
|
||||
checked={checked}
|
||||
disabled={disabled || full}
|
||||
onCheckedChange={(next) =>
|
||||
onChange(
|
||||
next === true
|
||||
? [...value, option.value]
|
||||
: value.filter((item) => item !== option.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** Month and year of "valid since", as `YYYY-MM`. */
|
||||
function MonthField({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
t,
|
||||
disabled,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
t: Translate;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const fmt = useFormatters();
|
||||
const [year, month] = value.split("-");
|
||||
const thisYear = new Date().getFullYear();
|
||||
const years = Array.from({ length: 80 }, (_, i) => String(thisYear - i));
|
||||
return (
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-sm font-medium">
|
||||
{t("records.life.validSince")}
|
||||
</legend>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<NativeSelect
|
||||
id={`${id}-month`}
|
||||
aria-label={t("records.life.month")}
|
||||
value={month}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(`${year}-${event.target.value}`)}
|
||||
>
|
||||
{Array.from({ length: 12 }, (_, i) => {
|
||||
const m = String(i + 1).padStart(2, "0");
|
||||
return (
|
||||
<option key={m} value={m}>
|
||||
{fmt.monthShort(new Date(Date.UTC(2026, i, 15)))}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</NativeSelect>
|
||||
<NativeSelect
|
||||
id={`${id}-year`}
|
||||
aria-label={t("records.life.year")}
|
||||
value={year}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(`${event.target.value}-${month}`)}
|
||||
>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={y}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** The kinds a section writes, in the order it writes them. */
|
||||
const SECTION_KINDS: Record<Section["key"], ReadonlyArray<keyof Values>> = {
|
||||
tobacco: ["SMOKING_STATUS", "TOBACCO_DETAIL"],
|
||||
|
||||
Reference in new issue
Block a user