@ogygie/ui 0.0.0-stage → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @intent The ONE component that decides what a screen shows before its body
3
+ * (D39): access denied, then error, then loading, then empty — in that order,
4
+ * because a denial must never read as a failure to load, and a failure must
5
+ * never read as "still loading". It receives a `QueryLike` (the subset of a
6
+ * TanStack Query result), so a container passes its hook's result as it is
7
+ * and a test passes a fixture. The body is a render function that receives
8
+ * the data ONCE it is there: a body never branches on `undefined`.
9
+ *
10
+ * Every text is a prop (the product translates); the loading state has none
11
+ * — a skeleton, `aria-busy`, no sentence to translate.
12
+ */
13
+ import type { ReactNode } from "react";
14
+ import { EmptyState } from "./EmptyState.js";
15
+ import { ErrorBanner } from "./ErrorBanner.js";
16
+ import { isAccessDenied } from "./failures.js";
17
+ import type { QueryLike } from "./query-like.js";
18
+
19
+ /** What an explanatory state says: the same three slots as EmptyState. */
20
+ export interface StateCopy {
21
+ title: ReactNode;
22
+ explanation: ReactNode;
23
+ action?: ReactNode;
24
+ }
25
+
26
+ export interface ScreenStateProps<TData> {
27
+ query: QueryLike<TData>;
28
+ /** The body, rendered once the data is served and not empty. */
29
+ children: (data: TData) => ReactNode;
30
+ /** The caller's own verdict (the viewer's permissions) — shown before any request is judged. */
31
+ denied?: boolean;
32
+ /** What a denied caller reads, also used when the read fails with 401 or 403. Absent, a 401/403 shows as an ErrorBanner. */
33
+ accessDenied?: StateCopy;
34
+ /** When the served data is an empty collection — the caller says what "empty" means for its shape. */
35
+ isEmpty?: (data: TData) => boolean;
36
+ /** What an empty screen says, with its one creation action. Absent, the body renders the empty data. */
37
+ emptyState?: StateCopy;
38
+ /** The loading surface. Absent: `Skeleton`. */
39
+ loading?: ReactNode;
40
+ /** The sentence above a failure, worded by the product. */
41
+ errorTitle?: ReactNode;
42
+ retry?: { label: ReactNode; onClick: () => void };
43
+ }
44
+
45
+ /** Pulsing bars, `role="status"`: the loading state has no sentence to translate. */
46
+ export function Skeleton({ rows = 3 }: { rows?: number }) {
47
+ return (
48
+ <div role="status" aria-busy="true" data-pattern="Skeleton" className="animate-pulse space-y-3">
49
+ {Array.from({ length: rows }, (_, index) => (
50
+ <div key={index} className={index === 0 ? "h-6 w-1/3 rounded-sm bg-border" : "h-4 rounded-sm bg-border"} />
51
+ ))}
52
+ </div>
53
+ );
54
+ }
55
+
56
+ export function ScreenState<TData>(props: ScreenStateProps<TData>) {
57
+ const { query } = props;
58
+ const deniedCopy = props.accessDenied;
59
+
60
+ if (props.denied === true) {
61
+ return (
62
+ <div data-pattern="ScreenState" data-state="denied">
63
+ {deniedCopy === undefined ? null : <EmptyState {...deniedCopy} />}
64
+ </div>
65
+ );
66
+ }
67
+ if (query.error != null) {
68
+ if (deniedCopy !== undefined && isAccessDenied(query.error)) {
69
+ return (
70
+ <div data-pattern="ScreenState" data-state="denied">
71
+ <EmptyState {...deniedCopy} />
72
+ </div>
73
+ );
74
+ }
75
+ return (
76
+ <div data-pattern="ScreenState" data-state="error">
77
+ <ErrorBanner
78
+ error={query.error}
79
+ {...(props.errorTitle === undefined ? {} : { title: props.errorTitle })}
80
+ {...(props.retry === undefined ? {} : { retry: props.retry })}
81
+ />
82
+ </div>
83
+ );
84
+ }
85
+ if (query.data === undefined) {
86
+ return (
87
+ <div data-pattern="ScreenState" data-state="loading">
88
+ {props.loading ?? <Skeleton />}
89
+ </div>
90
+ );
91
+ }
92
+ if (props.emptyState !== undefined && props.isEmpty?.(query.data) === true) {
93
+ return (
94
+ <div data-pattern="ScreenState" data-state="empty">
95
+ <EmptyState {...props.emptyState} />
96
+ </div>
97
+ );
98
+ }
99
+ return <>{props.children(query.data)}</>;
100
+ }
101
+
102
+ ScreenState.Skeleton = Skeleton;
@@ -0,0 +1,45 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * @intent Proves the variance chart (spec ogygie-ui B8): two named series per
4
+ * label in an SVG whose colours are the product's tokens, the empty state
5
+ * delegated to the caller, a skeleton for the loading state.
6
+ */
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+ import { cleanup, render, screen } from "@testing-library/react";
9
+ import { VarianceChart, VarianceChartSkeleton } from "./VarianceChart.js";
10
+
11
+ const ROWS = [
12
+ { label: "A1", measured: 3.2, reference: 2.8 },
13
+ { label: "A2", measured: 2.1, reference: 2.8 },
14
+ { label: "B1", measured: null, reference: 3 },
15
+ ];
16
+
17
+ describe("VarianceChart", () => {
18
+ afterEach(cleanup);
19
+
20
+ it("ÉTANT DONNÉ des lignes et une largeur fixe ALORS un SVG, une barre par série et la légende nommée par le produit", () => {
21
+ const { container } = render(
22
+ <VarianceChart rows={ROWS} measuredName="Measured" referenceName="Reference" varianceName="Variance" unit=" pts" width={640} height={240} ariaLabel="Variance by criterion" />,
23
+ );
24
+ const figure = container.querySelector("figure[data-pattern='VarianceChart']") as HTMLElement;
25
+ expect((figure).getAttribute("aria-label")).toBe("Variance by criterion");
26
+ expect(container.querySelector("svg")).not.toBeNull();
27
+ expect(container.textContent).toContain("Measured");
28
+ expect(container.textContent).toContain("Reference");
29
+ const fills = Array.from(container.querySelectorAll("path, rect")).map((node) => node.getAttribute("fill"));
30
+ expect(fills).toContain("var(--color-accent)");
31
+ expect(fills).toContain("var(--color-text-muted)");
32
+ });
33
+
34
+ it("ÉTANT DONNÉ aucune ligne et un emptyState ALORS lui seul se rend", () => {
35
+ const { container } = render(<VarianceChart rows={[]} measuredName="Measured" referenceName="Reference" varianceName="Variance" emptyState={<p>No target yet</p>} />);
36
+ expect(screen.getByText("No target yet")).toBeDefined();
37
+ expect(container.querySelector("svg")).toBeNull();
38
+ });
39
+
40
+ it("le squelette est un status occupé", () => {
41
+ render(<VarianceChartSkeleton />);
42
+ expect((screen.getByRole("status")).getAttribute("aria-busy")).toBe("true");
43
+ expect(VarianceChart.Skeleton).toBe(VarianceChartSkeleton);
44
+ });
45
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @intent The variance chart of the starter (D39): a measured value against
3
+ * its reference, per label, as two grouped bars — the variance (measured minus
4
+ * reference) read in the tooltip with its sign — and a zero line when the
5
+ * values cross it. Colours come from the product's tokens through CSS
6
+ * variables (`--color-accent`, `--color-text-muted`…), never from a palette of
7
+ * this file; units and series names are strings the product translates.
8
+ * Loading and error are the screen's business (ScreenState); the empty state
9
+ * is delegated to the caller. Recharts is an OPTIONAL peer carried by this
10
+ * entry point only (`@ogygie/ui/variance-chart`).
11
+ */
12
+ import type { ReactNode } from "react";
13
+ import { Bar, BarChart, CartesianGrid, Legend, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
14
+
15
+ export interface VarianceRow {
16
+ label: string;
17
+ measured: number | null;
18
+ reference: number | null;
19
+ }
20
+
21
+ export interface VarianceChartProps {
22
+ rows: ReadonlyArray<VarianceRow>;
23
+ /** Series names, as the legend and the tooltip show them — translated strings. */
24
+ measuredName: string;
25
+ referenceName: string;
26
+ varianceName: string;
27
+ /** Appended to every figure (`%`, ` pts`, ` €`). */
28
+ unit?: string;
29
+ /** How a figure reads. Default: up to two decimals, in the runtime's locale. */
30
+ format?: (value: number) => string;
31
+ /** The value axis bounds. Default: Recharts' automatic domain. */
32
+ domain?: [number, number];
33
+ height?: number;
34
+ /** A fixed width renders without a ResponsiveContainer — tests and print. Absent, the chart fills its container. */
35
+ width?: number;
36
+ /** The accessible name of the figure, translated. */
37
+ ariaLabel?: string;
38
+ /** Rendered INSTEAD of the chart when there is no row. */
39
+ emptyState?: ReactNode;
40
+ }
41
+
42
+ const defaultFormat = (value: number): string => value.toLocaleString(undefined, { maximumFractionDigits: 2 });
43
+
44
+ interface TooltipEntry {
45
+ payload?: unknown;
46
+ }
47
+
48
+ function VarianceTooltip(props: {
49
+ active?: boolean | undefined;
50
+ payload?: ReadonlyArray<TooltipEntry> | undefined;
51
+ names: { measured: string; reference: string; variance: string };
52
+ unit: string;
53
+ format: (value: number) => string;
54
+ }) {
55
+ const row = props.payload?.[0]?.payload as VarianceRow | undefined;
56
+ if (props.active !== true || row === undefined) return null;
57
+ const variance = row.measured !== null && row.reference !== null ? row.measured - row.reference : null;
58
+ const figure = (value: number | null): string => (value === null ? "—" : `${props.format(value)}${props.unit}`);
59
+ return (
60
+ <div className="rounded-sm border border-border bg-surface-raised px-3 py-2 text-xs text-text shadow-raised">
61
+ <p className="font-medium">{row.label}</p>
62
+ <p className="tabular-nums">
63
+ {props.names.measured}: {figure(row.measured)}
64
+ </p>
65
+ <p className="tabular-nums text-text-muted">
66
+ {props.names.reference}: {figure(row.reference)}
67
+ </p>
68
+ <p className={`tabular-nums ${variance === null ? "" : variance < 0 ? "text-danger" : "text-positive"}`}>
69
+ {props.names.variance}: {variance === null ? "—" : `${variance > 0 ? "+" : ""}${props.format(variance)}${props.unit}`}
70
+ </p>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ export function VarianceChart(props: VarianceChartProps) {
76
+ const { rows, height = 240, unit = "", format = defaultFormat } = props;
77
+ if (rows.length === 0 && props.emptyState !== undefined) return <>{props.emptyState}</>;
78
+ const names = { measured: props.measuredName, reference: props.referenceName, variance: props.varianceName };
79
+ const crossesZero = rows.some((row) => (row.measured ?? 0) < 0 || (row.reference ?? 0) < 0);
80
+ const chart = (
81
+ <BarChart
82
+ data={rows.map((row) => ({ ...row }))}
83
+ margin={{ top: 8, right: 8, bottom: 8, left: 8 }}
84
+ {...(props.width === undefined ? {} : { width: props.width, height })}
85
+ >
86
+ <CartesianGrid vertical={false} stroke="var(--color-border)" />
87
+ <XAxis dataKey="label" tick={{ fill: "var(--color-text-muted)", fontSize: 12 }} stroke="var(--color-border)" />
88
+ <YAxis
89
+ tickFormatter={(value: number) => `${format(value)}${unit}`}
90
+ tick={{ fill: "var(--color-text-muted)", fontSize: 12 }}
91
+ stroke="var(--color-border)"
92
+ width={56}
93
+ {...(props.domain === undefined ? {} : { domain: props.domain })}
94
+ />
95
+ <Tooltip
96
+ cursor={{ fill: "var(--color-surface)" }}
97
+ content={(tooltipProps) => (
98
+ <VarianceTooltip
99
+ active={tooltipProps.active}
100
+ payload={tooltipProps.payload as ReadonlyArray<TooltipEntry> | undefined}
101
+ names={names}
102
+ unit={unit}
103
+ format={format}
104
+ />
105
+ )}
106
+ />
107
+ <Legend wrapperStyle={{ fontSize: 12, color: "var(--color-text-muted)" }} />
108
+ {crossesZero && <ReferenceLine y={0} stroke="var(--color-text-muted)" />}
109
+ <Bar dataKey="measured" name={props.measuredName} fill="var(--color-accent)" radius={2} isAnimationActive={false} />
110
+ <Bar dataKey="reference" name={props.referenceName} fill="var(--color-text-muted)" radius={2} isAnimationActive={false} />
111
+ </BarChart>
112
+ );
113
+ return (
114
+ <figure
115
+ data-pattern="VarianceChart"
116
+ {...(props.ariaLabel === undefined ? {} : { "aria-label": props.ariaLabel })}
117
+ className="m-0 text-sm text-text"
118
+ style={{ height }}
119
+ >
120
+ {props.width === undefined ? (
121
+ <ResponsiveContainer width="100%" height={height}>
122
+ {chart}
123
+ </ResponsiveContainer>
124
+ ) : (
125
+ chart
126
+ )}
127
+ </figure>
128
+ );
129
+ }
130
+
131
+ /** The loading state of a chart: pulsing bars of uneven height, `role="status"`. */
132
+ export function VarianceChartSkeleton({ height = 240 }: { height?: number }) {
133
+ return (
134
+ <div
135
+ role="status"
136
+ aria-busy="true"
137
+ data-pattern="VarianceChart.Skeleton"
138
+ className="flex animate-pulse items-end gap-3 rounded-md border border-border bg-surface-raised px-6 pb-6 pt-10"
139
+ style={{ height }}
140
+ >
141
+ {[0.6, 0.9, 0.4, 0.75, 0.55].map((ratio, index) => (
142
+ <div key={index} className="w-full rounded-sm bg-border" style={{ height: `${Math.round(ratio * 100)}%` }} />
143
+ ))}
144
+ </div>
145
+ );
146
+ }
147
+
148
+ VarianceChart.Skeleton = VarianceChartSkeleton;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @intent Proves the field derivation of ContractForm (spec ogygie-ui B6):
3
+ * each Zod kind yields its control, bounds and options; optional, default and
4
+ * nullable wrappers are read through; a non-object schema is refused by name;
5
+ * the controls' raw values come back in the contract's types.
6
+ */
7
+ import { describe, expect, it } from "vitest";
8
+ import { z } from "zod";
9
+ import { OgygieFailure } from "@ogygie/core/errors";
10
+ import { coerceFormValues, formFieldsOf } from "./contract-fields.js";
11
+
12
+ const Schema = z
13
+ .object({
14
+ label: z.string().trim().min(1).max(80),
15
+ note: z.string().max(2000).optional(),
16
+ code: z.string().regex(/^[a-z-]+$/).max(32).meta({ title: "Code", description: "A slug." }),
17
+ email: z.email(),
18
+ site: z.url().optional(),
19
+ effectiveDate: z.iso.date(),
20
+ recordedAt: z.iso.datetime().nullable(),
21
+ level: z.number().int().min(1).max(4).nullable(),
22
+ weight: z.number().positive().default(1),
23
+ active: z.boolean(),
24
+ tier: z.enum(["A", "B"]),
25
+ kind: z.literal("standard").optional(),
26
+ tags: z.array(z.string()).optional(),
27
+ })
28
+ .refine((body) => body.label !== "forbidden");
29
+
30
+ describe("formFieldsOf", () => {
31
+ const fields = formFieldsOf(Schema);
32
+ const field = (name: string) => {
33
+ const found = fields.find((candidate) => candidate.name === name);
34
+ if (found === undefined) throw new Error(`no field ${name}`);
35
+ return found;
36
+ };
37
+
38
+ it("garde l'ordre de la forme et lit à travers le refine", () => {
39
+ expect(fields.map((candidate) => candidate.name)).toEqual([
40
+ "label", "note", "code", "email", "site", "effectiveDate", "recordedAt", "level", "weight", "active", "tier", "kind", "tags",
41
+ ]);
42
+ });
43
+
44
+ it("une chaîne bornée est un text avec ses bornes ; une longue est un textarea", () => {
45
+ expect(field("label")).toMatchObject({ kind: "text", required: true, minLength: 1, maxLength: 80, nullable: false });
46
+ expect(field("note")).toMatchObject({ kind: "textarea", required: false, maxLength: 2000 });
47
+ });
48
+
49
+ it("un regex donne le pattern, et le meta donne le titre et la description", () => {
50
+ expect(field("code")).toMatchObject({ kind: "text", pattern: "^[a-z-]+$", maxLength: 32, title: "Code", description: "A slug." });
51
+ });
52
+
53
+ it("les formats de chaîne choisissent leur contrôle", () => {
54
+ expect(field("email").kind).toBe("email");
55
+ expect(field("site")).toMatchObject({ kind: "url", required: false });
56
+ expect(field("effectiveDate").kind).toBe("date");
57
+ expect(field("recordedAt")).toMatchObject({ kind: "datetime", nullable: true, required: true });
58
+ });
59
+
60
+ it("un entier borné et nullable, un nombre avec défaut", () => {
61
+ expect(field("level")).toMatchObject({ kind: "integer", minimum: 1, maximum: 4, nullable: true });
62
+ expect(field("weight")).toMatchObject({ kind: "number", required: false, defaultValue: 1 });
63
+ expect(field("weight").minimum).toBeGreaterThan(0);
64
+ });
65
+
66
+ it("booléen, enum et littéral", () => {
67
+ expect(field("active").kind).toBe("checkbox");
68
+ expect(field("tier")).toMatchObject({ kind: "select", options: [{ value: "A" }, { value: "B" }] });
69
+ expect(field("kind")).toMatchObject({ kind: "select", required: false, options: [{ value: "standard" }] });
70
+ });
71
+
72
+ it("une forme que le formulaire ne sait pas rendre est nommée, jamais tue", () => {
73
+ expect(field("tags")).toMatchObject({ kind: "unsupported", typeName: "array" });
74
+ });
75
+
76
+ it("un schéma qui n'est pas un objet est refusé par son nom", () => {
77
+ expect(() => formFieldsOf(z.string())).toThrowError(OgygieFailure);
78
+ try {
79
+ formFieldsOf(z.array(z.string()));
80
+ } catch (thrown) {
81
+ expect((thrown as OgygieFailure).error.code).toBe("CONTRACT_FORM_NOT_AN_OBJECT");
82
+ expect((thrown as OgygieFailure).error.cause).toContain("array");
83
+ }
84
+ });
85
+ });
86
+
87
+ describe("coerceFormValues", () => {
88
+ const fields = formFieldsOf(Schema);
89
+
90
+ it("un contrôle vide devient undefined pour un optionnel, null pour un nullable, reste vide pour un requis", () => {
91
+ const values = coerceFormValues(fields, { label: "", note: "", site: "", recordedAt: "", level: Number.NaN, weight: Number.NaN, tier: "", kind: "", active: false });
92
+ expect(values).toEqual({ label: "", code: "", email: "", effectiveDate: "", recordedAt: null, level: null, active: false });
93
+ });
94
+
95
+ it("les nombres reviennent en nombres, les options gardent leur type, la case à cocher un booléen", () => {
96
+ const numeric = formFieldsOf(z.object({ level: z.number(), rank: z.enum({ one: 1, two: 2 }), flag: z.boolean() }));
97
+ expect(coerceFormValues(numeric, { level: "3", rank: "2", flag: "on" })).toEqual({ level: 3, rank: 2, flag: true });
98
+ expect(coerceFormValues(numeric, { level: 3, rank: "9", flag: true })).toEqual({ level: 3, rank: "9", flag: true });
99
+ });
100
+ });
@@ -0,0 +1,274 @@
1
+ /**
2
+ * @intent What a FORM needs to know about each field of a Zod object contract
3
+ * (D39, pattern ContractForm): its control kind, whether it is required, its
4
+ * bounds and its options — read from the schema's public core definition
5
+ * (`schema._zod.def`, the `zod/v4/core` contract library authors build on),
6
+ * never redeclared by hand. The schema itself stays the validator: these
7
+ * descriptors only choose the control and its HTML constraints; `safeParse`
8
+ * decides. A field the structure cannot render (an object, an array, a union)
9
+ * is reported `unsupported` and named, never silently dropped.
10
+ */
11
+ import * as core from "zod/v4/core";
12
+ import { throwOgygieError } from "@ogygie/core/errors";
13
+
14
+ export type FormFieldKind =
15
+ | "text"
16
+ | "textarea"
17
+ | "email"
18
+ | "url"
19
+ | "date"
20
+ | "datetime"
21
+ | "number"
22
+ | "integer"
23
+ | "checkbox"
24
+ | "select"
25
+ | "unsupported";
26
+
27
+ export interface FormFieldOption {
28
+ value: string | number;
29
+ }
30
+
31
+ export interface FormField {
32
+ name: string;
33
+ kind: FormFieldKind;
34
+ /** False when the contract accepts `undefined` (optional) or carries a default. */
35
+ required: boolean;
36
+ /** True when the contract accepts `null` — an empty control then sends null, not undefined. */
37
+ nullable: boolean;
38
+ minLength: number | null;
39
+ maxLength: number | null;
40
+ /** The source of a `.regex()`, for the control's `pattern` attribute. */
41
+ pattern: string | null;
42
+ minimum: number | null;
43
+ maximum: number | null;
44
+ /** Options of a `select` (enum values, or the literal's values). */
45
+ options: FormFieldOption[];
46
+ defaultValue: unknown;
47
+ /** `.meta({ title, description })` of the field, when declared. */
48
+ title: string | null;
49
+ description: string | null;
50
+ /** The Zod type name — what an `unsupported` message names. */
51
+ typeName: string;
52
+ }
53
+
54
+ /** The parts of a core definition this module reads, structurally typed. */
55
+ interface ReadableDef {
56
+ type: string;
57
+ innerType?: unknown;
58
+ in?: unknown;
59
+ shape?: Record<string, unknown>;
60
+ entries?: Record<string, string | number>;
61
+ values?: unknown[];
62
+ format?: unknown;
63
+ defaultValue?: unknown;
64
+ checks?: unknown[];
65
+ }
66
+
67
+ interface ReadableCheckDef {
68
+ check: string;
69
+ format?: unknown;
70
+ pattern?: unknown;
71
+ minimum?: unknown;
72
+ maximum?: unknown;
73
+ value?: unknown;
74
+ inclusive?: unknown;
75
+ }
76
+
77
+ function isZodSchema(candidate: unknown): candidate is core.$ZodType {
78
+ return typeof candidate === "object" && candidate !== null && "_zod" in candidate;
79
+ }
80
+
81
+ function defOf(schema: core.$ZodType): ReadableDef {
82
+ return schema._zod.def as ReadableDef;
83
+ }
84
+
85
+ function checkDefsOf(def: ReadableDef): ReadableCheckDef[] {
86
+ const defs: ReadableCheckDef[] = [];
87
+ for (const check of def.checks ?? []) {
88
+ if (typeof check === "object" && check !== null && "_zod" in check) {
89
+ const inner = (check as { _zod: { def: unknown } })._zod.def as ReadableCheckDef;
90
+ if (typeof inner.check === "string") defs.push(inner);
91
+ }
92
+ }
93
+ return defs;
94
+ }
95
+
96
+ function metaOf(schema: core.$ZodType): { title: string | null; description: string | null } {
97
+ const meta = core.globalRegistry.get(schema) as { title?: unknown; description?: unknown } | undefined;
98
+ return {
99
+ title: typeof meta?.title === "string" ? meta.title : null,
100
+ description: typeof meta?.description === "string" ? meta.description : null,
101
+ };
102
+ }
103
+
104
+ function asNumber(value: unknown): number | null {
105
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
106
+ }
107
+
108
+ /** Peels optional / nullable / default / pipe wrappers, collecting what they say. */
109
+ function unwrap(schema: core.$ZodType): {
110
+ inner: core.$ZodType;
111
+ required: boolean;
112
+ nullable: boolean;
113
+ defaultValue: unknown;
114
+ title: string | null;
115
+ description: string | null;
116
+ } {
117
+ let current = schema;
118
+ let required = true;
119
+ let nullable = false;
120
+ let defaultValue: unknown = undefined;
121
+ let { title, description } = metaOf(current);
122
+ for (;;) {
123
+ const def = defOf(current);
124
+ const next = def.type === "pipe" ? def.in : def.innerType;
125
+ if (def.type === "optional") required = false;
126
+ if (def.type === "nullable") nullable = true;
127
+ if (def.type === "default") {
128
+ required = false;
129
+ defaultValue = typeof def.defaultValue === "function" ? (def.defaultValue as () => unknown)() : def.defaultValue;
130
+ }
131
+ if (!["optional", "nullable", "default", "pipe", "nonoptional", "readonly"].includes(def.type) || !isZodSchema(next)) break;
132
+ current = next;
133
+ const meta = metaOf(current);
134
+ title ??= meta.title;
135
+ description ??= meta.description;
136
+ }
137
+ return { inner: current, required, nullable, defaultValue, title, description };
138
+ }
139
+
140
+ function fieldOf(name: string, schema: core.$ZodType): FormField {
141
+ const { inner, required, nullable, defaultValue, title, description } = unwrap(schema);
142
+ const def = defOf(inner);
143
+ const base: FormField = {
144
+ name,
145
+ kind: "unsupported",
146
+ required,
147
+ nullable,
148
+ minLength: null,
149
+ maxLength: null,
150
+ pattern: null,
151
+ minimum: null,
152
+ maximum: null,
153
+ options: [],
154
+ defaultValue,
155
+ title,
156
+ description,
157
+ typeName: def.type,
158
+ };
159
+ switch (def.type) {
160
+ case "string": {
161
+ const field: FormField = { ...base, kind: "text" };
162
+ const format = typeof def.format === "string" ? def.format : null;
163
+ if (format === "email") field.kind = "email";
164
+ else if (format === "url") field.kind = "url";
165
+ else if (format === "date") field.kind = "date";
166
+ else if (format === "datetime") field.kind = "datetime";
167
+ for (const check of checkDefsOf(def)) {
168
+ if (check.check === "min_length") field.minLength = asNumber(check.minimum);
169
+ if (check.check === "max_length") field.maxLength = asNumber(check.maximum);
170
+ if (check.check === "length_equals") {
171
+ field.minLength = asNumber(check.minimum) ?? field.minLength;
172
+ field.maxLength = asNumber(check.maximum) ?? field.maxLength;
173
+ }
174
+ if (check.check === "string_format" && check.format === "regex" && check.pattern instanceof RegExp) {
175
+ field.pattern = check.pattern.source;
176
+ }
177
+ }
178
+ // A long text reads in a textarea; a bounded short one in an input.
179
+ if (field.kind === "text" && field.maxLength !== null && field.maxLength > 160) field.kind = "textarea";
180
+ return field;
181
+ }
182
+ case "number": {
183
+ const field: FormField = { ...base, kind: "number" };
184
+ if (typeof def.format === "string" && def.format.includes("int")) field.kind = "integer";
185
+ for (const check of checkDefsOf(def)) {
186
+ if (check.check === "number_format" && typeof check.format === "string" && check.format.includes("int")) field.kind = "integer";
187
+ if (check.check === "greater_than") {
188
+ const bound = asNumber(check.value);
189
+ field.minimum = bound === null ? null : check.inclusive === true ? bound : bound + (field.kind === "integer" ? 1 : Number.EPSILON);
190
+ }
191
+ if (check.check === "less_than") {
192
+ const bound = asNumber(check.value);
193
+ field.maximum = bound === null ? null : check.inclusive === true ? bound : bound - (field.kind === "integer" ? 1 : Number.EPSILON);
194
+ }
195
+ }
196
+ return field;
197
+ }
198
+ case "boolean":
199
+ return { ...base, kind: "checkbox" };
200
+ case "enum":
201
+ return {
202
+ ...base,
203
+ kind: "select",
204
+ options: Object.values(def.entries ?? {}).map((value) => ({ value })),
205
+ };
206
+ case "literal":
207
+ return {
208
+ ...base,
209
+ kind: "select",
210
+ options: (def.values ?? [])
211
+ .filter((value): value is string | number => typeof value === "string" || typeof value === "number")
212
+ .map((value) => ({ value })),
213
+ };
214
+ default:
215
+ return base;
216
+ }
217
+ }
218
+
219
+ /** The fields of an object contract, in the order its shape declares them. Refused when the schema is not an object. */
220
+ export function formFieldsOf(schema: core.$ZodType): FormField[] {
221
+ const { inner } = unwrap(schema);
222
+ const def = defOf(inner);
223
+ if (def.type !== "object" || def.shape === undefined) {
224
+ throwOgygieError(
225
+ {
226
+ code: "CONTRACT_FORM_NOT_AN_OBJECT",
227
+ message: "ContractForm derives its fields from an object schema.",
228
+ file: null,
229
+ cause: `the schema given is a "${def.type}"`,
230
+ suggestedFix: "Pass the z.object() input schema of the route (CreateXInputSchema), not a field or a union.",
231
+ },
232
+ 500,
233
+ );
234
+ }
235
+ return Object.entries(def.shape)
236
+ .filter((entry): entry is [string, core.$ZodType] => isZodSchema(entry[1]))
237
+ .map(([name, fieldSchema]) => fieldOf(name, fieldSchema));
238
+ }
239
+
240
+ /**
241
+ * What the controls hand back, turned into what the contract expects: an
242
+ * empty control is `undefined` for an optional field, `null` for a nullable
243
+ * one, kept as is for a required one (the schema then says what is missing);
244
+ * numbers come back as numbers, select options keep their declared type.
245
+ */
246
+ export function coerceFormValues(fields: ReadonlyArray<FormField>, raw: Record<string, unknown>): Record<string, unknown> {
247
+ const values: Record<string, unknown> = {};
248
+ for (const field of fields) {
249
+ const given = raw[field.name];
250
+ if (field.kind === "checkbox") {
251
+ values[field.name] = given === true || given === "on" || given === "true";
252
+ continue;
253
+ }
254
+ const isBlank = given === undefined || given === null || given === "" || (typeof given === "number" && Number.isNaN(given));
255
+ if (isBlank) {
256
+ if (field.nullable) values[field.name] = null;
257
+ else if (!field.required) continue;
258
+ else if (field.kind === "number" || field.kind === "integer" || field.kind === "select") continue;
259
+ else values[field.name] = "";
260
+ continue;
261
+ }
262
+ if ((field.kind === "number" || field.kind === "integer") && typeof given === "string") {
263
+ values[field.name] = Number(given);
264
+ continue;
265
+ }
266
+ if (field.kind === "select" && typeof given === "string") {
267
+ const option = field.options.find((candidate) => String(candidate.value) === given);
268
+ values[field.name] = option === undefined ? given : option.value;
269
+ continue;
270
+ }
271
+ values[field.name] = given;
272
+ }
273
+ return values;
274
+ }