@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,161 @@
1
+ /**
2
+ * @intent The data table of every Ogygie screen (D39): typed columns, sorting
3
+ * by header (TanStack Table v9 owns the model and the state, this file owns
4
+ * the markup), figures right-aligned in tabular figures with their unit, a
5
+ * skeleton for the loading state and the empty state DELEGATED to the caller
6
+ * (an EmptyState says why the table is empty; a table never says it alone).
7
+ * Every header is a node the product translates. TanStack Table is an
8
+ * OPTIONAL peer carried by this entry point only (`@ogygie/ui/data-table`).
9
+ */
10
+ import { useMemo, useState, type ReactNode } from "react";
11
+ import {
12
+ createColumnHelper,
13
+ createSortedRowModel,
14
+ rowSortingFeature,
15
+ sortFn_alphanumeric,
16
+ sortFn_basic,
17
+ sortFn_datetime,
18
+ sortFn_text,
19
+ tableFeatures,
20
+ useTable,
21
+ type RowData,
22
+ type SortingState,
23
+ } from "@tanstack/react-table";
24
+
25
+ const features = tableFeatures({
26
+ rowSortingFeature,
27
+ sortedRowModel: createSortedRowModel(),
28
+ sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic, datetime: sortFn_datetime, text: sortFn_text },
29
+ });
30
+
31
+ export type SortableValue = string | number | boolean | null | undefined;
32
+
33
+ export interface DataTableColumn<TRow extends RowData> {
34
+ id: string;
35
+ header: ReactNode;
36
+ cell: (row: TRow) => ReactNode;
37
+ /** The value the column sorts on. Absent, the column does not sort. */
38
+ sortValue?: (row: TRow) => SortableValue;
39
+ /** Figures read right-aligned, in tabular figures (AGENTS.md §Front). */
40
+ align?: "start" | "end";
41
+ }
42
+
43
+ export interface DataTableProps<TRow extends RowData> {
44
+ rows: ReadonlyArray<TRow>;
45
+ /** Keep this array stable (module scope or useMemo): the model is rebuilt when it changes. */
46
+ columns: ReadonlyArray<DataTableColumn<TRow>>;
47
+ rowKey: (row: TRow) => string;
48
+ /** The table's accessible name, translated — read by screen readers only. */
49
+ caption?: ReactNode;
50
+ initialSort?: { id: string; desc: boolean };
51
+ /** Rendered INSTEAD of the table when there is no row — an EmptyState that says why. Absent, the headers render alone. */
52
+ emptyState?: ReactNode;
53
+ }
54
+
55
+ function alignClassOf(align: "start" | "end" | undefined): string {
56
+ return align === "end" ? "text-right tabular-nums" : "text-left";
57
+ }
58
+
59
+ export function DataTable<TRow extends RowData>(props: DataTableProps<TRow>) {
60
+ const descriptors = useMemo(() => new Map(props.columns.map((column) => [column.id, column])), [props.columns]);
61
+ const columns = useMemo(() => {
62
+ const helper = createColumnHelper<typeof features, TRow>();
63
+ return helper.columns(
64
+ props.columns.map((column) => {
65
+ const sortValue = column.sortValue;
66
+ return sortValue === undefined
67
+ ? helper.display({ id: column.id, enableSorting: false })
68
+ : helper.accessor((row: TRow) => sortValue(row) ?? undefined, { id: column.id, sortUndefined: "last" });
69
+ }),
70
+ );
71
+ }, [props.columns]);
72
+ const [sorting, setSorting] = useState<SortingState>(props.initialSort === undefined ? [] : [props.initialSort]);
73
+ const table = useTable({
74
+ features,
75
+ columns,
76
+ data: props.rows,
77
+ getRowId: (row) => props.rowKey(row),
78
+ state: { sorting },
79
+ onSortingChange: setSorting,
80
+ enableSortingRemoval: false,
81
+ });
82
+
83
+ if (props.rows.length === 0 && props.emptyState !== undefined) {
84
+ return <>{props.emptyState}</>;
85
+ }
86
+
87
+ return (
88
+ <div data-pattern="DataTable" className="overflow-x-auto rounded-md border border-border bg-surface-raised">
89
+ <table className="w-full border-collapse text-sm text-text">
90
+ {props.caption !== undefined && <caption className="sr-only">{props.caption}</caption>}
91
+ <thead className="bg-surface text-xs font-medium uppercase tracking-wide text-text-muted">
92
+ {table.getHeaderGroups().map((group) => (
93
+ <tr key={group.id}>
94
+ {group.headers.map((header) => {
95
+ const descriptor = descriptors.get(header.column.id);
96
+ const sorted = header.column.getIsSorted();
97
+ const canSort = header.column.getCanSort();
98
+ return (
99
+ <th
100
+ key={header.id}
101
+ scope="col"
102
+ aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}
103
+ className={`px-3 py-2 ${alignClassOf(descriptor?.align)}`}
104
+ >
105
+ {canSort ? (
106
+ <button
107
+ type="button"
108
+ onClick={header.column.getToggleSortingHandler()}
109
+ data-sorted={sorted === false ? "none" : sorted}
110
+ className="inline-flex items-center gap-1 uppercase hover:text-text"
111
+ >
112
+ {descriptor?.header}
113
+ <span aria-hidden="true" className={sorted === false ? "opacity-40" : ""}>
114
+ {sorted === "asc" ? "▲" : sorted === "desc" ? "▼" : "⇅"}
115
+ </span>
116
+ </button>
117
+ ) : (
118
+ descriptor?.header
119
+ )}
120
+ </th>
121
+ );
122
+ })}
123
+ </tr>
124
+ ))}
125
+ </thead>
126
+ <tbody>
127
+ {table.getRowModel().rows.map((row) => (
128
+ <tr key={row.id} className="border-t border-border hover:bg-surface">
129
+ {row.getAllCells().map((cell) => {
130
+ const descriptor = descriptors.get(cell.column.id);
131
+ return (
132
+ <td key={cell.id} className={`px-3 py-2 ${alignClassOf(descriptor?.align)}`}>
133
+ {descriptor?.cell(row.original)}
134
+ </td>
135
+ );
136
+ })}
137
+ </tr>
138
+ ))}
139
+ </tbody>
140
+ </table>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ /** The loading state of a table: a header bar and pulsing rows, `role="status"`. */
146
+ export function DataTableSkeleton({ rows = 5 }: { rows?: number }) {
147
+ return (
148
+ <div role="status" aria-busy="true" data-pattern="DataTable.Skeleton" className="animate-pulse rounded-md border border-border bg-surface-raised">
149
+ <div className="h-9 rounded-t-md bg-surface" />
150
+ {Array.from({ length: rows }, (_, index) => (
151
+ <div key={index} className="flex gap-3 border-t border-border px-3 py-3">
152
+ <div className="h-4 w-1/4 rounded-sm bg-border" />
153
+ <div className="h-4 w-1/2 rounded-sm bg-border" />
154
+ <div className="ml-auto h-4 w-12 rounded-sm bg-border" />
155
+ </div>
156
+ ))}
157
+ </div>
158
+ );
159
+ }
160
+
161
+ DataTable.Skeleton = DataTableSkeleton;
@@ -0,0 +1,107 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * @intent Proves the inline editor (spec ogygie-ui B5): plain text for a
4
+ * reader, a button for an editor, a focused field on click, save on blur or
5
+ * Enter, abandon on Escape, a blank draft refused without a write, the
6
+ * character count at a bound, an inactive control while a write is pending.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
10
+ import { EditableText } from "./EditableText.js";
11
+
12
+ describe("EditableText", () => {
13
+ afterEach(cleanup);
14
+
15
+ it("ÉTANT DONNÉ un lecteur ALORS le texte nu, jamais un contrôle inactif", () => {
16
+ const { container } = render(<EditableText value="First grade" onSave={() => undefined} placeholder="Unnamed" editable={false} ariaLabel="Label" />);
17
+ expect(container.querySelector("button")).toBeNull();
18
+ expect(screen.getByText("First grade")).toBeDefined();
19
+ });
20
+
21
+ it("ÉTANT DONNÉ une valeur vide ALORS le placeholder, en italique", () => {
22
+ render(<EditableText value="" onSave={() => undefined} placeholder="Unnamed" editable ariaLabel="Label" />);
23
+ expect(screen.getByText("Unnamed").className).toContain("italic");
24
+ });
25
+
26
+ it("ÉTANT DONNÉ un éditeur QUAND il clique, tape et quitte le champ ALORS la valeur est enregistrée", () => {
27
+ const onSave = vi.fn();
28
+ render(<EditableText value="First grade" onSave={onSave} placeholder="Unnamed" editable ariaLabel="Label" />);
29
+ fireEvent.click(screen.getByRole("button", { name: "Label" }));
30
+ const field = screen.getByRole("textbox", { name: "Label" });
31
+ expect(document.activeElement).toBe(field);
32
+ fireEvent.change(field, { target: { value: "Second grade" } });
33
+ fireEvent.blur(field);
34
+ expect(onSave).toHaveBeenCalledWith("Second grade");
35
+ expect(screen.getByRole("button", { name: "Label" })).toBeDefined();
36
+ });
37
+
38
+ it("QUAND la valeur n'a pas changé ALORS rien n'est écrit", () => {
39
+ const onSave = vi.fn();
40
+ render(<EditableText value="First grade" onSave={onSave} placeholder="Unnamed" editable ariaLabel="Label" />);
41
+ fireEvent.click(screen.getByRole("button", { name: "Label" }));
42
+ fireEvent.keyDown(screen.getByRole("textbox", { name: "Label" }), { key: "Enter" });
43
+ expect(onSave).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it("QUAND Échap est pressé ALORS le brouillon est abandonné", () => {
47
+ const onSave = vi.fn();
48
+ render(<EditableText value="First grade" onSave={onSave} placeholder="Unnamed" editable ariaLabel="Label" />);
49
+ fireEvent.click(screen.getByRole("button", { name: "Label" }));
50
+ const field = screen.getByRole("textbox", { name: "Label" });
51
+ fireEvent.change(field, { target: { value: "Draft" } });
52
+ fireEvent.keyDown(field, { key: "Escape" });
53
+ expect(onSave).not.toHaveBeenCalled();
54
+ expect(screen.getByRole("button", { name: "Label" }).textContent).toBe("First grade");
55
+ });
56
+
57
+ it("QUAND le brouillon est blanc ALORS il est refusé sans écriture, et la valeur revient", () => {
58
+ const onSave = vi.fn();
59
+ render(<EditableText value="First grade" onSave={onSave} placeholder="Unnamed" editable ariaLabel="Label" />);
60
+ fireEvent.click(screen.getByRole("button", { name: "Label" }));
61
+ const field = screen.getByRole("textbox", { name: "Label" });
62
+ fireEvent.change(field, { target: { value: " " } });
63
+ fireEvent.blur(field);
64
+ expect(onSave).not.toHaveBeenCalled();
65
+ expect(screen.getByRole("button", { name: "Label" }).textContent).toBe("First grade");
66
+ });
67
+
68
+ it("QUAND le contrat accepte le vide (allowEmpty) ALORS un brouillon vide s'enregistre", () => {
69
+ const onSave = vi.fn();
70
+ render(<EditableText value="Note" onSave={onSave} placeholder="Unnamed" editable allowEmpty ariaLabel="Label" />);
71
+ fireEvent.click(screen.getByRole("button", { name: "Label" }));
72
+ const field = screen.getByRole("textbox", { name: "Label" });
73
+ fireEvent.change(field, { target: { value: "" } });
74
+ fireEvent.blur(field);
75
+ expect(onSave).toHaveBeenCalledWith("");
76
+ });
77
+
78
+ it("ÉTANT DONNÉ une borne ALORS le champ la porte et le compteur suit le brouillon", () => {
79
+ render(<EditableText value="abc" onSave={() => undefined} placeholder="Unnamed" editable maxLength={5} ariaLabel="Label" />);
80
+ fireEvent.click(screen.getByRole("button", { name: "Label" }));
81
+ const field = screen.getByRole("textbox", { name: "Label" });
82
+ expect((field).getAttribute("maxlength")).toBe("5");
83
+ expect(screen.getByRole("status").textContent).toBe("3 / 5");
84
+ fireEvent.change(field, { target: { value: "abcde" } });
85
+ expect(screen.getByRole("status").className).toContain("text-warning");
86
+ });
87
+
88
+ it("ÉTANT DONNÉ multiline ALORS un textarea, qui garde Entrée pour la ligne suivante", () => {
89
+ const onSave = vi.fn();
90
+ render(<EditableText value="Note" onSave={onSave} placeholder="Unnamed" editable multiline ariaLabel="Label" />);
91
+ fireEvent.click(screen.getByRole("button", { name: "Label" }));
92
+ const field = screen.getByRole("textbox", { name: "Label" });
93
+ expect(field.tagName).toBe("TEXTAREA");
94
+ fireEvent.change(field, { target: { value: "Note\nmore" } });
95
+ fireEvent.keyDown(field, { key: "Enter" });
96
+ expect(onSave).not.toHaveBeenCalled();
97
+ fireEvent.blur(field);
98
+ expect(onSave).toHaveBeenCalledWith("Note\nmore");
99
+ });
100
+
101
+ it("ÉTANT DONNÉ une écriture en cours ALORS le contrôle est inactif et occupé", () => {
102
+ render(<EditableText value="First grade" onSave={() => undefined} placeholder="Unnamed" editable pending ariaLabel="Label" />);
103
+ const button = screen.getByRole("button", { name: "Label" });
104
+ expect((button as HTMLButtonElement).disabled).toBe(true);
105
+ expect((button).getAttribute("aria-busy")).toBe("true");
106
+ });
107
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @intent Text that is written where it is read (D39): a click turns the value
3
+ * into a field, leaving it saves, Escape abandons. No form, no submit button.
4
+ * A read-only viewer gets plain text, never a disabled control: a button one
5
+ * cannot press is a promise the screen does not keep. A blank draft is refused
6
+ * without a write unless the contract allows it; a bounded field stops at its
7
+ * bound and shows how much is used. One component for both apps of a product
8
+ * — the two copies it replaces had already diverged (astorg-ai-pdlc).
9
+ */
10
+ import { useEffect, useRef, useState, type ReactNode } from "react";
11
+ import { CharacterCount } from "./CharacterCount.js";
12
+
13
+ export interface EditableTextProps {
14
+ value: string;
15
+ onSave: (value: string) => void;
16
+ /** Shown dimmed when the value is empty. */
17
+ placeholder: ReactNode;
18
+ editable: boolean;
19
+ multiline?: boolean;
20
+ /** The contract's bound: the field stops there and shows how much is used. */
21
+ maxLength?: number;
22
+ /** A blank or whitespace-only draft is refused without a write, unless the contract accepts an empty text. */
23
+ allowEmpty?: boolean;
24
+ /** A write in flight: the text stays, the control is inactive. */
25
+ pending?: boolean;
26
+ className?: string;
27
+ /** The accessible name of the control — a translated string from the product. */
28
+ ariaLabel: string;
29
+ }
30
+
31
+ export function EditableText(props: EditableTextProps) {
32
+ const { value, editable, multiline = false, className = "", ariaLabel, maxLength, allowEmpty = false, pending = false } = props;
33
+ const [editing, setEditing] = useState(false);
34
+ const [draft, setDraft] = useState(value);
35
+ const field = useRef<HTMLTextAreaElement & HTMLInputElement>(null);
36
+
37
+ useEffect(() => {
38
+ if (editing) field.current?.focus();
39
+ }, [editing]);
40
+
41
+ const commit = (): void => {
42
+ setEditing(false);
43
+ if (draft === value) return;
44
+ if (!allowEmpty && draft.trim() === "") {
45
+ setDraft(value);
46
+ return;
47
+ }
48
+ props.onSave(draft);
49
+ };
50
+ const abandon = (): void => {
51
+ setDraft(value);
52
+ setEditing(false);
53
+ };
54
+
55
+ const shown = value === "" ? <span className="italic text-text-muted">{props.placeholder}</span> : value;
56
+
57
+ if (!editable) {
58
+ return (
59
+ <span data-pattern="EditableText" className={className}>
60
+ {shown}
61
+ </span>
62
+ );
63
+ }
64
+
65
+ if (editing) {
66
+ const shared = {
67
+ ref: field,
68
+ value: draft,
69
+ "aria-label": ariaLabel,
70
+ ...(maxLength === undefined ? {} : { maxLength }),
71
+ onChange: (event: { target: { value: string } }) => setDraft(event.target.value),
72
+ onBlur: commit,
73
+ className: `w-full rounded-sm border border-focus bg-surface-raised px-1.5 py-1 text-inherit outline-none ${className}`,
74
+ };
75
+ const control = multiline ? (
76
+ <textarea
77
+ {...shared}
78
+ rows={4}
79
+ onKeyDown={(event) => {
80
+ if (event.key === "Escape") abandon();
81
+ }}
82
+ />
83
+ ) : (
84
+ <input
85
+ {...shared}
86
+ onKeyDown={(event) => {
87
+ if (event.key === "Enter") commit();
88
+ if (event.key === "Escape") abandon();
89
+ }}
90
+ />
91
+ );
92
+ return (
93
+ <span data-pattern="EditableText" data-state="editing">
94
+ {control}
95
+ {maxLength !== undefined && <CharacterCount length={draft.length} max={maxLength} />}
96
+ </span>
97
+ );
98
+ }
99
+
100
+ return (
101
+ <button
102
+ type="button"
103
+ data-pattern="EditableText"
104
+ data-state={pending ? "pending" : "read"}
105
+ aria-label={ariaLabel}
106
+ aria-busy={pending ? "true" : undefined}
107
+ disabled={pending}
108
+ onClick={() => {
109
+ setDraft(value);
110
+ setEditing(true);
111
+ }}
112
+ className={`w-full rounded-sm border border-transparent px-1.5 py-1 text-left hover:border-border hover:bg-surface disabled:opacity-60 ${className}`}
113
+ >
114
+ {shown}
115
+ </button>
116
+ );
117
+ }
@@ -0,0 +1,25 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * @intent Proves the empty state (spec ogygie-ui B4): title, explanation and
4
+ * at most one action, every text coming from the caller.
5
+ */
6
+ import { afterEach, describe, expect, it } from "vitest";
7
+ import { cleanup, render, screen } from "@testing-library/react";
8
+ import { EmptyState } from "./EmptyState.js";
9
+
10
+ describe("EmptyState", () => {
11
+ afterEach(cleanup);
12
+
13
+ it("ÉTANT DONNÉ un titre, une explication et une action ALORS les trois se lisent", () => {
14
+ render(<EmptyState title="No row yet" explanation="Create the first one." action={<button type="button">Create</button>} />);
15
+ expect(screen.getByText("No row yet")).toBeDefined();
16
+ expect(screen.getByText("Create the first one.")).toBeDefined();
17
+ expect(screen.getByRole("button", { name: "Create" })).toBeDefined();
18
+ });
19
+
20
+ it("ÉTANT DONNÉ aucune action ALORS aucun bouton", () => {
21
+ const { container } = render(<EmptyState title="No match" explanation="Widen the filter." />);
22
+ expect(container.querySelector("button")).toBeNull();
23
+ expect(container.querySelector("[data-pattern='EmptyState']")).not.toBeNull();
24
+ });
25
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @intent An explicit empty state (D39): a title that names the CAUSE, an
3
+ * explanation that says what would fill it, and ONE creation action at most —
4
+ * an empty screen is the first screen every new user sees. The two causes of
5
+ * an empty view are told with different words by the caller (nothing recorded
6
+ * yet, or a filter that matches nothing): confusing them makes a reader doubt
7
+ * the data when they only searched too narrowly. Every text is a prop: the
8
+ * product translates, the pattern lays out.
9
+ */
10
+ import type { ReactNode } from "react";
11
+
12
+ export interface EmptyStateProps {
13
+ title: ReactNode;
14
+ explanation: ReactNode;
15
+ /** One creation action at most. */
16
+ action?: ReactNode;
17
+ }
18
+
19
+ export function EmptyState({ title, explanation, action }: EmptyStateProps) {
20
+ return (
21
+ <div
22
+ data-pattern="EmptyState"
23
+ className="rounded-md border border-dashed border-border bg-surface-raised px-6 py-10 text-center"
24
+ >
25
+ <p className="text-base font-medium text-text">{title}</p>
26
+ <p className="mt-1 text-sm text-text-muted">{explanation}</p>
27
+ {action !== undefined && <div className="mt-4 flex justify-center">{action}</div>}
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,46 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * @intent Proves the error surface (spec ogygie-ui B3): an OgygieFailure is
4
+ * shown in full — message, code, status, cause, suggested fix — under the
5
+ * product's title; anything else degrades to its message; a retry is a button
6
+ * the product labels.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
10
+ import { OgygieFailure, ogygieError } from "@ogygie/core/errors";
11
+ import { ErrorBanner } from "./ErrorBanner.js";
12
+
13
+ const failure = new OgygieFailure(
14
+ ogygieError({ code: "GRADE_IN_USE", message: "The row is still referenced.", file: null, cause: "3 holders", suggestedFix: "Move the holders first." }),
15
+ 409,
16
+ );
17
+
18
+ describe("ErrorBanner", () => {
19
+ afterEach(cleanup);
20
+
21
+ it("ÉTANT DONNÉ une OgygieFailure ALORS message, code, statut, cause et correction sont lisibles", () => {
22
+ render(<ErrorBanner error={failure} title="This change could not be saved" kind="save" />);
23
+ const alert = screen.getByRole("alert");
24
+ expect((alert).getAttribute("data-code")).toBe("GRADE_IN_USE");
25
+ expect((alert).getAttribute("data-kind")).toBe("save");
26
+ expect(screen.getByText("This change could not be saved")).toBeDefined();
27
+ expect(screen.getByText("The row is still referenced.")).toBeDefined();
28
+ expect(alert.textContent).toContain("GRADE_IN_USE · 409 — 3 holders");
29
+ expect(screen.getByText("Move the holders first.")).toBeDefined();
30
+ });
31
+
32
+ it("ÉTANT DONNÉ une erreur quelconque ALORS son message mène, sans code", () => {
33
+ render(<ErrorBanner error={new Error("boom")} />);
34
+ const alert = screen.getByRole("alert");
35
+ expect((alert).hasAttribute("data-code")).toBe(false);
36
+ expect((alert).getAttribute("data-kind")).toBe("load");
37
+ expect(screen.getByText("boom")).toBeDefined();
38
+ });
39
+
40
+ it("ÉTANT DONNÉ un retry ALORS un bouton libellé par le produit le déclenche", () => {
41
+ const onClick = vi.fn();
42
+ render(<ErrorBanner error={failure} retry={{ label: "Try again", onClick }} />);
43
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
44
+ expect(onClick).toHaveBeenCalledTimes(1);
45
+ });
46
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @intent Shows a failed view without taking the whole screen down (D39): the
3
+ * sentence the product puts on it, the failure's message, then its code, its
4
+ * cause and its suggested fix — so a reader can say what happened and an agent
5
+ * can act on it. The rest of the screen stays usable. Accepts ANY thrown
6
+ * value: an OgygieFailure is displayed in full, anything else degrades to its
7
+ * message. No text of its own: the title and the retry label come translated
8
+ * from the product.
9
+ */
10
+ import type { ReactNode } from "react";
11
+ import { failureOf, messageOf, statusOf } from "./failures.js";
12
+
13
+ export interface ErrorBannerProps {
14
+ error: unknown;
15
+ /** The sentence above the failure, worded by the product. Absent, the failure's message leads. */
16
+ title?: ReactNode;
17
+ /** A refused WRITE is not a view that failed to load: the product words its title accordingly, the banner marks it for styling and tests. */
18
+ kind?: "load" | "save";
19
+ /** A way back: re-run the read, close the panel. */
20
+ retry?: { label: ReactNode; onClick: () => void };
21
+ }
22
+
23
+ export function ErrorBanner({ error, title, kind = "load", retry }: ErrorBannerProps) {
24
+ const failure = failureOf(error);
25
+ const status = statusOf(error);
26
+ const message = messageOf(error);
27
+ return (
28
+ <div
29
+ role="alert"
30
+ data-pattern="ErrorBanner"
31
+ data-kind={kind}
32
+ data-code={failure?.code}
33
+ className="rounded-md border border-danger/40 bg-surface-raised px-4 py-3 text-sm text-text"
34
+ >
35
+ {title !== undefined && <p className="font-medium text-danger">{title}</p>}
36
+ <p className={title === undefined ? "font-medium text-danger" : "mt-1"}>{message}</p>
37
+ {failure !== null && (
38
+ <p className="mt-1 font-mono text-xs text-text-muted">
39
+ {failure.code}
40
+ {status === null ? "" : ` · ${status}`}
41
+ {failure.cause === "" ? "" : ` — ${failure.cause}`}
42
+ </p>
43
+ )}
44
+ {failure !== null && failure.suggestedFix !== "" && (
45
+ <p className="mt-1 text-xs text-text-muted">{failure.suggestedFix}</p>
46
+ )}
47
+ {retry !== undefined && (
48
+ <button
49
+ type="button"
50
+ onClick={retry.onClick}
51
+ className="mt-3 rounded-sm border border-border bg-surface px-3 py-1 text-xs font-medium text-text hover:bg-surface-raised"
52
+ >
53
+ {retry.label}
54
+ </button>
55
+ )}
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,104 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * @intent Proves the one screen-state component (spec ogygie-ui B2): denied
4
+ * before error, error before loading, loading before empty, the body last and
5
+ * only with data — each state carrying its data-state for a test to read.
6
+ */
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+ import { cleanup, render, screen } from "@testing-library/react";
9
+ import { OgygieFailure, ogygieError } from "@ogygie/core/errors";
10
+ import { ScreenState } from "./ScreenState.js";
11
+ import { failedQuery, pendingQuery, readyQuery } from "./query-like.js";
12
+
13
+ const denied = new OgygieFailure(ogygieError({ code: "WORKSPACE_OWNER_REQUIRED", message: "Only an owner may do this.", file: null, cause: "role member", suggestedFix: "Ask an owner." }), 403);
14
+ const broken = new OgygieFailure(ogygieError({ code: "CONTRACT_BROKEN", message: "The answer violates its contract.", file: null, cause: "items.0.code", suggestedFix: "Align both sides." }), 200);
15
+
16
+ const copy = { title: "Nothing here yet", explanation: "Create the first row." };
17
+ const stateOf = (container: HTMLElement): string | null => container.querySelector("[data-pattern='ScreenState']")?.getAttribute("data-state") ?? null;
18
+
19
+ describe("ScreenState", () => {
20
+ afterEach(cleanup);
21
+
22
+ it("ÉTANT DONNÉ une lecture en attente ALORS le squelette, et rien d'autre", () => {
23
+ const { container } = render(<ScreenState query={pendingQuery<string[]>()}>{(rows) => <ul>{rows.length}</ul>}</ScreenState>);
24
+ expect(stateOf(container)).toBe("loading");
25
+ expect((screen.getByRole("status")).getAttribute("aria-busy")).toBe("true");
26
+ });
27
+
28
+ it("ÉTANT DONNÉ une lecture en attente avec un chargement fourni ALORS il remplace le squelette", () => {
29
+ render(
30
+ <ScreenState query={pendingQuery<string[]>()} loading={<p>custom skeleton</p>}>
31
+ {() => null}
32
+ </ScreenState>,
33
+ );
34
+ expect(screen.getByText("custom skeleton")).toBeDefined();
35
+ });
36
+
37
+ it("ÉTANT DONNÉ une lecture en erreur ALORS un ErrorBanner avec le code, titré par le produit", () => {
38
+ const { container } = render(
39
+ <ScreenState query={failedQuery(broken)} errorTitle="This view could not be loaded">
40
+ {() => null}
41
+ </ScreenState>,
42
+ );
43
+ expect(stateOf(container)).toBe("error");
44
+ expect((screen.getByRole("alert")).getAttribute("data-code")).toBe("CONTRACT_BROKEN");
45
+ expect(screen.getByText("This view could not be loaded")).toBeDefined();
46
+ });
47
+
48
+ it("ÉTANT DONNÉ un refus 403 et une copie d'accès refusé ALORS l'explication, jamais un bandeau d'erreur", () => {
49
+ const { container } = render(
50
+ <ScreenState query={failedQuery(denied)} accessDenied={{ title: "Owners only", explanation: "Ask an owner." }}>
51
+ {() => null}
52
+ </ScreenState>,
53
+ );
54
+ expect(stateOf(container)).toBe("denied");
55
+ expect(screen.getByText("Owners only")).toBeDefined();
56
+ expect(screen.queryByRole("alert")).toBeNull();
57
+ });
58
+
59
+ it("ÉTANT DONNÉ un refus 403 sans copie ALORS le bandeau d'erreur dit le code", () => {
60
+ const { container } = render(<ScreenState query={failedQuery(denied)}>{() => null}</ScreenState>);
61
+ expect(stateOf(container)).toBe("error");
62
+ expect((screen.getByRole("alert")).getAttribute("data-code")).toBe("WORKSPACE_OWNER_REQUIRED");
63
+ });
64
+
65
+ it("ÉTANT DONNÉ le verdict de l'appelant (denied) ALORS l'explication avant toute lecture, même servie", () => {
66
+ const { container } = render(
67
+ <ScreenState query={readyQuery(["a"])} denied accessDenied={{ title: "Owners only", explanation: "Ask an owner." }}>
68
+ {(rows) => <p>{rows.join()}</p>}
69
+ </ScreenState>,
70
+ );
71
+ expect(stateOf(container)).toBe("denied");
72
+ expect(screen.queryByText("a")).toBeNull();
73
+ });
74
+
75
+ it("ÉTANT DONNÉ une collection vide et une copie d'état vide ALORS l'EmptyState avec son action", () => {
76
+ const { container } = render(
77
+ <ScreenState query={readyQuery<string[]>([])} isEmpty={(rows) => rows.length === 0} emptyState={{ ...copy, action: <button type="button">Add</button> }}>
78
+ {() => <p>body</p>}
79
+ </ScreenState>,
80
+ );
81
+ expect(stateOf(container)).toBe("empty");
82
+ expect(screen.getByRole("button", { name: "Add" })).toBeDefined();
83
+ expect(screen.queryByText("body")).toBeNull();
84
+ });
85
+
86
+ it("ÉTANT DONNÉ une collection vide sans copie ALORS le corps reçoit la collection vide", () => {
87
+ render(
88
+ <ScreenState query={readyQuery<string[]>([])} isEmpty={(rows) => rows.length === 0}>
89
+ {(rows) => <p>body {rows.length}</p>}
90
+ </ScreenState>,
91
+ );
92
+ expect(screen.getByText("body 0")).toBeDefined();
93
+ });
94
+
95
+ it("ÉTANT DONNÉ des données servies ALORS le corps, sans enveloppe d'état", () => {
96
+ const { container } = render(
97
+ <ScreenState query={readyQuery(["a", "b"])} isEmpty={(rows) => rows.length === 0} emptyState={copy}>
98
+ {(rows) => <ul>{rows.map((row) => <li key={row}>{row}</li>)}</ul>}
99
+ </ScreenState>,
100
+ );
101
+ expect(stateOf(container)).toBeNull();
102
+ expect(screen.getAllByRole("listitem")).toHaveLength(2);
103
+ });
104
+ });