@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 x10sion LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,61 @@
1
- # Temporary Holding Version
1
+ # @ogygie/ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Le socle front du framework **Ogygie** (DECISIONS.md D39) : les briques
4
+ d'écran que le starter prescrivait sans les livrer. Un produit les IMPORTE ;
5
+ il ne les recopie plus dans `apps/web/src/patterns/`, qui ne garde que ses
6
+ patterns propres. Le paquet livre ses sources TypeScript (comme `@ogygie/core`)
7
+ et ne porte **aucune chaîne lue par un humain** : tout texte lui arrive en
8
+ prop, traduit par la feature (Lingui).
9
+
10
+ | Point d'entrée | Ce qu'il porte | Peer |
11
+ |---|---|---|
12
+ | `@ogygie/ui` | `ScreenState` (accès refusé → erreur → chargement → vide → corps), `EmptyState`, `ErrorBanner`, `EditableText`, `Skeleton`, `fetchContract` / `createContractFetcher`, `QueryLike` / `MutationLike` et leurs fixtures | `react`, `zod` (`@ogygie/core` est une DÉPENDANCE, à la version du lockstep) |
13
+ | `@ogygie/ui/data-table` | `DataTable` (colonnes typées, tri, chiffres alignés) + `DataTable.Skeleton` | `@tanstack/react-table` (optionnel) |
14
+ | `@ogygie/ui/contract-form` | `ContractForm` (formulaire dérivé d'un contrat Zod) + `formFieldsOf` | `react-hook-form` (optionnel) |
15
+ | `@ogygie/ui/variance-chart` | `VarianceChart` (constaté contre référence) + `VarianceChart.Skeleton` | `recharts` (optionnel) |
16
+
17
+ ## Thème
18
+
19
+ Les composants portent les utilitaires Tailwind des tokens du starter
20
+ (`surface`, `surface-raised`, `border`, `text`, `text-muted`, `accent`,
21
+ `accent-contrast`, `positive`, `warning`, `danger`, `focus`, les rayons
22
+ `sm`/`md`/`lg`). La feuille Tailwind du produit déclare le paquet comme
23
+ source, sinon ces classes ne sont pas générées :
24
+
25
+ ```css
26
+ @import "tailwindcss";
27
+ @source "../node_modules/@ogygie/ui/src";
28
+ ```
29
+
30
+ Les couleurs du graphique passent par les variables CSS du thème
31
+ (`--color-accent`, `--color-text-muted`, `--color-border`).
32
+
33
+ ## Un écran
34
+
35
+ ```tsx
36
+ import { ScreenState } from "@ogygie/ui";
37
+ import { DataTable } from "@ogygie/ui/data-table";
38
+
39
+ export function OrdersList() {
40
+ return (
41
+ <ScreenState
42
+ query={useOrders()}
43
+ errorTitle={<Trans>This view could not be loaded</Trans>}
44
+ loading={<DataTable.Skeleton />}
45
+ isEmpty={(data) => data.orders.length === 0}
46
+ emptyState={{ title: <Trans>No order yet</Trans>, explanation: <Trans>Orders appear here once recorded.</Trans> }}
47
+ >
48
+ {(data) => <DataTable rows={data.orders} columns={orderColumns} rowKey={(order) => order.id} />}
49
+ </ScreenState>
50
+ );
51
+ }
52
+ ```
53
+
54
+ Le corps d'un écran reçoit des `QueryLike` et des `MutationLike`, jamais un
55
+ hook : il se teste en rendu, sans client de requêtes ni réseau.
56
+
57
+ ## Tests
58
+
59
+ Les tests du paquet tournent sous jsdom (`@testing-library/react`), un par
60
+ état de chaque pattern : `pnpm vitest run packages/ogygie-ui` depuis le repo
61
+ du framework.
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@ogygie/ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.16.0",
4
+ "description": "Ogygie front socle: the five prescribed patterns (DataTable, EmptyState, ErrorBanner, ContractForm, VarianceChart), one screen-state component (ScreenState), fetchContract and EditableText. Ships TypeScript sources — Ogygie products run Vite/tsx/vitest by construction (D1); a product imports them, it no longer copies them (D39).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "src",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "exports": {
16
+ ".": "./src/index.ts",
17
+ "./data-table": "./src/DataTable.tsx",
18
+ "./contract-form": "./src/ContractForm.tsx",
19
+ "./variance-chart": "./src/VarianceChart.tsx"
20
+ },
21
+ "dependencies": {
22
+ "@ogygie/core": "0.16.0"
23
+ },
24
+ "peerDependencies": {
25
+ "@tanstack/react-table": "^9.0.0",
26
+ "react": "^19.0.0",
27
+ "react-dom": "^19.0.0",
28
+ "react-hook-form": "^7.60.0",
29
+ "recharts": "^2.15.0 || ^3.0.0",
30
+ "zod": "^4.0.0"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@tanstack/react-table": {
34
+ "optional": true
35
+ },
36
+ "react-hook-form": {
37
+ "optional": true
38
+ },
39
+ "recharts": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "@tanstack/react-table": "9.2.4",
45
+ "@testing-library/dom": "^10.4.1",
46
+ "@testing-library/react": "^16.3.2",
47
+ "@types/node": "^24.13.3",
48
+ "@types/react": "^19.2.18",
49
+ "@types/react-dom": "^19.2.4",
50
+ "jsdom": "^30.0.1",
51
+ "react": "^19.2.8",
52
+ "react-dom": "^19.2.8",
53
+ "react-hook-form": "^7.90.0",
54
+ "recharts": "^3.10.1",
55
+ "typescript": "^5.9.3",
56
+ "vitest": "^3.2.7",
57
+ "zod": "^4.4.3"
58
+ },
59
+ "scripts": {
60
+ "typecheck": "tsc -p tsconfig.json"
61
+ }
6
62
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @intent How much of a bounded field is used, `12 / 80`, turning to the
3
+ * warning colour at the bound — figures only, nothing to translate. Rendered
4
+ * by EditableText and ContractForm next to a field whose contract declares a
5
+ * maximum length (D39).
6
+ */
7
+ export function CharacterCount({ length, max }: { length: number; max: number }) {
8
+ return (
9
+ <span
10
+ role="status"
11
+ data-pattern="CharacterCount"
12
+ className={`ml-2 text-xs tabular-nums ${length >= max ? "text-warning" : "text-text-muted"}`}
13
+ >
14
+ {length} / {max}
15
+ </span>
16
+ );
17
+ }
@@ -0,0 +1,121 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * @intent Proves the contract-derived form (spec ogygie-ui B6): controls from
4
+ * the schema, labels and option labels from the product, a refused submission
5
+ * showing one sentence per faulty field and calling nothing, an accepted one
6
+ * handing the PARSED output over, the pending and server-refusal states, the
7
+ * form-level issue of a refine.
8
+ */
9
+ import { afterEach, describe, expect, it, vi } from "vitest";
10
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
11
+ import { z } from "zod";
12
+ import { OgygieFailure, ogygieError } from "@ogygie/core/errors";
13
+ import { ContractForm } from "./ContractForm.js";
14
+
15
+ const CreateInputSchema = z.object({
16
+ label: z.string().trim().min(1).max(80),
17
+ horizon: z.string().regex(/^2\d{3}-Q[1-4]$/, "expected a quarter such as 2027-Q2"),
18
+ level: z.number().int().min(1).max(4).nullable(),
19
+ tier: z.enum(["A", "B"]),
20
+ active: z.boolean(),
21
+ weight: z.number().positive().default(1),
22
+ });
23
+
24
+ const labels = { label: "Name", horizon: "Target quarter", level: "Level", tier: "Tier", active: "Active", weight: "Weight" };
25
+
26
+ describe("ContractForm", () => {
27
+ afterEach(cleanup);
28
+
29
+ it("ÉTANT DONNÉ le contrat ALORS un contrôle par champ, du bon type, avec son libellé et sa marque de requis", () => {
30
+ render(<ContractForm schema={CreateInputSchema} onSubmit={() => undefined} submitLabel="Create" labels={labels} optionLabels={{ tier: { A: "Tier A", B: "Tier B" } }} />);
31
+ expect((screen.getByLabelText(/Name/)).getAttribute("type")).toBe("text");
32
+ expect((screen.getByLabelText(/Name/)).getAttribute("maxlength")).toBe("80");
33
+ expect((screen.getByLabelText(/Target quarter/)).getAttribute("pattern")).toBe("^2\\d{3}-Q[1-4]$");
34
+ const level = screen.getByLabelText(/Level/);
35
+ expect((level).getAttribute("type")).toBe("number");
36
+ expect((level).getAttribute("min")).toBe("1");
37
+ expect((level).getAttribute("max")).toBe("4");
38
+ expect((level).getAttribute("step")).toBe("1");
39
+ expect(screen.getByLabelText(/Tier/).tagName).toBe("SELECT");
40
+ expect(screen.getByRole("option", { name: "Tier B" })).toBeDefined();
41
+ expect((screen.getByLabelText("Active")).getAttribute("type")).toBe("checkbox");
42
+ expect(screen.getByLabelText(/Name/).closest("[data-field]")?.textContent).toContain("*");
43
+ expect(screen.getByLabelText(/Weight/).closest("[data-field]")?.textContent).not.toContain("*");
44
+ expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
45
+ });
46
+
47
+ it("QUAND la soumission viole le contrat ALORS une phrase par champ fautif, et rien n'est envoyé", async () => {
48
+ const onSubmit = vi.fn();
49
+ render(<ContractForm schema={CreateInputSchema} onSubmit={onSubmit} submitLabel="Create" labels={labels} />);
50
+ fireEvent.change(screen.getByLabelText(/Target quarter/), { target: { value: "soon" } });
51
+ fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form") as HTMLFormElement);
52
+ const alerts = await screen.findAllByRole("alert");
53
+ expect(alerts.map((alert) => alert.textContent)).toContain("expected a quarter such as 2027-Q2");
54
+ expect((screen.getByLabelText(/Target quarter/)).getAttribute("aria-invalid")).toBe("true");
55
+ expect(onSubmit).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it("QUAND la soumission respecte le contrat ALORS onSubmit reçoit la sortie parsée (nombres, null, défauts)", async () => {
59
+ const onSubmit = vi.fn();
60
+ render(<ContractForm schema={CreateInputSchema} onSubmit={onSubmit} submitLabel="Create" labels={labels} />);
61
+ fireEvent.change(screen.getByLabelText(/Name/), { target: { value: " First grade " } });
62
+ fireEvent.change(screen.getByLabelText(/Target quarter/), { target: { value: "2027-Q2" } });
63
+ fireEvent.change(screen.getByLabelText(/Tier/), { target: { value: "B" } });
64
+ fireEvent.click(screen.getByLabelText("Active"));
65
+ fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form") as HTMLFormElement);
66
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
67
+ expect(onSubmit).toHaveBeenCalledWith({ label: "First grade", horizon: "2027-Q2", level: null, tier: "B", active: true, weight: 1 });
68
+ });
69
+
70
+ it("ÉTANT DONNÉ renderIssue ALORS le produit traduit la phrase d'un champ", async () => {
71
+ render(
72
+ <ContractForm
73
+ schema={CreateInputSchema}
74
+ onSubmit={() => undefined}
75
+ submitLabel="Create"
76
+ labels={labels}
77
+ renderIssue={(issue, field) => `${field?.name ?? "form"}: ${issue.code}`}
78
+ />,
79
+ );
80
+ fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form") as HTMLFormElement);
81
+ const alerts = await screen.findAllByRole("alert");
82
+ expect(alerts.map((alert) => alert.textContent)).toContain("label: too_small");
83
+ });
84
+
85
+ it("ÉTANT DONNÉ un refine sur l'objet ALORS la phrase se lit au niveau du formulaire", async () => {
86
+ const UpdateSchema = z
87
+ .object({ label: z.string().optional(), horizon: z.string().optional() })
88
+ .refine((body) => Object.values(body).some((value) => value !== undefined), { message: "at least one field must be sent" });
89
+ const onSubmit = vi.fn();
90
+ render(<ContractForm schema={UpdateSchema} onSubmit={onSubmit} submitLabel="Save" />);
91
+ fireEvent.submit(screen.getByRole("button", { name: "Save" }).closest("form") as HTMLFormElement);
92
+ expect((await screen.findAllByRole("alert")).map((alert) => alert.textContent)).toContain("at least one field must be sent");
93
+ expect(onSubmit).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("ÉTANT DONNÉ pending ALORS le bouton est inactif ; ÉTANT DONNÉ error ALORS un bandeau save au-dessus des actions", () => {
97
+ const refusal = new OgygieFailure(ogygieError({ code: "GRADE_CODE_ALREADY_USED", message: "This code is already used.", file: null, cause: "first-grade", suggestedFix: "Pick another label." }), 409);
98
+ render(<ContractForm schema={CreateInputSchema} onSubmit={() => undefined} submitLabel="Create" labels={labels} pending error={refusal} errorTitle="This change could not be saved" />);
99
+ expect((screen.getByRole("button", { name: "Create" }) as HTMLButtonElement).disabled).toBe(true);
100
+ expect((screen.getByRole("alert")).getAttribute("data-code")).toBe("GRADE_CODE_ALREADY_USED");
101
+ expect((screen.getByRole("alert")).getAttribute("data-kind")).toBe("save");
102
+ });
103
+
104
+ it("ÉTANT DONNÉ fields ALORS seuls ces champs se montrent, dans cet ordre, et un cancel est un bouton", () => {
105
+ const onCancel = vi.fn();
106
+ render(<ContractForm schema={CreateInputSchema} onSubmit={() => undefined} submitLabel="Create" labels={labels} fields={["horizon", "label"]} cancel={{ label: "Cancel", onClick: onCancel }} />);
107
+ const shown = Array.from(document.querySelectorAll("[data-field]")).map((node) => node.getAttribute("data-field"));
108
+ expect(shown).toEqual(["horizon", "label"]);
109
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
110
+ expect(onCancel).toHaveBeenCalledTimes(1);
111
+ });
112
+
113
+ it("ÉTANT DONNÉ resetOnSubmit ALORS le formulaire se vide après une soumission acceptée", async () => {
114
+ const Simple = z.object({ label: z.string().min(1) });
115
+ render(<ContractForm schema={Simple} onSubmit={() => undefined} submitLabel="Create" resetOnSubmit />);
116
+ const input = screen.getByLabelText(/label/);
117
+ fireEvent.change(input, { target: { value: "Grade" } });
118
+ fireEvent.submit(screen.getByRole("button", { name: "Create" }).closest("form") as HTMLFormElement);
119
+ await waitFor(() => expect((input as HTMLInputElement).value).toBe(""));
120
+ });
121
+ });
@@ -0,0 +1,269 @@
1
+ /**
2
+ * @intent A form DERIVED from a Zod contract (D39): the fields come from the
3
+ * schema's shape, their controls from their kinds, their constraints from the
4
+ * schema's own bounds — nothing is redeclared by hand — and the schema itself
5
+ * validates every submission (`safeParse`, through a react-hook-form resolver
6
+ * written here, no extra dependency). States: typing, submitting (button
7
+ * inactive), field errors (one sentence under the field, from the Zod issue,
8
+ * translated by the product through `renderIssue`), server refusal (an
9
+ * ErrorBanner `kind: save` above the actions). Every text is a prop: labels,
10
+ * hints, option labels, the submit and cancel labels. react-hook-form is an
11
+ * OPTIONAL peer carried by this entry point only (`@ogygie/ui/contract-form`).
12
+ */
13
+ import { useId, useMemo, useRef, type ReactNode } from "react";
14
+ import { useForm, type FieldErrors, type Resolver } from "react-hook-form";
15
+ import type { z } from "zod";
16
+ import type { $ZodIssue } from "zod/v4/core";
17
+ import { CharacterCount } from "./CharacterCount.js";
18
+ import { ErrorBanner } from "./ErrorBanner.js";
19
+ import { coerceFormValues, formFieldsOf, type FormField } from "./contract-fields.js";
20
+
21
+ type FormValues = Record<string, unknown>;
22
+
23
+ export interface ContractFormProps<TSchema extends z.ZodType<Record<string, unknown>, Record<string, unknown>>> {
24
+ schema: TSchema;
25
+ /** Receives the PARSED output of the contract — defaults applied, transforms run. */
26
+ onSubmit: (values: z.output<TSchema>) => void | Promise<void>;
27
+ submitLabel: ReactNode;
28
+ defaultValues?: Partial<z.input<TSchema>>;
29
+ /** The label of each field, translated. A field without one shows its name. */
30
+ labels?: Partial<Record<keyof z.input<TSchema> & string, ReactNode>>;
31
+ /** A sentence under a field. */
32
+ hints?: Partial<Record<keyof z.input<TSchema> & string, ReactNode>>;
33
+ /** The label of each option of a `select` field, by option value. */
34
+ optionLabels?: Partial<Record<keyof z.input<TSchema> & string, Record<string, ReactNode>>>;
35
+ /** Which fields to show, in which order. Default: every field of the shape, in its order. A hidden field still validates (its default value is sent). */
36
+ fields?: ReadonlyArray<keyof z.input<TSchema> & string>;
37
+ /** The write in flight outside the form (a mutation): the submit stays inactive. */
38
+ pending?: boolean;
39
+ /** The server's refusal, shown as a `save` banner above the actions. */
40
+ error?: unknown;
41
+ errorTitle?: ReactNode;
42
+ cancel?: { label: ReactNode; onClick: () => void };
43
+ /** The sentence for a Zod issue. Default: the issue's own message. */
44
+ renderIssue?: (issue: $ZodIssue, field: FormField | null) => ReactNode;
45
+ /** Empties the form after a submission the contract accepted. */
46
+ resetOnSubmit?: boolean;
47
+ /** Focus the first field when the form appears. */
48
+ autoFocus?: boolean;
49
+ id?: string;
50
+ }
51
+
52
+ const CONTROL_CLASS =
53
+ "w-full rounded-sm border bg-surface-raised px-2 py-1.5 text-sm text-text outline-none focus:border-focus";
54
+
55
+ function controlClassOf(invalid: boolean): string {
56
+ return `${CONTROL_CLASS} ${invalid ? "border-danger" : "border-border"}`;
57
+ }
58
+
59
+ export function ContractForm<TSchema extends z.ZodType<Record<string, unknown>, Record<string, unknown>>>(
60
+ props: ContractFormProps<TSchema>,
61
+ ) {
62
+ const { schema, pending = false, resetOnSubmit = false, autoFocus = false } = props;
63
+ const generatedId = useId();
64
+ const formId = props.id ?? generatedId;
65
+ const allFields = useMemo(() => formFieldsOf(schema), [schema]);
66
+ const shownNames = props.fields;
67
+ const fields = useMemo(
68
+ () =>
69
+ shownNames === undefined
70
+ ? allFields
71
+ : shownNames.flatMap((name) => allFields.filter((field) => field.name === name)),
72
+ [allFields, shownNames],
73
+ );
74
+ // The issues of the last validation, by field — what the error sentences render from.
75
+ const issuesRef = useRef<Map<string, $ZodIssue>>(new Map());
76
+
77
+ const resolver: Resolver<FormValues, unknown, FormValues> = (raw) => {
78
+ const values = coerceFormValues(allFields, raw);
79
+ const parsed = schema.safeParse(values);
80
+ if (parsed.success) {
81
+ issuesRef.current = new Map();
82
+ return { values: parsed.data, errors: {} };
83
+ }
84
+ const errors: FieldErrors<FormValues> = {};
85
+ const issues = new Map<string, $ZodIssue>();
86
+ for (const issue of parsed.error.issues) {
87
+ const key = typeof issue.path[0] === "string" ? issue.path[0] : "root";
88
+ if (issues.has(key)) continue;
89
+ issues.set(key, issue);
90
+ errors[key] = { type: issue.code, message: issue.message };
91
+ }
92
+ issuesRef.current = issues;
93
+ return { values: {}, errors };
94
+ };
95
+
96
+ const initialValues: FormValues = {};
97
+ for (const field of allFields) {
98
+ const given = (props.defaultValues as Record<string, unknown> | undefined)?.[field.name];
99
+ initialValues[field.name] = given ?? field.defaultValue ?? (field.kind === "checkbox" ? false : "");
100
+ }
101
+
102
+ const form = useForm<FormValues, unknown, FormValues>({ resolver, defaultValues: initialValues, mode: "onTouched" });
103
+ const { register, handleSubmit, formState, reset, watch } = form;
104
+ const renderIssue = props.renderIssue ?? ((issue: $ZodIssue) => issue.message);
105
+ const rootIssue = issuesRef.current.get("root");
106
+ const busy = formState.isSubmitting || pending;
107
+
108
+ const submit = handleSubmit(async (values) => {
109
+ await props.onSubmit(values as z.output<TSchema>);
110
+ if (resetOnSubmit) reset(initialValues);
111
+ });
112
+
113
+ return (
114
+ <form
115
+ id={formId}
116
+ data-pattern="ContractForm"
117
+ noValidate
118
+ aria-busy={busy ? "true" : undefined}
119
+ onSubmit={(event) => {
120
+ void submit(event);
121
+ }}
122
+ className="flex flex-col gap-4"
123
+ >
124
+ {fields.map((field, index) => {
125
+ const inputId = `${formId}-${field.name}`;
126
+ const fieldError = formState.errors[field.name];
127
+ const issue = issuesRef.current.get(field.name);
128
+ const invalid = fieldError !== undefined;
129
+ const hint = props.hints?.[field.name as keyof z.input<TSchema> & string];
130
+ const describedBy = [hint === undefined ? null : `${inputId}-hint`, invalid ? `${inputId}-error` : null]
131
+ .filter((part): part is string => part !== null)
132
+ .join(" ");
133
+ const label = props.labels?.[field.name as keyof z.input<TSchema> & string] ?? field.title ?? field.name;
134
+ const shared = {
135
+ id: inputId,
136
+ "aria-invalid": invalid ? true : undefined,
137
+ "aria-describedby": describedBy === "" ? undefined : describedBy,
138
+ disabled: busy,
139
+ autoFocus: autoFocus && index === 0,
140
+ };
141
+ let control: ReactNode;
142
+ switch (field.kind) {
143
+ case "checkbox":
144
+ control = (
145
+ <input type="checkbox" {...shared} {...register(field.name)} className="h-4 w-4 rounded-sm border-border accent-accent" />
146
+ );
147
+ break;
148
+ case "select": {
149
+ const optionLabels = props.optionLabels?.[field.name as keyof z.input<TSchema> & string];
150
+ control = (
151
+ <select {...shared} {...register(field.name)} className={controlClassOf(invalid)}>
152
+ <option value="">{""}</option>
153
+ {field.options.map((option) => (
154
+ <option key={String(option.value)} value={String(option.value)}>
155
+ {optionLabels?.[String(option.value)] ?? String(option.value)}
156
+ </option>
157
+ ))}
158
+ </select>
159
+ );
160
+ break;
161
+ }
162
+ case "textarea": {
163
+ const length = String(watch(field.name) ?? "").length;
164
+ control = (
165
+ <>
166
+ <textarea
167
+ {...shared}
168
+ {...register(field.name)}
169
+ rows={4}
170
+ {...(field.maxLength === null ? {} : { maxLength: field.maxLength })}
171
+ className={controlClassOf(invalid)}
172
+ />
173
+ {field.maxLength !== null && <CharacterCount length={length} max={field.maxLength} />}
174
+ </>
175
+ );
176
+ break;
177
+ }
178
+ case "number":
179
+ case "integer":
180
+ control = (
181
+ <input
182
+ type="number"
183
+ {...shared}
184
+ {...register(field.name, { valueAsNumber: true })}
185
+ {...(field.minimum === null ? {} : { min: field.minimum })}
186
+ {...(field.maximum === null ? {} : { max: field.maximum })}
187
+ step={field.kind === "integer" ? 1 : "any"}
188
+ className={`${controlClassOf(invalid)} text-right tabular-nums`}
189
+ />
190
+ );
191
+ break;
192
+ case "unsupported":
193
+ control = (
194
+ <p className="text-xs text-warning" data-unsupported={field.typeName}>
195
+ {field.name}: {field.typeName}
196
+ </p>
197
+ );
198
+ break;
199
+ default: {
200
+ const type =
201
+ field.kind === "email" ? "email" : field.kind === "url" ? "url" : field.kind === "date" ? "date" : field.kind === "datetime" ? "datetime-local" : "text";
202
+ control = (
203
+ <input
204
+ type={type}
205
+ {...shared}
206
+ {...register(field.name)}
207
+ {...(field.maxLength === null ? {} : { maxLength: field.maxLength })}
208
+ {...(field.pattern === null ? {} : { pattern: field.pattern })}
209
+ className={controlClassOf(invalid)}
210
+ />
211
+ );
212
+ }
213
+ }
214
+ return (
215
+ <div key={field.name} data-field={field.name} className="flex flex-col gap-1">
216
+ <label htmlFor={inputId} className="text-sm font-medium text-text">
217
+ {label}
218
+ {field.required && field.kind !== "checkbox" && (
219
+ <span aria-hidden="true" className="text-danger">
220
+ {" *"}
221
+ </span>
222
+ )}
223
+ </label>
224
+ {control}
225
+ {hint !== undefined && (
226
+ <p id={`${inputId}-hint`} className="text-xs text-text-muted">
227
+ {hint}
228
+ </p>
229
+ )}
230
+ {invalid && (
231
+ <p id={`${inputId}-error`} role="alert" className="text-xs text-danger">
232
+ {issue === undefined ? fieldError.message : renderIssue(issue, field)}
233
+ </p>
234
+ )}
235
+ </div>
236
+ );
237
+ })}
238
+ {rootIssue !== undefined && (
239
+ <p role="alert" className="text-xs text-danger">
240
+ {renderIssue(rootIssue, null)}
241
+ </p>
242
+ )}
243
+ {props.error != null && (
244
+ <ErrorBanner error={props.error} kind="save" {...(props.errorTitle === undefined ? {} : { title: props.errorTitle })} />
245
+ )}
246
+ <div className="flex items-center gap-3">
247
+ <button
248
+ type="submit"
249
+ disabled={busy}
250
+ className="rounded-sm bg-accent px-4 py-1.5 text-sm font-medium text-accent-contrast hover:opacity-90 disabled:opacity-60"
251
+ >
252
+ {props.submitLabel}
253
+ </button>
254
+ {props.cancel !== undefined && (
255
+ <button
256
+ type="button"
257
+ onClick={props.cancel.onClick}
258
+ disabled={formState.isSubmitting}
259
+ className="rounded-sm border border-border bg-surface px-4 py-1.5 text-sm font-medium text-text hover:bg-surface-raised"
260
+ >
261
+ {props.cancel.label}
262
+ </button>
263
+ )}
264
+ </div>
265
+ </form>
266
+ );
267
+ }
268
+
269
+ export { formFieldsOf, coerceFormValues, type FormField, type FormFieldKind, type FormFieldOption } from "./contract-fields.js";
@@ -0,0 +1,84 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * @intent Proves the data table (spec ogygie-ui B7): headers and cells from
4
+ * typed columns, sorting toggled on a header with its aria-sort, figures
5
+ * right-aligned, the empty state delegated to the caller, a skeleton for the
6
+ * loading state.
7
+ */
8
+ import { afterEach, describe, expect, it } from "vitest";
9
+ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
10
+ import { DataTable, DataTableSkeleton, type DataTableColumn } from "./DataTable.js";
11
+
12
+ interface Row {
13
+ code: string;
14
+ label: string;
15
+ overall: number | null;
16
+ }
17
+
18
+ const ROWS: Row[] = [
19
+ { code: "second-grade", label: "Second grade", overall: 2.5 },
20
+ { code: "first-grade", label: "First grade", overall: 3.2 },
21
+ { code: "third-grade", label: "Third grade", overall: null },
22
+ ];
23
+
24
+ const COLUMNS: DataTableColumn<Row>[] = [
25
+ { id: "label", header: "Label", cell: (row) => row.label, sortValue: (row) => row.label },
26
+ { id: "overall", header: "Overall", cell: (row) => (row.overall === null ? "—" : row.overall.toFixed(1)), sortValue: (row) => row.overall, align: "end" },
27
+ { id: "actions", header: "Actions", cell: (row) => <button type="button">{`Delete ${row.code}`}</button> },
28
+ ];
29
+
30
+ const bodyLabels = (): string[] =>
31
+ within(screen.getByRole("table")).getAllByRole("row").slice(1).map((row) => within(row).getAllByRole("cell")[0]?.textContent ?? "");
32
+
33
+ describe("DataTable", () => {
34
+ afterEach(cleanup);
35
+
36
+ it("ÉTANT DONNÉ des colonnes typées ALORS en-têtes et cellules, dans l'ordre servi", () => {
37
+ render(<DataTable rows={ROWS} columns={COLUMNS} rowKey={(row) => row.code} caption="Grades" />);
38
+ expect(screen.getAllByRole("columnheader").map((header) => header.textContent?.replace(/[▲▼⇅]/g, ""))).toEqual(["Label", "Overall", "Actions"]);
39
+ expect(bodyLabels()).toEqual(["Second grade", "First grade", "Third grade"]);
40
+ expect(screen.getByRole("button", { name: "Delete first-grade" })).toBeDefined();
41
+ expect(screen.getByRole("table").querySelector("caption")?.textContent).toBe("Grades");
42
+ });
43
+
44
+ it("QUAND un en-tête triable est cliqué ALORS les lignes se trient et aria-sort le dit ; un second clic inverse", () => {
45
+ render(<DataTable rows={ROWS} columns={COLUMNS} rowKey={(row) => row.code} />);
46
+ const labelHeader = screen.getAllByRole("columnheader")[0] as HTMLElement;
47
+ fireEvent.click(within(labelHeader).getByRole("button"));
48
+ expect((labelHeader).getAttribute("aria-sort")).toBe("ascending");
49
+ expect(bodyLabels()).toEqual(["First grade", "Second grade", "Third grade"]);
50
+ fireEvent.click(within(labelHeader).getByRole("button"));
51
+ expect((labelHeader).getAttribute("aria-sort")).toBe("descending");
52
+ expect(bodyLabels()).toEqual(["Third grade", "Second grade", "First grade"]);
53
+ });
54
+
55
+ it("ÉTANT DONNÉ un tri initial sur une colonne de chiffres ALORS les vides vont à la fin, et la colonne est alignée à droite", () => {
56
+ render(<DataTable rows={ROWS} columns={COLUMNS} rowKey={(row) => row.code} initialSort={{ id: "overall", desc: true }} />);
57
+ expect(bodyLabels()).toEqual(["First grade", "Second grade", "Third grade"]);
58
+ const overallCell = within(screen.getAllByRole("row")[1] as HTMLElement).getAllByRole("cell")[1];
59
+ expect(overallCell?.className).toContain("tabular-nums");
60
+ expect(overallCell?.className).toContain("text-right");
61
+ });
62
+
63
+ it("ÉTANT DONNÉ une colonne sans sortValue ALORS son en-tête n'est pas un bouton", () => {
64
+ render(<DataTable rows={ROWS} columns={COLUMNS} rowKey={(row) => row.code} />);
65
+ const actionsHeader = screen.getAllByRole("columnheader")[2] as HTMLElement;
66
+ expect(within(actionsHeader).queryByRole("button")).toBeNull();
67
+ expect((actionsHeader).hasAttribute("aria-sort")).toBe(false);
68
+ });
69
+
70
+ it("ÉTANT DONNÉ aucune ligne et un emptyState ALORS lui seul se rend ; sans lui, les en-têtes seuls", () => {
71
+ const { rerender } = render(<DataTable rows={[]} columns={COLUMNS} rowKey={(row) => row.code} emptyState={<p>No grade yet</p>} />);
72
+ expect(screen.getByText("No grade yet")).toBeDefined();
73
+ expect(screen.queryByRole("table")).toBeNull();
74
+ rerender(<DataTable rows={[]} columns={COLUMNS} rowKey={(row) => row.code} />);
75
+ expect(screen.getAllByRole("columnheader")).toHaveLength(3);
76
+ expect(screen.getAllByRole("row")).toHaveLength(1);
77
+ });
78
+
79
+ it("le squelette est un status occupé", () => {
80
+ render(<DataTableSkeleton rows={2} />);
81
+ expect((screen.getByRole("status")).getAttribute("aria-busy")).toBe("true");
82
+ expect(DataTable.Skeleton).toBe(DataTableSkeleton);
83
+ });
84
+ });